Static website changes
This commit is contained in:
parent
bf4358de43
commit
655cf98579
38
.env.example
38
.env.example
|
|
@ -48,12 +48,24 @@ AWS_SECRET_ACCESS_KEY=ZpIxby1vbCLGB7CPWZsAltZBEqkhwzgBpFOKDGj8
|
|||
AWS_DEFAULT_REGION=ap-south-1
|
||||
AWS_BUCKET=rapportapp
|
||||
AWS_URL=https://did94hhhcgq1d.cloudfront.net
|
||||
# Amazon Bedrock (AI event quick-create). Uses Converse API.
|
||||
# 1) Bedrock console → Model access (ap-south-1): enable Meta Llama 3 8B / 70B.
|
||||
# Amazon Bedrock (AI event quick-create, concierge food/places summaries). Uses Converse API.
|
||||
# 1) Bedrock console → Model access (ap-south-1): enable Amazon Nova Micro/Lite and Titan Embeddings.
|
||||
# 2) IAM user/role needs bedrock:InvokeModel + bedrock:Converse on those foundation models.
|
||||
# See bedrock-iam-policy.example.json — attach to the same user as AWS_ACCESS_KEY_ID (e.g. rapport_upload).
|
||||
# ap-south-1 model IDs: meta.llama3-8b-instruct-v1:0, meta.llama3-70b-instruct-v1:0
|
||||
BEDROCK_MODEL_ID=meta.llama3-8b-instruct-v1:0
|
||||
# Default chat/extract / Call 2 model (code fallback: meta.llama3-8b-instruct-v1:0):
|
||||
# BEDROCK_MODEL_ID=meta.llama3-8b-instruct-v1:0
|
||||
# Call 1 intent router (Nova Micro → auto-resolved to apac.amazon.nova-micro-v1:0 in ap-south-1)
|
||||
# BEDROCK_ROUTER_MODEL_ID=amazon.nova-micro-v1:0
|
||||
# BEDROCK_ROUTER_MODEL_FALLBACKS=amazon.nova-lite-v1:0
|
||||
# Food/places summaries use amazon.nova-micro-v1:0 → auto-resolved to apac.amazon.nova-micro-v1:0 in ap-south-1
|
||||
# BEDROCK_RECOMMENDATION_MODEL_ID=amazon.nova-micro-v1:0
|
||||
# BEDROCK_RECOMMENDATION_MODEL_FALLBACKS=amazon.nova-lite-v1:0,meta.llama3-8b-instruct-v1:0
|
||||
# Nova in APAC requires inference profiles — IAM must allow arn:...:inference-profile/apac.amazon.nova-*
|
||||
# and foundation-model ARNs in the regions the profile routes to (see bedrock-iam-policy.example.json).
|
||||
# Attach that policy to the same IAM user as AWS_ACCESS_KEY_ID (e.g. rapport_upload).
|
||||
# BEDROCK_USE_INFERENCE_PROFILES=auto
|
||||
# Log full LLM request/response payloads (also enabled when CONCIERGE_DEBUG=true). Call1/Call2 always log truncated payloads.
|
||||
# BEDROCK_LOG_LLM=true
|
||||
# Use only AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY here, OR AWS_PROFILE in your shell — not both (SDK warning).
|
||||
# Optional: region override (defaults to AWS_DEFAULT_REGION). For Llama 3.1: BEDROCK_REGION=us-east-1
|
||||
# BEDROCK_REGION=ap-south-1
|
||||
|
|
@ -71,6 +83,11 @@ BEDROCK_MODEL_ID=meta.llama3-8b-instruct-v1:0
|
|||
# REDIS_URL=redis://127.0.0.1:6379
|
||||
# REDIS_TLS=true
|
||||
# CONCIERGE_QUEUE_PREFIX=eventbuzz:concierge:
|
||||
# The embedded concierge always uses the two-stage pipeline (Call 1 router + intent
|
||||
# handlers + optional Call 2 RAG). The legacy single-stage pipeline was removed.
|
||||
# Verbose per-turn pipeline logging to the console, and returns a `debug` turn log in the chat
|
||||
# response payload. Keep off in production.
|
||||
# CONCIERGE_DEBUG=false
|
||||
# Concierge URL crawl (multi-page ingest for knowledge sources)
|
||||
# CONCIERGE_CRAWL_MAX_PAGES=50
|
||||
# CONCIERGE_CRAWL_MAX_ASSETS=20
|
||||
|
|
@ -79,6 +96,15 @@ BEDROCK_MODEL_ID=meta.llama3-8b-instruct-v1:0
|
|||
# CONCIERGE_CRAWL_DELAY_MS=200
|
||||
# CONCIERGE_CRAWL_MAX_ASSET_BYTES=15728640
|
||||
|
||||
# Concierge food/places knowledge sync — Google Places + Bedrock summarization
|
||||
# GOOGLE_PLACES_API_KEY=
|
||||
# Stored in the default MONGODB_URI database (collections concierge_food, concierge_places)
|
||||
# CONCIERGE_FOOD_COLLECTION=concierge_food
|
||||
# CONCIERGE_PLACES_COLLECTION=concierge_places
|
||||
# CONCIERGE_ACCOMMODATION_COLLECTION=concierge_accommodation
|
||||
# Optional DB override (default: database name from MONGODB_URI)
|
||||
# CONCIERGE_RECOMMENDATION_DB=
|
||||
|
||||
# HMAC secret for public form captcha tokens (falls back to JWT_SECRET if unset)
|
||||
# FORM_CAPTCHA_SECRET=
|
||||
|
||||
|
|
@ -101,9 +127,13 @@ MICROSITE_API_BASE_URL=http://localhost:3001/api
|
|||
# Seed bundled microsite templates: npm run seed:global-microsite-v2-templates
|
||||
# Or one template: npm run seed:eventify-template / npm run seed:eventre-template
|
||||
# Dev: read template.json + template.html from disk instead of MongoDB/S3 (by template slug).
|
||||
# Also serves CSS/JS/images from local assets for Preview via
|
||||
# /api/public/microsite-v2-local/{slug}/assets/...
|
||||
# MICROSITE_V2_READ_FROM_LOCAL=true
|
||||
# Optional override for the templates folder (default: backend/microsite-v2-templates)
|
||||
# MICROSITE_V2_TEMPLATES_DIR=/path/to/microsite-v2-templates
|
||||
# Absolute API origin used in Preview asset URLs (defaults to request host, e.g. http://localhost:3001)
|
||||
# API_PUBLIC_ORIGIN=http://localhost:3001
|
||||
#
|
||||
# Published-site target: s3 (default) | ec2. When ec2, rendered index.html +
|
||||
# config.json are uploaded via SFTP to {REMOTE_ROOT}/{subdomain}/ on the EC2 box.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "BedrockInvokeLlamaForEventBuzz",
|
||||
"Sid": "BedrockInvokeForEventBuzz",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"bedrock:InvokeModel",
|
||||
|
|
@ -11,6 +11,10 @@
|
|||
"bedrock:ConverseStream"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:bedrock:ap-south-1:*:inference-profile/apac.amazon.nova-micro-v1:0",
|
||||
"arn:aws:bedrock:ap-south-1:*:inference-profile/apac.amazon.nova-lite-v1:0",
|
||||
"arn:aws:bedrock:*::foundation-model/amazon.nova-micro-v1:0",
|
||||
"arn:aws:bedrock:*::foundation-model/amazon.nova-lite-v1:0",
|
||||
"arn:aws:bedrock:ap-south-1::foundation-model/meta.llama3-8b-instruct-v1:0",
|
||||
"arn:aws:bedrock:ap-south-1::foundation-model/meta.llama3-70b-instruct-v1:0",
|
||||
"arn:aws:bedrock:ap-south-1::foundation-model/amazon.titan-embed-text-v2:0",
|
||||
|
|
|
|||
|
|
@ -324,7 +324,7 @@ a.back-to-top:hover, a.back-to-top:focus {
|
|||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 9999999999;
|
||||
z-index: 9998;
|
||||
}
|
||||
|
||||
.sk-circle {
|
||||
|
|
@ -3429,7 +3429,7 @@ body.eventup-marketing-popup-open {
|
|||
.eventup-marketing-popup {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000000001;
|
||||
z-index: 10000;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
|
|||
|
|
@ -313,7 +313,7 @@ a.back-to-top:hover, a.back-to-top:focus {
|
|||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 9999999999;
|
||||
z-index: 9998;
|
||||
}
|
||||
|
||||
.sk-circle {
|
||||
|
|
@ -3235,7 +3235,7 @@ body.eventup-marketing-popup-open {
|
|||
.eventup-marketing-popup {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000000001;
|
||||
z-index: 10000;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
|
|||
|
|
@ -8,14 +8,18 @@
|
|||
<body style="font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial; padding: 24px;">
|
||||
<h1>Concierge widget demo</h1>
|
||||
<p>Set a real bot id below.</p>
|
||||
<p style="color:#6b7280; font-size:14px; max-width:40rem;">
|
||||
To reopen a prior chat, append the WebChatSessions document id:
|
||||
<code>?session=<mongoObjectId></code>
|
||||
(the public <code>sessionId</code> string also works).
|
||||
</p>
|
||||
|
||||
<script>
|
||||
window.EB_CONCIERGE = {
|
||||
botId: "6a16c86483f93a9c9d04220d",
|
||||
botId: "6a61ddf778df0ea305244efc",
|
||||
apiBase: (location.origin.replace(/\/$/, "") + "/api"),
|
||||
};
|
||||
</script>
|
||||
<script async src="/embed/concierge-widget.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,34 @@
|
|||
--ebc-border:#e5e7eb;
|
||||
--ebc-primary:#2563eb;
|
||||
--ebc-primary-fg:#ffffff;
|
||||
--ebc-z-root:2147483647;
|
||||
--ebc-z-launcher:2147483647;
|
||||
--ebc-z-panel:2147483647;
|
||||
}
|
||||
|
||||
/* Full-viewport portal — pointer-events pass through except on chat controls. */
|
||||
#eventbuzz-concierge-root{
|
||||
position:fixed;
|
||||
inset:0;
|
||||
width:100%;
|
||||
height:100%;
|
||||
margin:0;
|
||||
padding:0;
|
||||
border:0;
|
||||
z-index:var(--ebc-z-root) !important;
|
||||
pointer-events:none;
|
||||
isolation:isolate;
|
||||
}
|
||||
|
||||
#eventbuzz-concierge-root .ebc-launcher,
|
||||
#eventbuzz-concierge-root .ebc-panel{
|
||||
pointer-events:auto;
|
||||
}
|
||||
|
||||
.ebc-launcher{
|
||||
position:fixed; right:18px; bottom:18px;
|
||||
z-index:var(--ebc-z-launcher) !important;
|
||||
isolation:isolate;
|
||||
width:54px; height:54px; border-radius:999px;
|
||||
border:1px solid var(--ebc-border);
|
||||
background:var(--ebc-primary); color:var(--ebc-primary-fg);
|
||||
|
|
@ -16,6 +41,8 @@
|
|||
}
|
||||
.ebc-panel{
|
||||
position:fixed; right:18px; bottom:84px;
|
||||
z-index:var(--ebc-z-panel) !important;
|
||||
isolation:isolate;
|
||||
width:340px; height:460px;
|
||||
border:1px solid var(--ebc-border);
|
||||
background:var(--ebc-bg); color:var(--ebc-fg);
|
||||
|
|
@ -34,10 +61,10 @@
|
|||
.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{ max-width:85%; padding:9px 10px; border-radius:12px; border:1px solid var(--ebc-border); background:#f9fafb; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.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-intro{ margin:0 0 9px; color:var(--ebc-fg); white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.ebc-card-list{ display:flex; flex-direction:column; gap:8px; }
|
||||
.ebc-card{
|
||||
display:grid; grid-template-columns:24px 1fr; gap:8px;
|
||||
|
|
@ -100,6 +127,42 @@
|
|||
padding:3px 7px; border-radius:999px;
|
||||
background:#fff7ed; color:#9a3412; font-size:11px;
|
||||
}
|
||||
|
||||
/* Generic structured cards (ticket, registration, check-in, join, session, hotel) */
|
||||
.ebc-msg.ebc-cards{ max-width:96%; background:#ffffff; }
|
||||
.ebc-cards-list{ display:flex; flex-direction:column; gap:9px; }
|
||||
.ebc-hotel-icon{
|
||||
width:26px; height:26px; border-radius:8px;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
background:#eef2ff; color:#4338ca; font-size:14px; flex:0 0 auto;
|
||||
}
|
||||
.ebc-place-price{ color:#047857; font-size:12px; font-weight:700; }
|
||||
.ebc-place-links{ display:flex; gap:12px; flex-wrap:wrap; }
|
||||
|
||||
.ebc-ticket-card,
|
||||
.ebc-action-card,
|
||||
.ebc-session-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-ticket-head{ display:flex; align-items:center; justify-content:space-between; gap:8px; }
|
||||
.ebc-ticket-price{
|
||||
padding:3px 8px; border-radius:999px;
|
||||
background:#ecfdf5; color:#047857; font-size:12px; font-weight:750; white-space:nowrap;
|
||||
}
|
||||
.ebc-ticket-points{ margin:0; padding-left:18px; color:var(--ebc-fg); font-size:12px; display:flex; flex-direction:column; gap:3px; }
|
||||
.ebc-action-title{ font-weight:700; font-size:13px; }
|
||||
.ebc-action-btn{
|
||||
align-self:flex-start; text-decoration:none;
|
||||
background:var(--ebc-primary); color:var(--ebc-primary-fg);
|
||||
border-radius:9px; padding:7px 12px; font-size:12px; font-weight:700;
|
||||
}
|
||||
.ebc-action-btn:hover{ filter:brightness(.95); }
|
||||
.ebc-action-disabled{
|
||||
background:#f3f4f6; color:var(--ebc-muted); cursor:not-allowed;
|
||||
}
|
||||
.ebc-inputbar{
|
||||
padding:10px; border-top:1px solid var(--ebc-border);
|
||||
display:flex; gap:8px; align-items:center;
|
||||
|
|
|
|||
|
|
@ -10,10 +10,22 @@
|
|||
|
||||
var storageKey = 'ebc_session_' + botId;
|
||||
var sessionId = null;
|
||||
// A page refresh starts a new chat. Remove IDs saved by older widget builds so
|
||||
// participant identity is never silently restored from localStorage.
|
||||
// A page refresh starts a new chat unless ?session=<WebChatSession id> is present.
|
||||
// Remove IDs saved by older widget builds so participant identity is never
|
||||
// silently restored from localStorage.
|
||||
try { localStorage.removeItem(storageKey); } catch {}
|
||||
|
||||
function readSessionRefFromUrl() {
|
||||
try {
|
||||
var params = new URLSearchParams(window.location.search || '');
|
||||
return String(params.get('session') || params.get('sessionId') || cfg.sessionId || '').trim();
|
||||
} catch (e) {
|
||||
return String(cfg.sessionId || '').trim();
|
||||
}
|
||||
}
|
||||
|
||||
var resumeSessionRef = readSessionRefFromUrl();
|
||||
|
||||
function el(tag, attrs, children) {
|
||||
var n = document.createElement(tag);
|
||||
if (attrs) for (var k in attrs) n.setAttribute(k, attrs[k]);
|
||||
|
|
@ -23,12 +35,13 @@
|
|||
|
||||
function addCssOnce() {
|
||||
if (document.getElementById('ebc-css')) return;
|
||||
var link = el('link', { id: 'ebc-css', rel: 'stylesheet', href: apiBase.replace(/\/api$/, '') + '/embed/concierge-widget.css' });
|
||||
var link = el('link', { id: 'ebc-css', rel: 'stylesheet', href: apiBase.replace(/\/api$/, '') + '/embed/concierge-widget.css?v=5' });
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
addCssOnce();
|
||||
|
||||
var root = el('div', { id: 'eventbuzz-concierge-root', 'data-eventbuzz-concierge': 'root' });
|
||||
var launcher = el('button', { class: 'ebc-launcher', type: 'button', 'aria-label': 'Chat with event concierge' }, ['Chat']);
|
||||
var panel = el('div', { class: 'ebc-panel', role: 'dialog', 'aria-label': 'Event concierge chat' });
|
||||
var header = el('div', { class: 'ebc-header' });
|
||||
|
|
@ -53,8 +66,34 @@
|
|||
panel.appendChild(messages);
|
||||
panel.appendChild(error);
|
||||
panel.appendChild(inputBar);
|
||||
document.body.appendChild(launcher);
|
||||
document.body.appendChild(panel);
|
||||
root.appendChild(launcher);
|
||||
root.appendChild(panel);
|
||||
document.body.appendChild(root);
|
||||
|
||||
function ensureOnTop() {
|
||||
if (!root.parentNode) {
|
||||
document.body.appendChild(root);
|
||||
return;
|
||||
}
|
||||
if (document.body.lastElementChild !== root) {
|
||||
document.body.appendChild(root);
|
||||
}
|
||||
}
|
||||
|
||||
ensureOnTop();
|
||||
if (typeof MutationObserver !== 'undefined') {
|
||||
var topTimer = null;
|
||||
var observer = new MutationObserver(function () {
|
||||
if (topTimer) return;
|
||||
topTimer = setTimeout(function () {
|
||||
topTimer = null;
|
||||
ensureOnTop();
|
||||
}, 50);
|
||||
});
|
||||
observer.observe(document.body, { childList: true });
|
||||
} else {
|
||||
setInterval(ensureOnTop, 1000);
|
||||
}
|
||||
|
||||
function setError(msg) {
|
||||
if (!msg) {
|
||||
|
|
@ -129,84 +168,219 @@
|
|||
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 || '');
|
||||
'&query_place_id=' + encodeURIComponent(place.placeId || place.foodId || place.hotelId || '');
|
||||
}
|
||||
return 'https://www.google.com/maps/search/?api=1&query=' + encodeURIComponent((place.name || '') + ' ' + (place.address || ''));
|
||||
}
|
||||
|
||||
// Only allow http/https links to reach the DOM; anything else is neutralized.
|
||||
function safeUrl(url) {
|
||||
var s = String(url || '').trim();
|
||||
return /^https?:\/\//i.test(s) ? s : '';
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '';
|
||||
var d = new Date(value);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
try {
|
||||
return d.toLocaleString([], { weekday: 'short', hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' });
|
||||
} catch (e) {
|
||||
return d.toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
function priceLevelLabel(level) {
|
||||
var n = Number(level);
|
||||
if (!isFinite(n) || n <= 0) return '';
|
||||
return new Array(Math.min(4, n) + 1).join('₹');
|
||||
}
|
||||
|
||||
function buildPlaceCard(place, opts) {
|
||||
opts = opts || {};
|
||||
var card = el('article', { class: 'ebc-place-card' });
|
||||
var head = el('div', { class: 'ebc-place-head' });
|
||||
head.appendChild(el('div', { class: opts.iconClass || 'ebc-place-pin', 'aria-hidden': 'true' }, [opts.icon || '⌖']));
|
||||
var titleWrap = el('div', { class: 'ebc-place-title-wrap' });
|
||||
titleWrap.appendChild(el('h3', { class: 'ebc-place-name' }, [place.name || opts.fallbackName || '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]));
|
||||
|
||||
var priceLabel = priceLevelLabel(place.priceLevel);
|
||||
if (priceLabel) card.appendChild(el('div', { class: 'ebc-place-price' }, [priceLabel]));
|
||||
|
||||
var tags = Array.isArray(opts.tags) ? opts.tags : (Array.isArray(place.tags) ? place.tags : []);
|
||||
if (tags.length) {
|
||||
var tagWrap = el('div', { class: 'ebc-place-tags' });
|
||||
tags.slice(0, 3).forEach(function (tag) {
|
||||
tagWrap.appendChild(el('span', { class: opts.tagClass || 'ebc-place-tag' }, [String(tag).replace(/_/g, ' ')]));
|
||||
});
|
||||
card.appendChild(tagWrap);
|
||||
}
|
||||
|
||||
var links = el('div', { class: 'ebc-place-links' });
|
||||
links.appendChild(el('a', { class: 'ebc-place-map', href: mapsUrl(place), target: '_blank', rel: 'noopener noreferrer' }, ['Open map']));
|
||||
var site = safeUrl(place.website);
|
||||
if (site) links.appendChild(el('a', { class: 'ebc-place-map', href: site, target: '_blank', rel: 'noopener noreferrer' }, ['Website']));
|
||||
card.appendChild(links);
|
||||
return card;
|
||||
}
|
||||
|
||||
function buildFoodCard(food) {
|
||||
return buildPlaceCard(food, {
|
||||
icon: '🍽', iconClass: 'ebc-food-icon',
|
||||
fallbackName: 'Unnamed restaurant',
|
||||
tags: Array.isArray(food.cuisines) ? food.cuisines : [],
|
||||
tagClass: 'ebc-food-tag',
|
||||
});
|
||||
}
|
||||
|
||||
function buildAccommodationCard(hotel) {
|
||||
return buildPlaceCard(hotel, { icon: '🏨', iconClass: 'ebc-hotel-icon', fallbackName: 'Unnamed stay' });
|
||||
}
|
||||
|
||||
function buildTicketCard(t) {
|
||||
var card = el('article', { class: 'ebc-ticket-card' });
|
||||
var head = el('div', { class: 'ebc-ticket-head' });
|
||||
head.appendChild(el('h3', { class: 'ebc-place-name' }, [t.name || 'Ticket']));
|
||||
if (t.price) head.appendChild(el('span', { class: 'ebc-ticket-price' }, [String(t.price)]));
|
||||
card.appendChild(head);
|
||||
if (t.description) card.appendChild(el('p', { class: 'ebc-place-address' }, [t.description]));
|
||||
if (Array.isArray(t.keyPoints) && t.keyPoints.length) {
|
||||
var ul = el('ul', { class: 'ebc-ticket-points' });
|
||||
t.keyPoints.slice(0, 5).forEach(function (kp) {
|
||||
ul.appendChild(el('li', {}, [String(kp)]));
|
||||
});
|
||||
card.appendChild(ul);
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
function buildActionCard(opts) {
|
||||
var card = el('article', { class: 'ebc-action-card' });
|
||||
if (opts.title) card.appendChild(el('div', { class: 'ebc-action-title' }, [opts.title]));
|
||||
if (opts.note) card.appendChild(el('div', { class: 'ebc-meta' }, [opts.note]));
|
||||
var url = safeUrl(opts.url);
|
||||
if (url) {
|
||||
card.appendChild(el('a', { class: 'ebc-action-btn', href: url, target: '_blank', rel: 'noopener noreferrer' }, [opts.label || 'Open']));
|
||||
} else if (opts.label) {
|
||||
card.appendChild(el('span', { class: 'ebc-action-btn ebc-action-disabled' }, [opts.label]));
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
function buildSessionJoinCard(s) {
|
||||
var card = el('article', { class: 'ebc-session-card' });
|
||||
card.appendChild(el('h3', { class: 'ebc-place-name' }, [s.title || 'Session']));
|
||||
var when = formatDateTime(s.startTime);
|
||||
if (when) card.appendChild(el('div', { class: 'ebc-meta' }, [when]));
|
||||
var url = safeUrl(s.url);
|
||||
if (url) card.appendChild(el('a', { class: 'ebc-action-btn', href: url, target: '_blank', rel: 'noopener noreferrer' }, ['Join session']));
|
||||
return card;
|
||||
}
|
||||
|
||||
// Dispatch a single {type, data} card to its renderer. Unknown types render
|
||||
// as a minimal titled note so new backend card types degrade gracefully.
|
||||
function renderCard(card) {
|
||||
var type = card && card.type;
|
||||
var data = (card && card.data) || {};
|
||||
switch (type) {
|
||||
case 'food': return buildFoodCard(data);
|
||||
case 'place': return buildPlaceCard(data);
|
||||
case 'accommodation': return buildAccommodationCard(data);
|
||||
case 'ticket': return buildTicketCard(data);
|
||||
case 'registration': return buildActionCard({
|
||||
title: data.open === false ? 'Registration closed' : 'Registration',
|
||||
note: data.deadline ? ('Deadline: ' + formatDateTime(data.deadline)) : '',
|
||||
url: data.open === false ? '' : data.url,
|
||||
label: data.label || (data.open === false ? 'Registration closed' : 'Register'),
|
||||
});
|
||||
case 'checkin': return buildActionCard({
|
||||
title: data.method === 'web' ? 'Online check-in' : 'Check-in',
|
||||
url: data.url,
|
||||
label: data.label || 'Check in',
|
||||
});
|
||||
case 'join': return buildActionCard({
|
||||
title: 'Join online',
|
||||
url: data.url,
|
||||
label: data.label || 'Join online',
|
||||
});
|
||||
case 'session_join': return buildSessionJoinCard(data);
|
||||
default:
|
||||
if (data && (data.name || data.title || data.label)) {
|
||||
return buildActionCard({ title: data.name || data.title || '', url: data.url, label: data.label });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function appendCards(bubble, text, cards) {
|
||||
bubble.classList.add('ebc-rich', 'ebc-cards');
|
||||
if (text) bubble.appendChild(el('p', { class: 'ebc-intro' }, [text]));
|
||||
var list = el('div', { class: 'ebc-cards-list' });
|
||||
cards.forEach(function (card) {
|
||||
var node = renderCard(card);
|
||||
if (node) list.appendChild(node);
|
||||
});
|
||||
if (list.childNodes.length) bubble.appendChild(list);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
places.forEach(function (place) { list.appendChild(buildPlaceCard(place)); });
|
||||
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);
|
||||
});
|
||||
foods.forEach(function (food) { list.appendChild(buildFoodCard(food)); });
|
||||
bubble.appendChild(list);
|
||||
}
|
||||
|
||||
function addMessage(role, text, places, foods) {
|
||||
function scrollAnchorToTop(anchorEl) {
|
||||
if (!anchorEl || !messages.contains(anchorEl)) {
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
return;
|
||||
}
|
||||
// Align the user's last message to the top of the visible transcript so the
|
||||
// reply can be read from the start without scrolling up.
|
||||
var delta = anchorEl.getBoundingClientRect().top - messages.getBoundingClientRect().top;
|
||||
messages.scrollTop += delta;
|
||||
}
|
||||
|
||||
function addMessage(role, text, opts) {
|
||||
opts = opts || {};
|
||||
var cards = Array.isArray(opts.cards) ? opts.cards : [];
|
||||
var places = Array.isArray(opts.places) ? opts.places : [];
|
||||
var foods = Array.isArray(opts.foods) ? opts.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);
|
||||
if (role === 'assistant' && cards.length) appendCards(bubble, text, cards);
|
||||
else if (role === 'assistant' && foods.length) appendFoodCards(bubble, text, foods);
|
||||
else if (role === 'assistant' && places.length) appendPlaceCards(bubble, text, places);
|
||||
else if (role === 'assistant') appendAssistantContent(bubble, text);
|
||||
else bubble.textContent = text;
|
||||
messages.appendChild(bubble);
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
|
||||
if (opts.anchorToTop) {
|
||||
// Wait a frame so layout (cards / pre-wrap height) is settled.
|
||||
requestAnimationFrame(function () {
|
||||
scrollAnchorToTop(opts.anchorToTop);
|
||||
});
|
||||
} else if (opts.scroll !== false) {
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
}
|
||||
return bubble;
|
||||
}
|
||||
|
||||
function openPanel() {
|
||||
ensureOnTop();
|
||||
panel.classList.add('ebc-open');
|
||||
input.focus();
|
||||
}
|
||||
|
|
@ -215,6 +389,7 @@
|
|||
}
|
||||
|
||||
launcher.addEventListener('click', function () {
|
||||
ensureOnTop();
|
||||
if (panel.classList.contains('ebc-open')) closePanel();
|
||||
else openPanel();
|
||||
});
|
||||
|
|
@ -243,13 +418,45 @@
|
|||
});
|
||||
}
|
||||
|
||||
// Load config
|
||||
// Load config (and optionally resume a prior WebChatSession from ?session=)
|
||||
http('/public/concierge/' + encodeURIComponent(botId) + '/config').then(function (res) {
|
||||
if (res.json && res.json.success && res.json.data) {
|
||||
title.textContent = res.json.data.eventName ? ('Concierge · ' + res.json.data.eventName) : 'Event Concierge';
|
||||
var greet = res.json.data.greeting;
|
||||
if (greet) addMessage('assistant', greet);
|
||||
if (res.json.data.maxMessageLength) input.setAttribute('maxlength', String(res.json.data.maxMessageLength));
|
||||
|
||||
var greet = res.json.data.greeting;
|
||||
if (!resumeSessionRef) {
|
||||
if (greet) addMessage('assistant', greet);
|
||||
return;
|
||||
}
|
||||
|
||||
return http(
|
||||
'/public/concierge/' + encodeURIComponent(botId) + '/session/' + encodeURIComponent(resumeSessionRef)
|
||||
).then(function (sessionRes) {
|
||||
if (!sessionRes.json || !sessionRes.json.success || !sessionRes.json.data) {
|
||||
setError((sessionRes.json && sessionRes.json.error && sessionRes.json.error.message)
|
||||
? sessionRes.json.error.message
|
||||
: 'Could not load that chat session');
|
||||
if (greet) addMessage('assistant', greet);
|
||||
return;
|
||||
}
|
||||
var data = sessionRes.json.data;
|
||||
sessionId = data.sessionId || null;
|
||||
var history = Array.isArray(data.history) ? data.history : [];
|
||||
if (!history.length) {
|
||||
if (greet) addMessage('assistant', greet);
|
||||
} else {
|
||||
history.forEach(function (m) {
|
||||
if (!m || !m.text) return;
|
||||
addMessage(m.role === 'user' ? 'user' : 'assistant', m.text, { scroll: false });
|
||||
});
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
openPanel();
|
||||
}
|
||||
}).catch(function () {
|
||||
setError('Could not load that chat session');
|
||||
if (greet) addMessage('assistant', greet);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -258,7 +465,7 @@
|
|||
if (!text) return;
|
||||
setError(null);
|
||||
input.value = '';
|
||||
addMessage('user', text);
|
||||
var userBubble = addMessage('user', text, { scroll: false });
|
||||
sendBtn.disabled = true;
|
||||
|
||||
http('/public/concierge/' + encodeURIComponent(botId) + '/chat', {
|
||||
|
|
@ -273,11 +480,16 @@
|
|||
if (res.json.data && res.json.data.sessionId) {
|
||||
sessionId = res.json.data.sessionId;
|
||||
}
|
||||
var data = res.json.data || {};
|
||||
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 : []
|
||||
data.reply ? data.reply : '…',
|
||||
{
|
||||
cards: Array.isArray(data.cards) ? data.cards : [],
|
||||
places: Array.isArray(data.places) ? data.places : [],
|
||||
foods: Array.isArray(data.foods) ? data.foods : [],
|
||||
anchorToTop: userBubble,
|
||||
}
|
||||
);
|
||||
// Log detected intent to browser console when backend returns it
|
||||
if (res.json.data && res.json.data.intent) {
|
||||
|
|
|
|||
|
|
@ -1,40 +1,39 @@
|
|||
require('dotenv').config();
|
||||
const { MongoClient } = require('mongodb');
|
||||
const {
|
||||
fetchFoodRecordForEvent,
|
||||
getDbName,
|
||||
getFoodCollectionName,
|
||||
} = require('../src/utils/conciergeRecommendationDb');
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const MONGO_URL = process.env.MONGODB_URI || 'mongodb://realworldmodb:pjTzlem85s@64.227.152.139:27017/reallives_serverless_eb?authSource=admin';
|
||||
const client = new MongoClient(MONGO_URL, { useNewUrlParser: true, useUnifiedTopology: true });
|
||||
await client.connect();
|
||||
|
||||
const db = client.db('reallives_serverless_eb');
|
||||
const coll = db.collection('Food');
|
||||
const doc = await coll.findOne({ eventId: '69ce3c91be56b4a3b09b2333' });
|
||||
|
||||
const eventId = process.argv[2] || '69ce3c91be56b4a3b09b2333';
|
||||
const doc = await fetchFoodRecordForEvent(eventId);
|
||||
|
||||
if (!doc) {
|
||||
console.log('No food document found');
|
||||
await client.close();
|
||||
console.log(`No food document found in ${getDbName()}.${getFoodCollectionName()}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log('Document ID:', doc._id);
|
||||
console.log('Event ID:', doc.eventId);
|
||||
console.log('Location:', doc.location);
|
||||
console.log('Number of places:', doc.places?.length || 0);
|
||||
console.log('\nRecommendations:\n', doc.recommendations || 'None');
|
||||
|
||||
const raw = doc.raw || doc;
|
||||
console.log('Database:', getDbName());
|
||||
console.log('Collection:', getFoodCollectionName());
|
||||
console.log('Document ID:', raw._id);
|
||||
console.log('Event ID:', raw.eventId);
|
||||
console.log('Location:', raw.location);
|
||||
console.log('Number of restaurants:', raw.restaurants?.length || 0);
|
||||
console.log('\nRecommendations:\n', raw.recommendations || 'None');
|
||||
console.log('');
|
||||
console.log('Full document JSON:\n', JSON.stringify(doc, null, 2));
|
||||
console.log('\nPlaces sample (first 5):');
|
||||
(doc.places || []).slice(0, 5).forEach((p, i) => {
|
||||
console.log(` ${i+1}. ${p.name}`);
|
||||
console.log('Full document JSON:\n', JSON.stringify(raw, null, 2));
|
||||
console.log('\nRestaurants sample (first 5):');
|
||||
(raw.restaurants || []).slice(0, 5).forEach((p, i) => {
|
||||
console.log(` ${i + 1}. ${p.name}`);
|
||||
console.log(` Address: ${p.address}`);
|
||||
console.log(` Rating: ${p.rating}/5 (${p.user_ratings_total} reviews)`);
|
||||
console.log(` Cuisines: ${p.cuisines?.join(', ')}`);
|
||||
console.log(` Distance: ${(p.distance_meters / 1000).toFixed(2)} km`);
|
||||
console.log(` Distance: ${p.distance_meters ? (p.distance_meters / 1000).toFixed(2) + ' km' : 'N/A'}`);
|
||||
console.log('');
|
||||
});
|
||||
|
||||
await client.close();
|
||||
} catch (e) {
|
||||
console.error('Error:', e.message);
|
||||
process.exit(1);
|
||||
|
|
|
|||
|
|
@ -4,11 +4,18 @@ const ConciergeBot = require('../models/ConciergeBot');
|
|||
const Event = require('../models/Event');
|
||||
const WebChatSession = require('../models/WebChatSession');
|
||||
const { success, error } = require('../utils/response');
|
||||
const { runConciergePipeline } = require('../services/conciergePipeline.service');
|
||||
const { interpolateConciergeGreeting } = require('../utils/conciergeGreeting');
|
||||
const { runTwoStage } = require('../services/conciergeResponse.service');
|
||||
const {
|
||||
createTurnLog,
|
||||
finalizeTurnLog,
|
||||
logTurnConsole,
|
||||
isDebugEnabled,
|
||||
} = require('../utils/conciergePipelineLog');
|
||||
|
||||
const MAX_MESSAGE_LENGTH = 800;
|
||||
const MAX_HISTORY_MESSAGES = 40;
|
||||
const MAX_PIPELINE_LOG = 40;
|
||||
const SESSION_IDLE_MS = 30 * 60 * 1000;
|
||||
|
||||
// Basic in-memory rate limit (best-effort).
|
||||
|
|
@ -34,7 +41,7 @@ async function loadBotOr404(botId) {
|
|||
const bot = await ConciergeBot.findById(botId).lean();
|
||||
if (!bot) return { err: error('NOT_FOUND', 'Chatbot not found') };
|
||||
if (!bot.enabled || bot.status !== 'ACTIVE') return { err: error('FORBIDDEN', 'Chatbot is not enabled') };
|
||||
const event = await Event.findById(bot.event).select('name').lean();
|
||||
const event = await Event.findById(bot.event).select('name location locationType').lean();
|
||||
return { bot, event };
|
||||
}
|
||||
|
||||
|
|
@ -110,61 +117,25 @@ const chat = async (request, h) => {
|
|||
}
|
||||
session.lastSeenAt = new Date();
|
||||
|
||||
const historyForPrompt = session.history.slice(-12).map((m) => ({ role: m.role, text: m.text }));
|
||||
const includeEventDetails = bot.knowledge?.includeEventDetails !== false;
|
||||
const includeFoodRecommendations = bot.knowledge?.includeFoodRecommendations !== false;
|
||||
const DEFAULT_FALLBACK = "I don't have that in my knowledge base yet. Please check with event staff or try another question.";
|
||||
|
||||
const { determineIntentCategory } = require('../utils/conciergeIntent');
|
||||
const turnLog = createTurnLog({ sessionId, botId: String(bot._id), userMessage: msg });
|
||||
const orchestration = await runTwoStage({ bot, event, session, turnLog });
|
||||
|
||||
const last6ForIntent = session.history.slice(-6).map((m) => ({
|
||||
role: m.role,
|
||||
text: m.text,
|
||||
}));
|
||||
const reply = String(orchestration.reply || DEFAULT_FALLBACK).trim().slice(0, 1200);
|
||||
const cards = Array.isArray(orchestration.cards) ? orchestration.cards.slice(0, 10) : [];
|
||||
const places = Array.isArray(orchestration.places) ? orchestration.places.slice(0, 5) : [];
|
||||
const foods = Array.isArray(orchestration.foods) ? orchestration.foods.slice(0, 5) : [];
|
||||
|
||||
const intentResult = session.awaitingPersonalEmail
|
||||
? { category: 'Personal Information', databaseSearchNeeded: true }
|
||||
: await determineIntentCategory(last6ForIntent);
|
||||
const intentCategory = intentResult?.category || 'unrecognized';
|
||||
|
||||
if (intentResult?.databaseSearchNeeded) {
|
||||
console.log('[concierge] database search needed for intent:', intentCategory);
|
||||
}
|
||||
|
||||
// Web console / backend terminal debugging: show intent classification for each inbound chat message.
|
||||
console.log('[concierge][intent]', {
|
||||
botId: String(bot._id),
|
||||
sessionId,
|
||||
message: msg,
|
||||
intent: intentCategory,
|
||||
databaseSearchNeeded: Boolean(intentResult?.databaseSearchNeeded),
|
||||
});
|
||||
|
||||
|
||||
const pipelineResult = await runConciergePipeline({
|
||||
eventId: String(bot.event),
|
||||
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);
|
||||
const inTok = pipelineResult?.usage?.inputTokens || 0;
|
||||
const outTok = pipelineResult?.usage?.outputTokens || 0;
|
||||
const inTok = orchestration.usage?.inputTokens || 0;
|
||||
const outTok = orchestration.usage?.outputTokens || 0;
|
||||
session.inputTokensTotal = Number(session.inputTokensTotal || 0) + Number(inTok || 0);
|
||||
session.outputTokensTotal = Number(session.outputTokensTotal || 0) + Number(outTok || 0);
|
||||
session.history.push({
|
||||
role: 'assistant',
|
||||
text: reply,
|
||||
ts: new Date(),
|
||||
modelId: pipelineResult?.modelId || null,
|
||||
modelId: orchestration.modelId || null,
|
||||
inputTokens: inTok,
|
||||
outputTokens: outTok,
|
||||
});
|
||||
|
|
@ -172,9 +143,34 @@ const chat = async (request, h) => {
|
|||
if (session.history.length > MAX_HISTORY_MESSAGES) {
|
||||
session.history = session.history.slice(-MAX_HISTORY_MESSAGES);
|
||||
}
|
||||
|
||||
finalizeTurnLog(turnLog, { replyPreview: reply, cards });
|
||||
logTurnConsole(turnLog);
|
||||
session.pipelineLog = session.pipelineLog || [];
|
||||
session.pipelineLog.push(turnLog);
|
||||
if (session.pipelineLog.length > MAX_PIPELINE_LOG) {
|
||||
session.pipelineLog = session.pipelineLog.slice(-MAX_PIPELINE_LOG);
|
||||
}
|
||||
|
||||
const routedIntent = orchestration.intent ? String(orchestration.intent).trim() : '';
|
||||
if (routedIntent && routedIntent !== 'unrecognized') {
|
||||
session.lastIntent = routedIntent;
|
||||
}
|
||||
|
||||
await session.save();
|
||||
|
||||
return h.response(success({ sessionId, reply, places, foods, usage: pipelineResult?.usage || null, intent: intentCategory }));
|
||||
const responseBody = {
|
||||
sessionId,
|
||||
reply,
|
||||
cards,
|
||||
places,
|
||||
foods,
|
||||
usage: orchestration.usage || null,
|
||||
intent: orchestration.intent || 'unrecognized',
|
||||
};
|
||||
if (isDebugEnabled()) responseBody.debug = turnLog;
|
||||
|
||||
return h.response(success(responseBody));
|
||||
};
|
||||
|
||||
const end = async (request, h) => {
|
||||
|
|
@ -190,6 +186,7 @@ const end = async (request, h) => {
|
|||
$set: {
|
||||
participant: null,
|
||||
awaitingPersonalEmail: false,
|
||||
lastIntent: null,
|
||||
identityVerifiedAt: null,
|
||||
identityMethod: null,
|
||||
endedAt: new Date(),
|
||||
|
|
@ -201,10 +198,57 @@ const end = async (request, h) => {
|
|||
return h.response(success({ ended: true }));
|
||||
};
|
||||
|
||||
/**
|
||||
* Load a prior web chat by Mongo document _id or by public sessionId string.
|
||||
* Used by the embed demo (?session=<WebChatSession._id>) to restore transcript UI.
|
||||
* Idle sessions are still loadable (demo / replay); only explicitly ended chats are blocked.
|
||||
*/
|
||||
const getSession = async (request, h) => {
|
||||
const { botId, sessionRef } = request.params;
|
||||
const { bot, err } = await loadBotOr404(botId);
|
||||
if (err) return h.response(err).code(err.error?.code === 'NOT_FOUND' ? 404 : 400);
|
||||
|
||||
const ref = String(sessionRef || '').trim();
|
||||
if (!ref) return h.response(error('VALIDATION_ERROR', 'Session id is required')).code(400);
|
||||
|
||||
let session = null;
|
||||
if (mongoose.Types.ObjectId.isValid(ref) && String(new mongoose.Types.ObjectId(ref)) === ref) {
|
||||
session = await WebChatSession.findOne({ _id: ref, botId: bot._id });
|
||||
}
|
||||
if (!session) {
|
||||
session = await WebChatSession.findOne({ botId: bot._id, sessionId: ref });
|
||||
}
|
||||
if (!session) return h.response(error('NOT_FOUND', 'Chat session not found')).code(404);
|
||||
|
||||
if (session.endedAt) {
|
||||
return h.response(error('GONE', 'This chat session has ended')).code(410);
|
||||
}
|
||||
|
||||
// Touch so a resumed idle session can continue on the same sessionId via /chat.
|
||||
session.lastSeenAt = new Date();
|
||||
await session.save();
|
||||
|
||||
const history = (Array.isArray(session.history) ? session.history : [])
|
||||
.slice(-MAX_HISTORY_MESSAGES)
|
||||
.map((m) => ({
|
||||
role: m.role === 'user' ? 'user' : 'assistant',
|
||||
text: String(m.text || ''),
|
||||
}))
|
||||
.filter((m) => m.text);
|
||||
|
||||
return h.response(
|
||||
success({
|
||||
id: String(session._id),
|
||||
sessionId: session.sessionId,
|
||||
history,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
config,
|
||||
chat,
|
||||
end,
|
||||
getSession,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,13 @@ const {
|
|||
loadTemplateSchema,
|
||||
} = require('../utils/publishStaticMicrosite');
|
||||
const { renderHandlebarsTemplate } = require('../utils/handlebarsRender');
|
||||
const {
|
||||
isLocalMicrositeV2TemplatesEnabled,
|
||||
localPreviewAssetsBaseUrl,
|
||||
resolveLocalAssetFile,
|
||||
mimeForLocalAsset,
|
||||
} = require('../utils/localMicrositeV2Templates');
|
||||
const fs = require('fs');
|
||||
|
||||
function requirePlatformAdmin(user) {
|
||||
return user.platform_admin === 'yes';
|
||||
|
|
@ -449,9 +456,11 @@ const previewTemplateHtml = async (request, h) => {
|
|||
try {
|
||||
const schema = await loadTemplateSchema(template);
|
||||
const defaultConfig = buildDefaultConfig(schema);
|
||||
const assetsBaseUrl = localPreviewAssetsBaseUrl(template.slug, request) || undefined;
|
||||
const html = await renderStaticMicrositeHtml({
|
||||
template,
|
||||
config: defaultConfig,
|
||||
assetsBaseUrl,
|
||||
});
|
||||
return h.response(html).type('text/html');
|
||||
} catch (e) {
|
||||
|
|
@ -459,6 +468,37 @@ const previewTemplateHtml = async (request, h) => {
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Serve template CSS/JS/images from local disk when MICROSITE_V2_READ_FROM_LOCAL is on.
|
||||
* Public (no JWT) so blob-URL previews can load assets.
|
||||
*/
|
||||
const serveLocalTemplateAsset = async (request, h) => {
|
||||
if (!isLocalMicrositeV2TemplatesEnabled()) {
|
||||
return h.response(error('NOT_FOUND', 'Local microsite assets are disabled')).code(404);
|
||||
}
|
||||
|
||||
const slug = String(request.params.slug || '').trim().toLowerCase();
|
||||
const assetPath = Array.isArray(request.params.assetPath)
|
||||
? request.params.assetPath.join('/')
|
||||
: String(request.params.assetPath || '');
|
||||
|
||||
const fullPath = resolveLocalAssetFile(slug, assetPath);
|
||||
if (!fullPath) {
|
||||
return h.response(error('NOT_FOUND', 'Asset not found')).code(404);
|
||||
}
|
||||
|
||||
try {
|
||||
const buf = fs.readFileSync(fullPath);
|
||||
return h
|
||||
.response(buf)
|
||||
.type(mimeForLocalAsset(fullPath))
|
||||
.header('Cache-Control', 'no-store, max-age=0')
|
||||
.header('Access-Control-Allow-Origin', '*');
|
||||
} catch (e) {
|
||||
return h.response(error('SERVER_ERROR', e.message || 'Failed to read asset')).code(500);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Event static microsite ---
|
||||
|
||||
const getForEvent = async (request, h) => {
|
||||
|
|
@ -578,11 +618,14 @@ const previewForEvent = async (request, h) => {
|
|||
if (!site) return h.response(error('NOT_FOUND', 'Static microsite not configured')).code(404);
|
||||
|
||||
try {
|
||||
const assetsBaseUrl =
|
||||
localPreviewAssetsBaseUrl(site.template?.slug, request) || undefined;
|
||||
const html = await renderStaticMicrositeHtml({
|
||||
template: site.template,
|
||||
config: site.config,
|
||||
eventId: event._id,
|
||||
subdomain: site.subdomain,
|
||||
assetsBaseUrl,
|
||||
});
|
||||
return h.response(html).type('text/html');
|
||||
} catch (e) {
|
||||
|
|
@ -600,6 +643,7 @@ module.exports = {
|
|||
removeTemplate,
|
||||
uploadTemplateFiles,
|
||||
previewTemplateHtml,
|
||||
serveLocalTemplateAsset,
|
||||
getForEvent,
|
||||
upsertForEvent,
|
||||
checkSubdomain,
|
||||
|
|
|
|||
|
|
@ -64,6 +64,16 @@ const eventSchema = new mongoose.Schema(
|
|||
/** Optional shared code for Invite Only registration (visitor must pass inviteToken) */
|
||||
inviteCode: String,
|
||||
},
|
||||
/**
|
||||
* Participant logistics policy for the concierge. When a flag is true, the
|
||||
* host arranges that service and the concierge answers from event knowledge;
|
||||
* when false, the concierge points participants to arrange their own
|
||||
* (external recommendations).
|
||||
*/
|
||||
participantLogistics: {
|
||||
hostManagedAccommodation: { type: Boolean, default: false },
|
||||
hostManagedTravel: { type: Boolean, default: false },
|
||||
},
|
||||
/** Loyalty point rules (defaults); session/quiz may override via mergeRules. */
|
||||
rewardRules: rewardRulesSubSchema,
|
||||
bannerImage: String,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,73 @@ const messageSchema = new mongoose.Schema(
|
|||
{ _id: false },
|
||||
);
|
||||
|
||||
// Two-stage concierge pipeline turn log. Kept in sync with the turn log shape
|
||||
// produced by utils/conciergePipelineLog.js. Stores previews only (no raw PII).
|
||||
const pipelineCall1Schema = new mongoose.Schema(
|
||||
{
|
||||
skipped: { type: Boolean, default: false },
|
||||
path: { type: String, default: null },
|
||||
modelId: { type: String, default: null },
|
||||
intent: { type: String, default: null },
|
||||
needsSecondCall: { type: Boolean, default: null },
|
||||
inputTokens: { type: Number, default: 0 },
|
||||
outputTokens: { type: Number, default: 0 },
|
||||
latencyMs: { type: Number, default: 0 },
|
||||
rawPreview: { type: String, default: null },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const pipelineHandlerSchema = new mongoose.Schema(
|
||||
{
|
||||
name: { type: String, default: null },
|
||||
skippedCall2: { type: Boolean, default: false },
|
||||
cardCount: { type: Number, default: 0 },
|
||||
cardTypes: { type: [String], default: [] },
|
||||
latencyMs: { type: Number, default: 0 },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const pipelineCall2Schema = new mongoose.Schema(
|
||||
{
|
||||
skipped: { type: Boolean, default: true },
|
||||
reason: { type: String, default: null },
|
||||
modelId: { type: String, default: null },
|
||||
inputTokens: { type: Number, default: 0 },
|
||||
outputTokens: { type: Number, default: 0 },
|
||||
latencyMs: { type: Number, default: 0 },
|
||||
retrievalSources: { type: Number, default: 0 },
|
||||
rawPreview: { type: String, default: null },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const pipelineTotalsSchema = new mongoose.Schema(
|
||||
{
|
||||
inputTokens: { type: Number, default: 0 },
|
||||
outputTokens: { type: Number, default: 0 },
|
||||
totalTokens: { type: Number, default: 0 },
|
||||
latencyMs: { type: Number, default: 0 },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const pipelineTurnSchema = new mongoose.Schema(
|
||||
{
|
||||
ts: { type: Date, default: Date.now },
|
||||
sessionId: { type: String, default: null },
|
||||
botId: { type: String, default: null },
|
||||
userMessagePreview: { type: String, default: null },
|
||||
call1: { type: pipelineCall1Schema, default: () => ({}) },
|
||||
handler: { type: pipelineHandlerSchema, default: () => ({}) },
|
||||
call2: { type: pipelineCall2Schema, default: () => ({}) },
|
||||
totals: { type: pipelineTotalsSchema, default: () => ({}) },
|
||||
replyPreview: { type: String, default: null },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const webChatSessionSchema = new mongoose.Schema(
|
||||
{
|
||||
botId: { type: mongoose.Schema.Types.ObjectId, ref: 'ConciergeBot', required: true, index: true },
|
||||
|
|
@ -20,10 +87,13 @@ const webChatSessionSchema = new mongoose.Schema(
|
|||
sessionId: { type: String, required: true, index: true },
|
||||
participant: { type: mongoose.Schema.Types.ObjectId, ref: 'Participant', default: null, index: true },
|
||||
awaitingPersonalEmail: { type: Boolean, default: false },
|
||||
/** Last successfully routed information intent (for affirmative "yes" continuations). */
|
||||
lastIntent: { type: String, default: null },
|
||||
identityVerifiedAt: { type: Date, default: null },
|
||||
identityMethod: { type: String, enum: ['email'], default: null },
|
||||
endedAt: { type: Date, default: null, index: true },
|
||||
history: { type: [messageSchema], default: [] },
|
||||
pipelineLog: { type: [pipelineTurnSchema], default: [] },
|
||||
lastSeenAt: { type: Date, default: Date.now, index: true },
|
||||
inputTokensTotal: { type: Number, default: 0 },
|
||||
outputTokensTotal: { type: Number, default: 0 },
|
||||
|
|
|
|||
|
|
@ -20,6 +20,20 @@ module.exports = [
|
|||
handler: controller.config,
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/public/concierge/{botId}/session/{sessionRef}',
|
||||
options: {
|
||||
auth: false,
|
||||
validate: {
|
||||
params: Joi.object({
|
||||
botId: Joi.string().required(),
|
||||
sessionRef: Joi.string().min(1).max(80).required(),
|
||||
}),
|
||||
},
|
||||
handler: controller.getSession,
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/api/public/concierge/{botId}/chat',
|
||||
|
|
@ -39,4 +53,3 @@ module.exports = [
|
|||
},
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,23 @@ const multipartPayload = {
|
|||
};
|
||||
|
||||
module.exports = [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/public/microsite-v2-local/{slug}/assets/{assetPath*}',
|
||||
options: {
|
||||
auth: false,
|
||||
validate: {
|
||||
params: Joi.object({
|
||||
slug: Joi.string().min(1).max(80).required(),
|
||||
// Hapi may pass multi-segment params as string or string[]
|
||||
assetPath: Joi.alternatives()
|
||||
.try(Joi.string().min(1).max(500), Joi.array().items(Joi.string().min(1)).min(1))
|
||||
.required(),
|
||||
}),
|
||||
},
|
||||
handler: controller.serveLocalTemplateAsset,
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/microsite-v2-templates',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
const {
|
||||
fetchAccommodationRecordForEvent,
|
||||
upsertAccommodationRecord,
|
||||
} = require('../utils/conciergeRecommendationDb');
|
||||
|
||||
module.exports = {
|
||||
fetchAccommodationRecordForEvent,
|
||||
upsertAccommodationRecord,
|
||||
};
|
||||
|
|
@ -0,0 +1,275 @@
|
|||
const axios = require('axios');
|
||||
const { completeText } = require('../utils/bedrockComplete');
|
||||
const { upsertAccommodationRecord } = require('../utils/conciergeRecommendationDb');
|
||||
|
||||
const API_KEY_ENV = 'GOOGLE_PLACES_API_KEY';
|
||||
|
||||
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 = 5000;
|
||||
const DEFAULT_RESULT_COUNT = 20;
|
||||
|
||||
const SEARCH_QUERY_TEMPLATES = [
|
||||
'hotels near {location}',
|
||||
'business hotels in {location}',
|
||||
'3 star hotels near {location}',
|
||||
'budget hotels near {location}',
|
||||
'guest houses near {location}',
|
||||
'lodges and inns near {location}',
|
||||
];
|
||||
|
||||
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;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
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',
|
||||
'geometry',
|
||||
'website',
|
||||
].join(','),
|
||||
});
|
||||
if (result.status !== 'OK' || !result.result) {
|
||||
throw new Error(`Place details failed: ${result.status}`);
|
||||
}
|
||||
return result.result;
|
||||
}
|
||||
|
||||
async function searchHotels(
|
||||
originLat,
|
||||
originLng,
|
||||
apiKey,
|
||||
locationName = '',
|
||||
radius = DEFAULT_RADIUS_METERS,
|
||||
limit = DEFAULT_RESULT_COUNT,
|
||||
) {
|
||||
const seen = new Set();
|
||||
const hotels = [];
|
||||
|
||||
for (const template of SEARCH_QUERY_TEMPLATES) {
|
||||
if (hotels.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 (hotels.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 summary = String(r.text || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.split(/[.!?]/)[0]
|
||||
.trim();
|
||||
const s = summary || String(r.text || '').slice(0, 120);
|
||||
const rating = r.rating != null ? ` (${r.rating})` : '';
|
||||
return `${s}${rating}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.slice(0, 8)
|
||||
.join(' • ');
|
||||
|
||||
hotels.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,
|
||||
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 hotels;
|
||||
}
|
||||
|
||||
function buildAccommodationRecommendationPrompt(hotels, location) {
|
||||
const summary = hotels
|
||||
.slice(0, 12)
|
||||
.map((h, index) =>
|
||||
`\n${index + 1}. ${h.name}\nTags: ${h.tags.join(', ') || 'N/A'}\nRating: ${h.rating || 'N/A'}\nDistance: ${formatDistance(h.distance_meters)}\nAddress: ${h.address || 'N/A'}\nWhy guests like it: ${h.reviewsSummary || 'N/A'}\n`,
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
return `
|
||||
You are a local accommodation recommendation assistant.
|
||||
|
||||
The user needs a place to stay near:
|
||||
${location}
|
||||
|
||||
Nearby candidate stays:
|
||||
${summary}
|
||||
|
||||
Your task:
|
||||
1) Recommend 4 to 6 places to stay for a visitor attending an event in this area.
|
||||
2) For each, include a brief reason (location, rating, value).
|
||||
3) Use ONLY the stays listed above (do NOT invent hotels).
|
||||
4) Do NOT assume the location is in a specific country.
|
||||
5) Start your response with a heading like "🏨 Places to Stay near ${location}".
|
||||
6) Return ONLY plain text.
|
||||
`;
|
||||
}
|
||||
|
||||
async function getAccommodationRecommendationsWithLLM(hotels, location) {
|
||||
try {
|
||||
const prompt = buildAccommodationRecommendationPrompt(hotels, location);
|
||||
return await completeText(prompt, { maxTokens: 1000, temperature: 0.5 });
|
||||
} catch (err) {
|
||||
console.log('Unable to generate accommodation recommendations:', err?.message || err);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch nearby stays via Google Places, summarize with the LLM, and persist to
|
||||
* the concierge_accommodation collection. Mirrors getPlacesRecommendations.
|
||||
*/
|
||||
async function getAccommodationRecommendations(location, eventId = null) {
|
||||
const apiKey = process.env[API_KEY_ENV];
|
||||
|
||||
let geo = { formattedAddress: location, lat: 0, lng: 0 };
|
||||
let hotels = [];
|
||||
|
||||
if (!apiKey) {
|
||||
const fallbackText = `Accommodation recommendations unavailable: missing Google Places API key for ${location}.`;
|
||||
try {
|
||||
await upsertAccommodationRecord({
|
||||
eventId: eventId ? String(eventId) : null,
|
||||
location,
|
||||
origin: geo,
|
||||
recommendations: fallbackText,
|
||||
hotels: [],
|
||||
note: 'no-api-key',
|
||||
createdAt: new Date(),
|
||||
});
|
||||
} catch (dbErr) {
|
||||
console.warn('Failed to save accommodation:', dbErr.message || dbErr);
|
||||
}
|
||||
return fallbackText + ' Set GOOGLE_PLACES_API_KEY to enable real recommendations.';
|
||||
}
|
||||
|
||||
try {
|
||||
geo = await geocodeAddress(location, apiKey);
|
||||
} catch (e) {
|
||||
console.warn('[getAccommodationRecommendations] Geocoding failed:', e.message);
|
||||
geo.formattedAddress = location;
|
||||
}
|
||||
|
||||
try {
|
||||
hotels = await searchHotels(geo.lat, geo.lng, apiKey, geo.formattedAddress);
|
||||
} catch (e) {
|
||||
console.warn('[getAccommodationRecommendations] Hotel search failed:', e.message);
|
||||
}
|
||||
|
||||
let recommendationText = '';
|
||||
try {
|
||||
recommendationText = await getAccommodationRecommendationsWithLLM(hotels, geo.formattedAddress);
|
||||
} catch (e) {
|
||||
console.warn('[getAccommodationRecommendations] LLM failed; fallback formatting:', e.message || e);
|
||||
}
|
||||
|
||||
if (!recommendationText) {
|
||||
if (hotels.length === 0) {
|
||||
recommendationText = `No places to stay found near ${geo.formattedAddress}.`;
|
||||
} else {
|
||||
const top = hotels
|
||||
.slice(0, 6)
|
||||
.map((h) => `${h.name} (rating: ${h.rating || 'N/A'})`)
|
||||
.join('\n');
|
||||
recommendationText = `Places to stay near ${geo.formattedAddress}\n\nNearby stays:\n${top}`;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await upsertAccommodationRecord({
|
||||
eventId: eventId ? String(eventId) : null,
|
||||
location: geo.formattedAddress,
|
||||
origin: { lat: geo.lat, lng: geo.lng },
|
||||
recommendations: recommendationText,
|
||||
hotels,
|
||||
createdAt: new Date(),
|
||||
generatedAt: new Date(),
|
||||
});
|
||||
} catch (dbErr) {
|
||||
console.warn('Failed to save accommodation:', dbErr.message || dbErr);
|
||||
}
|
||||
|
||||
return recommendationText;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAccommodationRecommendations,
|
||||
};
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
/**
|
||||
* Concierge Call 2: generate a grounded chat answer from retrieved event
|
||||
* context. Used by retrieval-backed intent handlers.
|
||||
*
|
||||
* Also exposes generateConversationalReply for out_of_scope / soft steering
|
||||
* where RAG is not required but history-aware tone is.
|
||||
*
|
||||
* Kept in its own module (rather than in conciergeResponse.service) so intent
|
||||
* handlers can depend on it without creating a require cycle with the
|
||||
* orchestrator (orchestrator -> handler registry -> handler -> Call 2).
|
||||
*/
|
||||
|
||||
const { retrieveEventChunks, answerFromEventChunks } = require('./conciergeRag.service');
|
||||
const { latestUserMessage, isAffirmative } = require('./conciergeRouter.service');
|
||||
const { recordCall2 } = require('../utils/conciergePipelineLog');
|
||||
const {
|
||||
loadOrganizerContact,
|
||||
formatOrganizerContactLine,
|
||||
} = require('../utils/organizerContact');
|
||||
|
||||
const NO_CONTEXT_REPLY =
|
||||
"I don't have that in my knowledge base yet. Please check with event staff or try another question.";
|
||||
|
||||
const OUT_OF_SCOPE_FALLBACK =
|
||||
"I may not be the best help for that specific ask, but I can still help with this event — "
|
||||
+ 'schedule, sessions, speakers, tickets, registration, travel, food, and places nearby. What would you like to know?';
|
||||
|
||||
/**
|
||||
* @param {{ bot: object, event: object, session: object, plan: object, turnLog: object }} params
|
||||
* @returns {Promise<{ reply: string, usage: object|null, modelId: string|null, retrievalSources: number, noContext: boolean }>}
|
||||
*/
|
||||
async function generateConciergeResponse({ bot, event, session, plan, turnLog }) {
|
||||
const historyForRag = (session?.history || []).slice(-12).map((m) => ({ role: m.role, text: m.text }));
|
||||
const userMessage = latestUserMessage(historyForRag);
|
||||
const retrievalQuery = (plan?.searchQuery && plan.searchQuery.trim()) || userMessage;
|
||||
const continueAffirmative = Boolean(plan?.path === 'affirmative_continue' || isAffirmative(userMessage));
|
||||
|
||||
const includeEventDetails = bot?.knowledge?.includeEventDetails !== false;
|
||||
const includeFoodRecommendations = bot?.knowledge?.includeFoodRecommendations !== false;
|
||||
|
||||
const startedAt = Date.now();
|
||||
const retrieval = await retrieveEventChunks(String(bot.event), retrievalQuery, {
|
||||
includeEventDetails,
|
||||
includeFoodRecommendations,
|
||||
});
|
||||
const chunks = retrieval?.chunks || [];
|
||||
|
||||
if (!chunks.length) {
|
||||
recordCall2(turnLog, {
|
||||
skipped: true,
|
||||
reason: retrieval ? 'no_context' : 'embed_failed',
|
||||
retrievalSources: 0,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
});
|
||||
return { reply: NO_CONTEXT_REPLY, usage: null, modelId: null, retrievalSources: 0, noContext: true };
|
||||
}
|
||||
|
||||
const answer = await answerFromEventChunks({
|
||||
eventId: String(bot.event),
|
||||
userQuery: userMessage,
|
||||
eventName: event?.name || 'Event',
|
||||
chunks,
|
||||
history: historyForRag,
|
||||
promptIntent: plan?.intent || 'general',
|
||||
continueAffirmative,
|
||||
searchQuery: plan?.searchQuery || null,
|
||||
});
|
||||
|
||||
recordCall2(turnLog, {
|
||||
skipped: false,
|
||||
reason: null,
|
||||
modelId: answer?.modelId || null,
|
||||
inputTokens: answer?.usage?.inputTokens || 0,
|
||||
outputTokens: answer?.usage?.outputTokens || 0,
|
||||
retrievalSources: chunks.length,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
raw: answer?.text || null,
|
||||
});
|
||||
|
||||
const reply = String(answer?.text || '').trim();
|
||||
return {
|
||||
reply: reply || NO_CONTEXT_REPLY,
|
||||
usage: answer?.usage || null,
|
||||
modelId: answer?.modelId || null,
|
||||
retrievalSources: chunks.length,
|
||||
noContext: !reply,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* History-aware conversational reply without RAG (for out_of_scope / soft steer-back).
|
||||
*/
|
||||
async function generateConversationalReply({ bot, event, session, plan, turnLog }) {
|
||||
const { completeText, DEFAULT_RECOMMENDATION_MODEL_ID } = require('../utils/bedrockComplete');
|
||||
const history = (session?.history || []).slice(-12).map((m) => ({
|
||||
role: m?.role === 'assistant' ? 'assistant' : 'user',
|
||||
text: String(m?.text || '').trim(),
|
||||
})).filter((m) => m.text);
|
||||
const userMessage = latestUserMessage(history) || '';
|
||||
const eventName = event?.name || 'this event';
|
||||
const eventId = bot?.event || event?._id;
|
||||
|
||||
let contactBlurb = '';
|
||||
try {
|
||||
const contact = await loadOrganizerContact(eventId);
|
||||
const line = formatOrganizerContactLine(contact);
|
||||
if (line) contactBlurb = line;
|
||||
} catch {
|
||||
contactBlurb = '';
|
||||
}
|
||||
|
||||
const facts = [
|
||||
`Event: ${eventName}`,
|
||||
event?.location ? `Location: ${event.location}` : '',
|
||||
contactBlurb ? `Organizer contact: ${contactBlurb}` : 'Organizer contact: not on file',
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
const transcript = history
|
||||
.map((m) => `${m.role === 'assistant' ? 'Assistant' : 'User'}: ${m.text}`)
|
||||
.join('\n');
|
||||
|
||||
const system = `You are the virtual concierge for "${eventName}". The user said something off-topic, frustrated, or loosely related.
|
||||
|
||||
Rules:
|
||||
- Acknowledge their tone or last point briefly and politely (including frustration).
|
||||
- Do NOT invent approval ETAs, ride-app availability (Uber/Ola), or contact details.
|
||||
- Only share organizer contact if it appears in EVENT FACTS below.
|
||||
- Steer back to event help with ONE concrete offer (schedule, registration, travel, food, places, tickets).
|
||||
- Do NOT repeat a canned "I'm the assistant for … I can only help with…" paragraph.
|
||||
- Do NOT end with open yes/no closers like "Would you like me to…?".
|
||||
- Keep under ~350 characters. Plain text only.
|
||||
|
||||
EVENT FACTS:
|
||||
${facts}`;
|
||||
|
||||
const userText = `RECENT CONVERSATION:
|
||||
${transcript || '(none)'}
|
||||
|
||||
CURRENT USER MESSAGE:
|
||||
${userMessage}
|
||||
|
||||
Write the assistant reply.`;
|
||||
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const text = await completeText(userText, {
|
||||
system,
|
||||
maxTokens: 280,
|
||||
temperature: 0.4,
|
||||
});
|
||||
const reply = String(text || '').trim() || OUT_OF_SCOPE_FALLBACK;
|
||||
recordCall2(turnLog, {
|
||||
skipped: false,
|
||||
reason: null,
|
||||
modelId: DEFAULT_RECOMMENDATION_MODEL_ID || null,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
retrievalSources: 0,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
raw: reply,
|
||||
});
|
||||
return {
|
||||
reply,
|
||||
usage: null,
|
||||
modelId: DEFAULT_RECOMMENDATION_MODEL_ID || null,
|
||||
retrievalSources: 0,
|
||||
noContext: false,
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn('[concierge][call2][conversational] failed:', err?.message || err);
|
||||
recordCall2(turnLog, {
|
||||
skipped: true,
|
||||
reason: 'conversational_llm_failed',
|
||||
retrievalSources: 0,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
});
|
||||
return {
|
||||
reply: OUT_OF_SCOPE_FALLBACK,
|
||||
usage: null,
|
||||
modelId: null,
|
||||
retrievalSources: 0,
|
||||
noContext: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateConciergeResponse,
|
||||
generateConversationalReply,
|
||||
NO_CONTEXT_REPLY,
|
||||
OUT_OF_SCOPE_FALLBACK,
|
||||
};
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
jest.mock('./conciergeRag.service', () => ({
|
||||
retrieveEventChunks: jest.fn(),
|
||||
answerFromEventChunks: jest.fn(),
|
||||
executeConciergeRag: jest.fn(),
|
||||
}));
|
||||
|
||||
const { retrieveEventChunks, answerFromEventChunks } = require('./conciergeRag.service');
|
||||
const { generateConciergeResponse, NO_CONTEXT_REPLY } = require('./conciergeCall2.service');
|
||||
const { createTurnLog } = require('../utils/conciergePipelineLog');
|
||||
|
||||
const bot = { event: 'evt-1', organization: 'org-1', knowledge: {} };
|
||||
const event = { name: 'Test Event' };
|
||||
|
||||
function makeSession(text) {
|
||||
return { sessionId: 's', history: [{ role: 'user', text }] };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('generateConciergeResponse', () => {
|
||||
test('retrieves then answers, recording Call 2', async () => {
|
||||
retrieveEventChunks.mockResolvedValue({ chunks: [{ text: 'a', score: 1 }, { text: 'b', score: 0.5 }], embedModelId: 'e' });
|
||||
answerFromEventChunks.mockResolvedValue({ text: 'Answer.', modelId: 'm', usage: { inputTokens: 10, outputTokens: 3 } });
|
||||
|
||||
const turnLog = createTurnLog({ sessionId: 's', botId: 'b', userMessage: 'q' });
|
||||
const result = await generateConciergeResponse({
|
||||
bot,
|
||||
event,
|
||||
session: makeSession('what is the agenda?'),
|
||||
plan: { intent: 'event_schedule', searchQuery: 'agenda' },
|
||||
turnLog,
|
||||
});
|
||||
|
||||
expect(retrieveEventChunks).toHaveBeenCalledWith('evt-1', 'agenda', expect.any(Object));
|
||||
expect(result.reply).toBe('Answer.');
|
||||
expect(result.noContext).toBe(false);
|
||||
expect(result.retrievalSources).toBe(2);
|
||||
expect(turnLog.call2.skipped).toBe(false);
|
||||
expect(turnLog.call2.retrievalSources).toBe(2);
|
||||
});
|
||||
|
||||
test('falls back to the user message when no searchQuery is provided', async () => {
|
||||
retrieveEventChunks.mockResolvedValue({ chunks: [{ text: 'a', score: 1 }], embedModelId: 'e' });
|
||||
answerFromEventChunks.mockResolvedValue({ text: 'Answer.', modelId: 'm', usage: null });
|
||||
|
||||
const turnLog = createTurnLog({ sessionId: 's', botId: 'b', userMessage: 'q' });
|
||||
await generateConciergeResponse({
|
||||
bot,
|
||||
event,
|
||||
session: makeSession('who is speaking?'),
|
||||
plan: { intent: 'speaker_info' },
|
||||
turnLog,
|
||||
});
|
||||
|
||||
expect(retrieveEventChunks).toHaveBeenCalledWith('evt-1', 'who is speaking?', expect.any(Object));
|
||||
});
|
||||
|
||||
test('returns the no-context reply and marks embed_failed when embedding fails', async () => {
|
||||
retrieveEventChunks.mockResolvedValue(null);
|
||||
|
||||
const turnLog = createTurnLog({ sessionId: 's', botId: 'b', userMessage: 'q' });
|
||||
const result = await generateConciergeResponse({
|
||||
bot,
|
||||
event,
|
||||
session: makeSession('anything'),
|
||||
plan: { intent: 'event_general_info' },
|
||||
turnLog,
|
||||
});
|
||||
|
||||
expect(answerFromEventChunks).not.toHaveBeenCalled();
|
||||
expect(result.reply).toBe(NO_CONTEXT_REPLY);
|
||||
expect(result.noContext).toBe(true);
|
||||
expect(turnLog.call2.skipped).toBe(true);
|
||||
expect(turnLog.call2.reason).toBe('embed_failed');
|
||||
});
|
||||
|
||||
test('passes continueAffirmative when the user confirms a prior offer', async () => {
|
||||
retrieveEventChunks.mockResolvedValue({ chunks: [{ text: 'a', score: 1 }], embedModelId: 'e' });
|
||||
answerFromEventChunks.mockResolvedValue({ text: 'Here are flight options…', modelId: 'm', usage: null });
|
||||
|
||||
const turnLog = createTurnLog({ sessionId: 's', botId: 'b', userMessage: 'yes' });
|
||||
await generateConciergeResponse({
|
||||
bot,
|
||||
event,
|
||||
session: {
|
||||
sessionId: 's',
|
||||
history: [
|
||||
{ role: 'user', text: 'how to travel from Thane' },
|
||||
{ role: 'assistant', text: 'Fly to Goa. Would you like more information on flights?' },
|
||||
{ role: 'user', text: 'yes' },
|
||||
],
|
||||
},
|
||||
plan: {
|
||||
intent: 'travel_transport',
|
||||
path: 'affirmative_continue',
|
||||
searchQuery: 'how to travel from Thane — flight options',
|
||||
},
|
||||
turnLog,
|
||||
});
|
||||
|
||||
expect(retrieveEventChunks).toHaveBeenCalledWith(
|
||||
'evt-1',
|
||||
'how to travel from Thane — flight options',
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(answerFromEventChunks).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
continueAffirmative: true,
|
||||
searchQuery: 'how to travel from Thane — flight options',
|
||||
userQuery: 'yes',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
const Event = require('../../models/Event');
|
||||
const { joinCodeUrl } = require('../../utils/conciergeEventKnowledge');
|
||||
const { generateConciergeResponse } = require('../conciergeCall2.service');
|
||||
|
||||
/**
|
||||
* check_in_on_site: attach a check-in action card (online web check-in or the
|
||||
* event join link when available) and answer from the knowledge base via
|
||||
* Call 2 (which may hold desk location/timing). Falls back to generic on-site
|
||||
* guidance when the KB has nothing specific.
|
||||
*/
|
||||
module.exports = async function checkInHandler({ plan, bot, event, session, turnLog }) {
|
||||
let ev = null;
|
||||
try {
|
||||
ev = await Event.findById(bot.event).select('settings joinCode').lean();
|
||||
} catch (err) {
|
||||
console.warn('[concierge][checkin] event load failed:', err?.message || err);
|
||||
}
|
||||
|
||||
const webCheckIn = Boolean(ev?.settings?.webCheckInEnabled);
|
||||
const joinUrl = joinCodeUrl(ev?.joinCode);
|
||||
|
||||
const cards = [];
|
||||
if (webCheckIn && joinUrl) {
|
||||
cards.push({ type: 'checkin', data: { method: 'web', url: joinUrl, label: 'Check in online' } });
|
||||
} else if (joinUrl) {
|
||||
cards.push({ type: 'checkin', data: { method: 'code', url: joinUrl, label: 'Open event' } });
|
||||
}
|
||||
|
||||
const call2 = await generateConciergeResponse({ bot, event, session, plan, turnLog });
|
||||
|
||||
let reply = call2.reply;
|
||||
if (call2.noContext || !call2.reply) {
|
||||
reply = webCheckIn
|
||||
? `You can check in online${joinUrl ? ` here: ${joinUrl}` : ''}. On-site, look for the registration/check-in desk when you arrive and have your ticket or QR code ready.`
|
||||
: 'On-site, head to the registration/check-in desk when you arrive and have your ticket or QR code ready.';
|
||||
}
|
||||
|
||||
return { reply, cards, usedCall2: true, usage: call2.usage, modelId: call2.modelId };
|
||||
};
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
const { generateConciergeResponse } = require('../conciergeCall2.service');
|
||||
|
||||
/**
|
||||
* Retrieval-backed handler for event-knowledge intents (schedule, sessions,
|
||||
* speakers, tickets, general info, virtual join, general help). Runs Call 2:
|
||||
* retrieve event context chunks, then generate a grounded answer.
|
||||
*/
|
||||
module.exports = async function eventKnowledgeHandler({ plan, bot, event, session, turnLog }) {
|
||||
const result = await generateConciergeResponse({ bot, event, session, plan, turnLog });
|
||||
return {
|
||||
reply: result.reply,
|
||||
usedCall2: true,
|
||||
usage: result.usage,
|
||||
modelId: result.modelId,
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
const { runConciergePipeline } = require('../conciergePipeline.service');
|
||||
|
||||
/**
|
||||
* food_recommendation: food LLM chooses places cards, prose, or both.
|
||||
* Uses the event food DB + recommendation model (not event Call 2 RAG).
|
||||
*/
|
||||
module.exports = async function foodHandler({ bot, event, session }) {
|
||||
const result = await runConciergePipeline({
|
||||
eventId: String(bot.event),
|
||||
eventName: event?.name || 'Event',
|
||||
eventLocation: String(event?.location || '').trim(),
|
||||
bot,
|
||||
sessionHistory: (session?.history || []).map((m) => ({ role: m.role, text: m.text })),
|
||||
intentResult: { category: 'Food', databaseSearchNeeded: true },
|
||||
chatSession: session,
|
||||
organizationId: String(bot.organization),
|
||||
});
|
||||
|
||||
const foods = Array.isArray(result?.foods) ? result.foods.slice(0, 5) : [];
|
||||
const cards = foods.map((data) => ({ type: 'food', data }));
|
||||
|
||||
return {
|
||||
reply: result?.text || '',
|
||||
foods,
|
||||
cards,
|
||||
usedCall2: false,
|
||||
usage: result?.usage || null,
|
||||
modelId: result?.modelId || null,
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
const { interpolateConciergeGreeting } = require('../../utils/conciergeGreeting');
|
||||
|
||||
/**
|
||||
* greeting: respond with the bot's configured greeting (or a sensible default).
|
||||
* No Call 2 — this is a direct response.
|
||||
*/
|
||||
module.exports = async function greetingHandler({ plan, bot, event }) {
|
||||
const direct = plan?.directReply && plan.directReply.trim();
|
||||
const reply = direct
|
||||
|| interpolateConciergeGreeting(bot?.greeting?.message || '', { eventName: event?.name });
|
||||
return { reply, usedCall2: false };
|
||||
};
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* Concierge intent handler registry.
|
||||
*
|
||||
* Maps a router intent (see conciergeRouter.service.js INTENTS) to a handler
|
||||
* function. A handler owns the response for its intent and may either build a
|
||||
* structured reply directly (skipping Call 2) or request a Call 2 RAG answer.
|
||||
*
|
||||
* Handler contract (Phase 1+):
|
||||
* async ({ plan, bot, event, session, turnLog }) => ({
|
||||
* reply: string,
|
||||
* cards?: Array<{ type: string, data: object }>,
|
||||
* places?: object[],
|
||||
* foods?: object[],
|
||||
* usage?: { inputTokens: number, outputTokens: number } | null,
|
||||
* modelId?: string | null,
|
||||
* needsCall2?: boolean,
|
||||
* })
|
||||
*
|
||||
* Every routable intent must have a registered handler (enforced by
|
||||
* registry.test.js). If the router cannot classify a turn at all, the
|
||||
* orchestrator returns a generic fallback reply.
|
||||
*/
|
||||
|
||||
const { INTENTS } = require('../conciergeRouter.service');
|
||||
const greetingHandler = require('./greeting.handler');
|
||||
const { clarificationHandler, outOfScopeHandler } = require('./simpleReplies.handler');
|
||||
const personalHandler = require('./personal.handler');
|
||||
const virtualTroubleshootingHandler = require('./virtualTroubleshooting.handler');
|
||||
const eventKnowledgeHandler = require('./eventKnowledge.handler');
|
||||
const foodHandler = require('./food.handler');
|
||||
const touristPlacesHandler = require('./touristPlaces.handler');
|
||||
const createLogisticsHandler = require('./logistics.handler');
|
||||
const ticketingHandler = require('./ticketing.handler');
|
||||
const registrationHandler = require('./registration.handler');
|
||||
const checkInHandler = require('./checkIn.handler');
|
||||
const virtualJoinHandler = require('./virtualJoin.handler');
|
||||
const virtualSessionJoinHandler = require('./virtualSessionJoin.handler');
|
||||
|
||||
const INTENT_HANDLERS = {
|
||||
// Direct-response intents (no Call 2)
|
||||
[INTENTS.GREETING]: greetingHandler,
|
||||
[INTENTS.CLARIFICATION]: clarificationHandler,
|
||||
[INTENTS.OUT_OF_SCOPE]: outOfScopeHandler,
|
||||
[INTENTS.PERSONAL_REGISTRATION]: personalHandler,
|
||||
[INTENTS.PERSONAL_SCHEDULE]: personalHandler,
|
||||
[INTENTS.PERSONAL_TICKET]: personalHandler,
|
||||
[INTENTS.PERSONAL_PROFILE]: personalHandler,
|
||||
[INTENTS.VIRTUAL_TROUBLESHOOTING]: virtualTroubleshootingHandler,
|
||||
|
||||
// Retrieval-backed intents (Call 2 over event knowledge)
|
||||
[INTENTS.EVENT_SCHEDULE]: eventKnowledgeHandler,
|
||||
[INTENTS.SESSION_DETAIL]: eventKnowledgeHandler,
|
||||
[INTENTS.SPEAKER_INFO]: eventKnowledgeHandler,
|
||||
[INTENTS.EVENT_GENERAL_INFO]: eventKnowledgeHandler,
|
||||
[INTENTS.GENERAL_HELP]: eventKnowledgeHandler,
|
||||
[INTENTS.VENUE_NAVIGATION]: eventKnowledgeHandler,
|
||||
|
||||
// Cards-first intents (deterministic DB cards, Call 2 as FAQ fallback)
|
||||
[INTENTS.TICKET_PRICING]: ticketingHandler,
|
||||
[INTENTS.REGISTRATION]: registrationHandler,
|
||||
[INTENTS.CHECK_IN_ON_SITE]: checkInHandler,
|
||||
|
||||
// Virtual / hybrid intents (cards-first join links, Call 2 as fallback)
|
||||
[INTENTS.VIRTUAL_JOIN]: virtualJoinHandler,
|
||||
[INTENTS.VIRTUAL_SESSION_JOIN]: virtualSessionJoinHandler,
|
||||
|
||||
// Recommendation intents (external Google Places-backed DB + LLM selection)
|
||||
[INTENTS.FOOD_RECOMMENDATION]: foodHandler,
|
||||
[INTENTS.TOURIST_PLACES]: touristPlacesHandler,
|
||||
|
||||
// Logistics intents (host-managed policy -> event knowledge, else self-arrange)
|
||||
[INTENTS.ACCOMMODATION]: createLogisticsHandler({ field: 'hostManagedAccommodation', topic: 'accommodation' }),
|
||||
[INTENTS.TRAVEL_TRANSPORT]: createLogisticsHandler({ field: 'hostManagedTravel', topic: 'travel' }),
|
||||
};
|
||||
|
||||
function getIntentHandler(intent) {
|
||||
if (!intent) return null;
|
||||
return INTENT_HANDLERS[intent] || null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
INTENT_HANDLERS,
|
||||
getIntentHandler,
|
||||
};
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
const Event = require('../../models/Event');
|
||||
const { generateConciergeResponse } = require('../conciergeCall2.service');
|
||||
|
||||
const RECORD_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Load the event's logistics policy + location once per turn.
|
||||
*/
|
||||
async function loadLogistics(eventId) {
|
||||
try {
|
||||
const ev = await Event.findById(eventId).select('participantLogistics location').lean();
|
||||
return {
|
||||
hostManagedAccommodation: Boolean(ev?.participantLogistics?.hostManagedAccommodation),
|
||||
hostManagedTravel: Boolean(ev?.participantLogistics?.hostManagedTravel),
|
||||
location: ev?.location || '',
|
||||
};
|
||||
} catch {
|
||||
return { hostManagedAccommodation: false, hostManagedTravel: false, location: '' };
|
||||
}
|
||||
}
|
||||
|
||||
function nearText(location) {
|
||||
return location ? ` near ${location}` : ' near the venue';
|
||||
}
|
||||
|
||||
/** Normalize a stored hotel record into a public accommodation card payload. */
|
||||
function publicHotelFromRecord(hotel) {
|
||||
const id = String(hotel?.place_id || hotel?.placeId || hotel?.id || '').trim();
|
||||
if (!id) return null;
|
||||
const lat = Number(hotel?.location?.lat);
|
||||
const lng = Number(hotel?.location?.lng);
|
||||
return {
|
||||
hotelId: id,
|
||||
placeId: id,
|
||||
name: hotel?.name || 'Unnamed stay',
|
||||
address: hotel?.address || hotel?.formatted_address || '',
|
||||
rating: hotel?.rating != null ? hotel.rating : null,
|
||||
userRatingsTotal: hotel?.user_ratings_total != null ? hotel.user_ratings_total : null,
|
||||
location: Number.isFinite(lat) && Number.isFinite(lng) ? { lat, lng } : null,
|
||||
priceLevel: hotel?.price_level != null ? hotel.price_level : null,
|
||||
website: hotel?.website || null,
|
||||
tags: Array.isArray(hotel?.tags) ? hotel.tags.slice(0, 4) : [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch (and refresh when stale) Google-Places-backed stays for the event and
|
||||
* return normalized accommodation cards.
|
||||
*/
|
||||
async function buildAccommodationCards(eventId, location) {
|
||||
const {
|
||||
fetchAccommodationRecordForEvent,
|
||||
} = require('../../utils/conciergeRecommendationDb');
|
||||
const { getAccommodationRecommendations } = require('../accommodationRecommendationService');
|
||||
|
||||
let record = await fetchAccommodationRecordForEvent(eventId);
|
||||
const generatedAtMs = record?.generatedAt ? new Date(record.generatedAt).getTime() : 0;
|
||||
const isExpired = generatedAtMs > 0 && Date.now() - generatedAtMs > RECORD_TTL_MS;
|
||||
const hasHotels = Array.isArray(record?.hotels) && record.hotels.length > 0;
|
||||
|
||||
if (!record || isExpired || !hasHotels) {
|
||||
try {
|
||||
await getAccommodationRecommendations(location || '', eventId);
|
||||
record = await fetchAccommodationRecordForEvent(eventId);
|
||||
} catch (err) {
|
||||
console.warn('[concierge][accommodation] fetch failed:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
const hotels = Array.isArray(record?.hotels) ? record.hotels : [];
|
||||
return hotels.map(publicHotelFromRecord).filter(Boolean).slice(0, 5);
|
||||
}
|
||||
|
||||
const FALLBACKS = {
|
||||
accommodation: {
|
||||
host: () =>
|
||||
'Your organizer is arranging accommodation for this event. For your hotel, check-in dates, and how to confirm your room, please reach out to the event team.',
|
||||
self: (location) =>
|
||||
`Accommodation isn't arranged by the organizer for this event, so you'll want to book your own stay${nearText(location)}. Tell me your budget or dates and I can help you narrow it down.`,
|
||||
},
|
||||
travel: {
|
||||
host: () =>
|
||||
'Your organizer is arranging travel/transport for this event. For pickup points, timings, and confirmations, please check with the event team.',
|
||||
self: (location) =>
|
||||
`You'll be arranging your own travel${nearText(location)}. Prepaid taxis, the hotel desk, or airport transfers are usually reliable — ride apps vary by city, so I won't assume Uber/Ola work everywhere. Share your arrival details and I can suggest options.`,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Factory for accommodation / travel_transport handlers.
|
||||
*
|
||||
* host-managed: answer from event knowledge (Call 2), falling back to a
|
||||
* host-managed message when the KB is thin.
|
||||
* self-managed accommodation: fetch external stays from Google Places and
|
||||
* return accommodation cards (falling back to a self-arrange message).
|
||||
* self-managed travel: try Call 2 for any general guidance, then fall back.
|
||||
*
|
||||
* @param {{ field: 'hostManagedAccommodation'|'hostManagedTravel', topic: 'accommodation'|'travel' }} config
|
||||
*/
|
||||
module.exports = function createLogisticsHandler({ field, topic }) {
|
||||
return async function logisticsHandler({ plan, bot, event, session, turnLog }) {
|
||||
const logistics = await loadLogistics(String(bot.event));
|
||||
const hostManaged = Boolean(logistics[field]);
|
||||
|
||||
if (hostManaged) {
|
||||
const call2 = await generateConciergeResponse({ bot, event, session, plan, turnLog });
|
||||
if (!call2.noContext && call2.reply) {
|
||||
return { reply: call2.reply, usedCall2: true, usage: call2.usage, modelId: call2.modelId };
|
||||
}
|
||||
return { reply: FALLBACKS[topic].host(), usedCall2: true, usage: call2.usage, modelId: call2.modelId };
|
||||
}
|
||||
|
||||
// Self-managed accommodation -> external Google Places stays as cards.
|
||||
if (topic === 'accommodation') {
|
||||
const cards = await buildAccommodationCards(String(bot.event), logistics.location);
|
||||
if (cards.length) {
|
||||
return {
|
||||
reply: `Here are some places to stay${nearText(logistics.location)}.`,
|
||||
cards: cards.map((data) => ({ type: 'accommodation', data })),
|
||||
places: cards,
|
||||
usedCall2: false,
|
||||
};
|
||||
}
|
||||
return { reply: FALLBACKS.accommodation.self(logistics.location), usedCall2: false };
|
||||
}
|
||||
|
||||
// Self-managed travel -> Call 2 general guidance, then fallback text.
|
||||
const call2 = await generateConciergeResponse({ bot, event, session, plan, turnLog });
|
||||
if (!call2.noContext && call2.reply) {
|
||||
return { reply: call2.reply, usedCall2: true, usage: call2.usage, modelId: call2.modelId };
|
||||
}
|
||||
return { reply: FALLBACKS.travel.self(logistics.location), usedCall2: true, usage: call2.usage, modelId: call2.modelId };
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
const { runPersonalInformation } = require('../personalInformation.service');
|
||||
|
||||
function latestUserQuestion(session) {
|
||||
const history = Array.isArray(session?.history) ? session.history : [];
|
||||
for (let i = history.length - 1; i >= 0; i -= 1) {
|
||||
if (history[i]?.role === 'user') return String(history[i]?.text || '').trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* personal_* : answer about the user's own registration/ticket/schedule/profile.
|
||||
* Delegates to the identity-gated personal information service (handles the
|
||||
* email verification flow and participant lookup). No Call 2 — the answer comes
|
||||
* from the participant DB, not RAG.
|
||||
*/
|
||||
module.exports = async function personalHandler({ bot, session }) {
|
||||
if (!bot?.organization) {
|
||||
return {
|
||||
reply: 'Please start a new chat and try your personal-information question again.',
|
||||
usedCall2: false,
|
||||
};
|
||||
}
|
||||
|
||||
const sessionHistory = (session?.history || []).map((m) => ({ role: m.role, text: m.text }));
|
||||
const result = await runPersonalInformation({
|
||||
eventId: String(bot.event),
|
||||
organizationId: String(bot.organization),
|
||||
session,
|
||||
sessionHistory,
|
||||
userQuestion: latestUserQuestion(session),
|
||||
});
|
||||
|
||||
return { reply: result?.text || '', usedCall2: false };
|
||||
};
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
jest.mock('../conciergePipeline.service', () => ({
|
||||
runConciergePipeline: jest.fn(),
|
||||
}));
|
||||
jest.mock('../conciergeCall2.service', () => ({
|
||||
generateConciergeResponse: jest.fn(),
|
||||
NO_CONTEXT_REPLY: 'no ctx',
|
||||
}));
|
||||
jest.mock('../../models/Event', () => ({
|
||||
findById: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/conciergeRecommendationDb', () => ({
|
||||
fetchAccommodationRecordForEvent: jest.fn(),
|
||||
}));
|
||||
jest.mock('../accommodationRecommendationService', () => ({
|
||||
getAccommodationRecommendations: jest.fn(),
|
||||
}));
|
||||
|
||||
const { runConciergePipeline } = require('../conciergePipeline.service');
|
||||
const { generateConciergeResponse } = require('../conciergeCall2.service');
|
||||
const Event = require('../../models/Event');
|
||||
const { fetchAccommodationRecordForEvent } = require('../../utils/conciergeRecommendationDb');
|
||||
const { getAccommodationRecommendations } = require('../accommodationRecommendationService');
|
||||
|
||||
const foodHandler = require('./food.handler');
|
||||
const touristPlacesHandler = require('./touristPlaces.handler');
|
||||
const createLogisticsHandler = require('./logistics.handler');
|
||||
|
||||
const bot = { event: 'evt-1', organization: 'org-1', knowledge: {} };
|
||||
const event = { name: 'Test Event' };
|
||||
|
||||
function makeSession(text) {
|
||||
return { sessionId: 's', history: [{ role: 'user', text }] };
|
||||
}
|
||||
|
||||
function mockEventSelect(doc) {
|
||||
Event.findById.mockReturnValue({ select: () => ({ lean: () => Promise.resolve(doc) }) });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('food.handler', () => {
|
||||
test('maps pipeline foods into food cards', async () => {
|
||||
runConciergePipeline.mockResolvedValue({
|
||||
intent: 'Food',
|
||||
text: 'Here are some places.',
|
||||
foods: [{ foodId: '1', name: 'Cafe A' }, { foodId: '2', name: 'Cafe B' }],
|
||||
});
|
||||
|
||||
const result = await foodHandler({ bot, event, session: makeSession('where to eat?') });
|
||||
|
||||
expect(runConciergePipeline).toHaveBeenCalledTimes(1);
|
||||
expect(runConciergePipeline.mock.calls[0][0].intentResult).toEqual({
|
||||
category: 'Food',
|
||||
databaseSearchNeeded: true,
|
||||
});
|
||||
expect(result.reply).toBe('Here are some places.');
|
||||
expect(result.foods).toHaveLength(2);
|
||||
expect(result.cards).toEqual([
|
||||
{ type: 'food', data: { foodId: '1', name: 'Cafe A' } },
|
||||
{ type: 'food', data: { foodId: '2', name: 'Cafe B' } },
|
||||
]);
|
||||
expect(result.usedCall2).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('touristPlaces.handler', () => {
|
||||
test('maps pipeline places into place cards', async () => {
|
||||
runConciergePipeline.mockResolvedValue({
|
||||
intent: 'Places',
|
||||
text: 'Here are some spots.',
|
||||
places: [{ placeId: 'p1', name: 'Museum' }],
|
||||
});
|
||||
|
||||
const result = await touristPlacesHandler({ bot, event, session: makeSession('things to do?') });
|
||||
|
||||
expect(runConciergePipeline.mock.calls[0][0].intentResult).toEqual({
|
||||
category: 'Places',
|
||||
databaseSearchNeeded: true,
|
||||
});
|
||||
expect(result.cards).toEqual([{ type: 'place', data: { placeId: 'p1', name: 'Museum' } }]);
|
||||
expect(result.usedCall2).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logistics.handler (accommodation)', () => {
|
||||
const handler = createLogisticsHandler({ field: 'hostManagedAccommodation', topic: 'accommodation' });
|
||||
|
||||
test('uses the Call 2 answer when knowledge is available', async () => {
|
||||
mockEventSelect({ participantLogistics: { hostManagedAccommodation: true }, location: 'Pune' });
|
||||
generateConciergeResponse.mockResolvedValue({
|
||||
reply: 'We have booked the Grand Hotel for you.',
|
||||
noContext: false,
|
||||
usage: { inputTokens: 10, outputTokens: 4 },
|
||||
modelId: 'm',
|
||||
});
|
||||
|
||||
const result = await handler({ plan: { intent: 'accommodation' }, bot, event, session: makeSession('where do I stay?'), turnLog: {} });
|
||||
|
||||
expect(result.reply).toBe('We have booked the Grand Hotel for you.');
|
||||
expect(result.usedCall2).toBe(true);
|
||||
});
|
||||
|
||||
test('host-managed fallback when the KB has no accommodation content', async () => {
|
||||
mockEventSelect({ participantLogistics: { hostManagedAccommodation: true }, location: 'Pune' });
|
||||
generateConciergeResponse.mockResolvedValue({ reply: 'no ctx', noContext: true, usage: null, modelId: null });
|
||||
|
||||
const result = await handler({ plan: { intent: 'accommodation' }, bot, event, session: makeSession('hotel?'), turnLog: {} });
|
||||
|
||||
expect(result.reply).toMatch(/organizer is arranging accommodation/i);
|
||||
expect(result.usedCall2).toBe(true);
|
||||
});
|
||||
|
||||
test('self-managed returns Google-Places-backed accommodation cards', async () => {
|
||||
mockEventSelect({ participantLogistics: { hostManagedAccommodation: false }, location: 'Pune' });
|
||||
fetchAccommodationRecordForEvent.mockResolvedValue({
|
||||
hotels: [
|
||||
{ place_id: 'h1', name: 'Grand Stay', address: 'MG Rd', rating: 4.5, location: { lat: 18.5, lng: 73.8 }, tags: ['lodging'] },
|
||||
],
|
||||
generatedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await handler({ plan: { intent: 'accommodation' }, bot, event, session: makeSession('hotel?'), turnLog: {} });
|
||||
|
||||
expect(generateConciergeResponse).not.toHaveBeenCalled();
|
||||
expect(result.reply).toMatch(/places to stay near Pune/i);
|
||||
expect(result.cards).toEqual([
|
||||
{ type: 'accommodation', data: expect.objectContaining({ hotelId: 'h1', name: 'Grand Stay', rating: 4.5 }) },
|
||||
]);
|
||||
expect(result.places).toHaveLength(1);
|
||||
expect(result.usedCall2).toBe(false);
|
||||
});
|
||||
|
||||
test('self-managed refreshes then falls back to text when no stays are found', async () => {
|
||||
mockEventSelect({ participantLogistics: { hostManagedAccommodation: false }, location: 'Pune' });
|
||||
fetchAccommodationRecordForEvent.mockResolvedValue(null); // no cached record, then still empty
|
||||
getAccommodationRecommendations.mockResolvedValue('');
|
||||
|
||||
const result = await handler({ plan: { intent: 'accommodation' }, bot, event, session: makeSession('hotel?'), turnLog: {} });
|
||||
|
||||
expect(getAccommodationRecommendations).toHaveBeenCalledWith('Pune', 'evt-1');
|
||||
expect(result.reply).toMatch(/book your own stay/i);
|
||||
expect(result.reply).toMatch(/near Pune/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logistics.handler (travel)', () => {
|
||||
const handler = createLogisticsHandler({ field: 'hostManagedTravel', topic: 'travel' });
|
||||
|
||||
test('self-managed travel fallback when KB empty', async () => {
|
||||
mockEventSelect({ participantLogistics: { hostManagedTravel: false }, location: 'Goa' });
|
||||
generateConciergeResponse.mockResolvedValue({ reply: 'no ctx', noContext: true, usage: null, modelId: null });
|
||||
|
||||
const result = await handler({ plan: { intent: 'travel_transport' }, bot, event, session: makeSession('how do I get there?'), turnLog: {} });
|
||||
|
||||
expect(result.reply).toMatch(/arranging your own travel/i);
|
||||
expect(result.reply).toMatch(/near Goa/);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
jest.mock('../conciergeCall2.service', () => ({
|
||||
generateConciergeResponse: jest.fn(),
|
||||
NO_CONTEXT_REPLY: 'no ctx',
|
||||
}));
|
||||
jest.mock('../../models/EventTicket', () => ({
|
||||
find: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../models/Event', () => ({
|
||||
findById: jest.fn(),
|
||||
}));
|
||||
|
||||
const { generateConciergeResponse } = require('../conciergeCall2.service');
|
||||
const EventTicket = require('../../models/EventTicket');
|
||||
const Event = require('../../models/Event');
|
||||
|
||||
const ticketingHandler = require('./ticketing.handler');
|
||||
const registrationHandler = require('./registration.handler');
|
||||
const checkInHandler = require('./checkIn.handler');
|
||||
|
||||
const bot = { event: 'evt-1', organization: 'org-1', knowledge: {} };
|
||||
const event = { name: 'Test Event' };
|
||||
|
||||
function makeSession(text) {
|
||||
return { sessionId: 's', history: [{ role: 'user', text }] };
|
||||
}
|
||||
|
||||
function mockTicketFind(tickets) {
|
||||
EventTicket.find.mockReturnValue({ sort: () => ({ lean: () => Promise.resolve(tickets) }) });
|
||||
}
|
||||
function mockEventSelect(doc) {
|
||||
Event.findById.mockReturnValue({ select: () => ({ lean: () => Promise.resolve(doc) }) });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
process.env.MICROSITE_REGISTRATION_URL_TEMPLATE = '';
|
||||
});
|
||||
|
||||
describe('ticketing.handler', () => {
|
||||
test('returns ticket cards from active tickets (no Call 2)', async () => {
|
||||
mockTicketFind([
|
||||
{ _id: 't1', name: 'Standard', basePriceMinor: 150000, currency: 'INR', description: 'Full access', keyPoints: ['Lunch'] },
|
||||
{ _id: 't2', name: 'Free Pass', basePriceMinor: 0, currency: 'INR' },
|
||||
]);
|
||||
|
||||
const result = await ticketingHandler({ plan: { intent: 'ticket_pricing' }, bot, event, session: makeSession('ticket prices?'), turnLog: {} });
|
||||
|
||||
expect(generateConciergeResponse).not.toHaveBeenCalled();
|
||||
expect(result.usedCall2).toBe(false);
|
||||
expect(result.cards).toHaveLength(2);
|
||||
expect(result.cards[0]).toEqual({
|
||||
type: 'ticket',
|
||||
data: expect.objectContaining({ ticketId: 't1', name: 'Standard', price: 'INR 1500.00', keyPoints: ['Lunch'] }),
|
||||
});
|
||||
expect(result.cards[1].data.price).toBe('Free');
|
||||
});
|
||||
|
||||
test('falls back to Call 2 when there are no active tickets', async () => {
|
||||
mockTicketFind([]);
|
||||
generateConciergeResponse.mockResolvedValue({ reply: 'This is a free event.', usage: null, modelId: 'm', noContext: false });
|
||||
|
||||
const result = await ticketingHandler({ plan: { intent: 'ticket_pricing' }, bot, event, session: makeSession('is it free?'), turnLog: {} });
|
||||
|
||||
expect(generateConciergeResponse).toHaveBeenCalledTimes(1);
|
||||
expect(result.usedCall2).toBe(true);
|
||||
expect(result.reply).toBe('This is a free event.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('registration.handler', () => {
|
||||
test('returns a registration link card', async () => {
|
||||
mockEventSelect({ website: { registrationUrl: 'https://reg.example.com' }, settings: { registrationOpen: true } });
|
||||
|
||||
const result = await registrationHandler({ plan: { intent: 'registration' }, bot, event, session: makeSession('how do I register?'), turnLog: {} });
|
||||
|
||||
expect(generateConciergeResponse).not.toHaveBeenCalled();
|
||||
expect(result.cards[0]).toEqual({
|
||||
type: 'registration',
|
||||
data: expect.objectContaining({ url: 'https://reg.example.com', open: true }),
|
||||
});
|
||||
expect(result.reply).toMatch(/register for/i);
|
||||
expect(result.reply).not.toContain('https://reg.example.com');
|
||||
});
|
||||
|
||||
test('reports closed registration', async () => {
|
||||
mockEventSelect({ website: { registrationUrl: 'https://reg.example.com' }, settings: { registrationOpen: false } });
|
||||
|
||||
const result = await registrationHandler({ plan: { intent: 'registration' }, bot, event, session: makeSession('register?'), turnLog: {} });
|
||||
|
||||
expect(result.cards[0].data.open).toBe(false);
|
||||
expect(result.reply).toMatch(/registration is currently closed/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkIn.handler', () => {
|
||||
test('adds a web check-in card and uses the Call 2 answer', async () => {
|
||||
mockEventSelect({ settings: { webCheckInEnabled: true }, joinCode: 'EVT123' });
|
||||
process.env.EVENTBUZZ_PUBLIC_URL = 'https://app.eventbuzz.live';
|
||||
generateConciergeResponse.mockResolvedValue({ reply: 'The check-in desk opens at 8am.', usage: null, modelId: 'm', noContext: false });
|
||||
|
||||
const result = await checkInHandler({ plan: { intent: 'check_in_on_site' }, bot, event, session: makeSession('where do I check in?'), turnLog: {} });
|
||||
|
||||
expect(result.usedCall2).toBe(true);
|
||||
expect(result.reply).toBe('The check-in desk opens at 8am.');
|
||||
expect(result.cards[0]).toEqual({
|
||||
type: 'checkin',
|
||||
data: expect.objectContaining({ method: 'web', url: 'https://app.eventbuzz.live/code/EVT123' }),
|
||||
});
|
||||
});
|
||||
|
||||
test('falls back to generic guidance when the KB has nothing', async () => {
|
||||
mockEventSelect({ settings: { webCheckInEnabled: false }, joinCode: null });
|
||||
generateConciergeResponse.mockResolvedValue({ reply: 'no ctx', noContext: true, usage: null, modelId: null });
|
||||
|
||||
const result = await checkInHandler({ plan: { intent: 'check_in_on_site' }, bot, event, session: makeSession('check in?'), turnLog: {} });
|
||||
|
||||
expect(result.cards).toHaveLength(0);
|
||||
expect(result.reply).toMatch(/registration\/check-in desk/i);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
jest.mock('../conciergeCall2.service', () => ({
|
||||
generateConciergeResponse: jest.fn(),
|
||||
NO_CONTEXT_REPLY: 'no ctx',
|
||||
}));
|
||||
jest.mock('../../models/Event', () => ({
|
||||
findById: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../models/Session', () => ({
|
||||
find: jest.fn(),
|
||||
}));
|
||||
|
||||
const { generateConciergeResponse } = require('../conciergeCall2.service');
|
||||
const Event = require('../../models/Event');
|
||||
const Session = require('../../models/Session');
|
||||
|
||||
const virtualJoinHandler = require('./virtualJoin.handler');
|
||||
const virtualSessionJoinHandler = require('./virtualSessionJoin.handler');
|
||||
|
||||
const bot = { event: 'evt-1', organization: 'org-1', knowledge: {} };
|
||||
const event = { name: 'Test Event' };
|
||||
|
||||
function makeSession(text) {
|
||||
return { sessionId: 's', history: [{ role: 'user', text }] };
|
||||
}
|
||||
function mockEventSelect(doc) {
|
||||
Event.findById.mockReturnValue({ select: () => ({ lean: () => Promise.resolve(doc) }) });
|
||||
}
|
||||
function mockSessionFind(sessions) {
|
||||
Session.find.mockReturnValue({
|
||||
select: () => ({ sort: () => ({ lean: () => Promise.resolve(sessions) }) }),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
process.env.EVENTBUZZ_PUBLIC_URL = 'https://app.eventbuzz.live';
|
||||
});
|
||||
|
||||
describe('virtualJoin.handler', () => {
|
||||
test('returns a join card from the event virtualLink (no Call 2)', async () => {
|
||||
mockEventSelect({ virtualLink: 'https://stream.example.com/live', joinCode: 'EVT9', name: 'Test Event' });
|
||||
|
||||
const result = await virtualJoinHandler({ plan: { intent: 'virtual_join' }, bot, event, session: makeSession('how do I join online?'), turnLog: {} });
|
||||
|
||||
expect(generateConciergeResponse).not.toHaveBeenCalled();
|
||||
expect(result.usedCall2).toBe(false);
|
||||
expect(result.cards[0]).toEqual({
|
||||
type: 'join',
|
||||
data: expect.objectContaining({ kind: 'event', url: 'https://stream.example.com/live', virtualLink: 'https://stream.example.com/live' }),
|
||||
});
|
||||
expect(result.reply).toContain('https://stream.example.com/live');
|
||||
});
|
||||
|
||||
test('falls back to the join-code URL when there is no virtualLink', async () => {
|
||||
mockEventSelect({ virtualLink: '', joinCode: 'EVT9', name: 'Test Event' });
|
||||
|
||||
const result = await virtualJoinHandler({ plan: { intent: 'virtual_join' }, bot, event, session: makeSession('join link?'), turnLog: {} });
|
||||
|
||||
expect(result.usedCall2).toBe(false);
|
||||
expect(result.cards[0].data.url).toBe('https://app.eventbuzz.live/code/EVT9');
|
||||
expect(result.cards[0].data.virtualLink).toBeNull();
|
||||
});
|
||||
|
||||
test('falls back to Call 2 when no link can be resolved', async () => {
|
||||
mockEventSelect({ virtualLink: '', joinCode: null, name: 'Test Event' });
|
||||
generateConciergeResponse.mockResolvedValue({ reply: 'Use the Zoom app to join.', usage: null, modelId: 'm', noContext: false });
|
||||
|
||||
const result = await virtualJoinHandler({ plan: { intent: 'virtual_join' }, bot, event, session: makeSession('what platform?'), turnLog: {} });
|
||||
|
||||
expect(generateConciergeResponse).toHaveBeenCalledTimes(1);
|
||||
expect(result.usedCall2).toBe(true);
|
||||
expect(result.reply).toBe('Use the Zoom app to join.');
|
||||
expect(result.cards).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('virtualSessionJoin.handler', () => {
|
||||
const now = Date.now();
|
||||
const hour = 60 * 60 * 1000;
|
||||
|
||||
test('prefers live sessions and returns session_join cards (no Call 2)', async () => {
|
||||
mockSessionFind([
|
||||
{ _id: 'live1', title: 'Keynote', startTime: new Date(now - hour), endTime: new Date(now + hour), status: 'scheduled', joinCode: 'S1' },
|
||||
{ _id: 'up1', title: 'Later Talk', startTime: new Date(now + 2 * hour), endTime: new Date(now + 3 * hour), status: 'scheduled', joinCode: 'S2' },
|
||||
]);
|
||||
|
||||
const result = await virtualSessionJoinHandler({ plan: { intent: 'virtual_session_join' }, bot, event, session: makeSession('what can I join now?'), turnLog: {} });
|
||||
|
||||
expect(generateConciergeResponse).not.toHaveBeenCalled();
|
||||
expect(result.usedCall2).toBe(false);
|
||||
expect(result.cards).toHaveLength(1);
|
||||
expect(result.cards[0]).toEqual({
|
||||
type: 'session_join',
|
||||
data: expect.objectContaining({ sessionId: 'live1', title: 'Keynote', url: 'https://app.eventbuzz.live/code/S1' }),
|
||||
});
|
||||
expect(result.reply).toMatch(/live now/i);
|
||||
});
|
||||
|
||||
test('shows upcoming sessions when nothing is live', async () => {
|
||||
mockSessionFind([
|
||||
{ _id: 'up1', title: 'Morning Talk', startTime: new Date(now + hour), endTime: new Date(now + 2 * hour), status: 'scheduled', joinCode: 'S2' },
|
||||
{ _id: 'past1', title: 'Old Talk', startTime: new Date(now - 3 * hour), endTime: new Date(now - 2 * hour), status: 'scheduled', joinCode: 'S3' },
|
||||
]);
|
||||
|
||||
const result = await virtualSessionJoinHandler({ plan: { intent: 'virtual_session_join' }, bot, event, session: makeSession('join a session'), turnLog: {} });
|
||||
|
||||
expect(result.usedCall2).toBe(false);
|
||||
expect(result.cards).toHaveLength(1);
|
||||
expect(result.cards[0].data.sessionId).toBe('up1');
|
||||
expect(result.reply).toMatch(/next sessions/i);
|
||||
});
|
||||
|
||||
test('falls back to Call 2 when nothing is joinable', async () => {
|
||||
mockSessionFind([
|
||||
{ _id: 'past1', title: 'Old Talk', startTime: new Date(now - 3 * hour), endTime: new Date(now - 2 * hour), status: 'scheduled', joinCode: 'S3' },
|
||||
]);
|
||||
generateConciergeResponse.mockResolvedValue({ reply: 'no ctx', noContext: true, usage: null, modelId: null });
|
||||
|
||||
const result = await virtualSessionJoinHandler({ plan: { intent: 'virtual_session_join' }, bot, event, session: makeSession('join?'), turnLog: {} });
|
||||
|
||||
expect(generateConciergeResponse).toHaveBeenCalledTimes(1);
|
||||
expect(result.cards).toHaveLength(0);
|
||||
expect(result.reply).toMatch(/no sessions open to join/i);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
const Event = require('../../models/Event');
|
||||
const { registrationHref } = require('../../utils/micrositeBuild');
|
||||
const { generateConciergeResponse } = require('../conciergeCall2.service');
|
||||
|
||||
/**
|
||||
* registration: cards-first. Return a registration link card built from the
|
||||
* event's website/registration config. Falls back to Call 2 when no link can
|
||||
* be resolved (e.g. process-only questions).
|
||||
*/
|
||||
module.exports = async function registrationHandler({ plan, bot, event, session, turnLog }) {
|
||||
let ev = null;
|
||||
try {
|
||||
ev = await Event.findById(bot.event).select('website settings').lean();
|
||||
} catch (err) {
|
||||
console.warn('[concierge][registration] event load failed:', err?.message || err);
|
||||
}
|
||||
|
||||
const website = ev?.website || {};
|
||||
const url = ev ? registrationHref({ _id: bot.event, website }, website) : '';
|
||||
const settings = ev?.settings || {};
|
||||
const isClosed = settings.registrationOpen === false;
|
||||
const deadline = settings.registrationDeadline
|
||||
? new Date(settings.registrationDeadline).toISOString()
|
||||
: null;
|
||||
|
||||
if (url) {
|
||||
const cards = [
|
||||
{
|
||||
type: 'registration',
|
||||
data: { url, label: isClosed ? 'Registration closed' : 'Register now', open: !isClosed, deadline },
|
||||
},
|
||||
];
|
||||
const reply = isClosed
|
||||
? `Registration is currently closed for ${event?.name || 'this event'}.`
|
||||
: `You can register for ${event?.name || 'this event'} using the button below.`;
|
||||
return { reply, cards, usedCall2: false };
|
||||
}
|
||||
|
||||
const call2 = await generateConciergeResponse({ bot, event, session, plan, turnLog });
|
||||
return { reply: call2.reply, usedCall2: true, usage: call2.usage, modelId: call2.modelId };
|
||||
};
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
const { ALL_INTENTS, INTENTS } = require('../conciergeRouter.service');
|
||||
const { INTENT_HANDLERS, getIntentHandler } = require('./index');
|
||||
|
||||
describe('concierge intent handler registry', () => {
|
||||
test('every routable intent has a registered handler', () => {
|
||||
const missing = ALL_INTENTS.filter((intent) => typeof INTENT_HANDLERS[intent] !== 'function');
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
test('venue_navigation and check_in_on_site are handled', () => {
|
||||
expect(typeof getIntentHandler(INTENTS.VENUE_NAVIGATION)).toBe('function');
|
||||
expect(typeof getIntentHandler(INTENTS.CHECK_IN_ON_SITE)).toBe('function');
|
||||
});
|
||||
|
||||
test('getIntentHandler returns null for an unrouted (null) intent', () => {
|
||||
expect(getIntentHandler(null)).toBeNull();
|
||||
expect(getIntentHandler('nonexistent_intent')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* clarification / out_of_scope handlers.
|
||||
* clarification stays direct; out_of_scope uses conversational Call 2 (history-aware).
|
||||
*/
|
||||
|
||||
const {
|
||||
generateConversationalReply,
|
||||
OUT_OF_SCOPE_FALLBACK,
|
||||
} = require('../conciergeCall2.service');
|
||||
|
||||
async function clarificationHandler({ plan }) {
|
||||
const direct = plan?.directReply && plan.directReply.trim();
|
||||
const reply = direct
|
||||
|| 'I want to point you to the right thing — could you tell me a bit more about what you need? '
|
||||
+ 'I can help with the event schedule, sessions, speakers, tickets, food and places nearby, and your own registration.';
|
||||
return { reply, usedCall2: false };
|
||||
}
|
||||
|
||||
async function outOfScopeHandler({ plan, bot, event, session, turnLog }) {
|
||||
const direct = plan?.directReply && plan.directReply.trim();
|
||||
if (direct) return { reply: direct, usedCall2: false };
|
||||
|
||||
try {
|
||||
const result = await generateConversationalReply({ bot, event, session, plan, turnLog });
|
||||
return {
|
||||
reply: result.reply || OUT_OF_SCOPE_FALLBACK,
|
||||
usedCall2: true,
|
||||
usage: result.usage || null,
|
||||
modelId: result.modelId || null,
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn('[concierge][out_of_scope] conversational failed:', err?.message || err);
|
||||
return { reply: OUT_OF_SCOPE_FALLBACK, usedCall2: false };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { clarificationHandler, outOfScopeHandler };
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
jest.mock('../conciergeCall2.service', () => ({
|
||||
generateConversationalReply: jest.fn(),
|
||||
OUT_OF_SCOPE_FALLBACK:
|
||||
"I may not be the best help for that specific ask, but I can still help with this event — schedule, sessions, speakers, tickets, registration, travel, food, and places nearby. What would you like to know?",
|
||||
}));
|
||||
|
||||
const { generateConversationalReply, OUT_OF_SCOPE_FALLBACK } = require('../conciergeCall2.service');
|
||||
const { outOfScopeHandler, clarificationHandler } = require('./simpleReplies.handler');
|
||||
|
||||
describe('outOfScopeHandler', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
test('uses conversational Call 2 reply when available', async () => {
|
||||
generateConversationalReply.mockResolvedValue({
|
||||
reply: 'Sorry that was frustrating — I can still help with your registration status or the event schedule.',
|
||||
usage: null,
|
||||
modelId: 'test-model',
|
||||
});
|
||||
|
||||
const result = await outOfScopeHandler({
|
||||
plan: { intent: 'out_of_scope' },
|
||||
bot: { event: 'e1' },
|
||||
event: { name: 'Summit' },
|
||||
session: { history: [{ role: 'user', text: "I'm tired of you." }] },
|
||||
turnLog: {},
|
||||
});
|
||||
|
||||
expect(generateConversationalReply).toHaveBeenCalled();
|
||||
expect(result.usedCall2).toBe(true);
|
||||
expect(result.reply).toMatch(/frustrating|registration|schedule/i);
|
||||
expect(result.reply).not.toMatch(/I'm the assistant for/i);
|
||||
});
|
||||
|
||||
test('falls back when conversational Call 2 throws', async () => {
|
||||
generateConversationalReply.mockRejectedValue(new Error('llm down'));
|
||||
|
||||
const result = await outOfScopeHandler({
|
||||
plan: { intent: 'out_of_scope' },
|
||||
bot: {},
|
||||
event: { name: 'Summit' },
|
||||
session: { history: [] },
|
||||
turnLog: {},
|
||||
});
|
||||
|
||||
expect(result.reply).toBe(OUT_OF_SCOPE_FALLBACK);
|
||||
expect(result.usedCall2).toBe(false);
|
||||
});
|
||||
|
||||
test('clarification still returns a direct template', async () => {
|
||||
const result = await clarificationHandler({ plan: {} });
|
||||
expect(result.usedCall2).toBe(false);
|
||||
expect(result.reply).toMatch(/tell me a bit more/i);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
const EventTicket = require('../../models/EventTicket');
|
||||
const { generateConciergeResponse } = require('../conciergeCall2.service');
|
||||
|
||||
function formatPrice(ticket) {
|
||||
const minor = ticket?.basePriceMinor;
|
||||
if (minor == null || Number.isNaN(Number(minor))) return null;
|
||||
const major = Number(minor) / 100;
|
||||
if (major === 0) return 'Free';
|
||||
const currency = (ticket.currency || 'INR').toUpperCase();
|
||||
return `${currency} ${major.toFixed(2)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* ticket_pricing: cards-first. Return deterministic ticket cards from the
|
||||
* EventTicket collection. If there are no active tickets, fall back to Call 2
|
||||
* for any policy/FAQ answer grounded in the event knowledge base.
|
||||
*/
|
||||
module.exports = async function ticketingHandler({ plan, bot, event, session, turnLog }) {
|
||||
let tickets = [];
|
||||
try {
|
||||
tickets = await EventTicket.find({ event: bot.event, isActive: true })
|
||||
.sort({ sortOrder: 1, name: 1 })
|
||||
.lean();
|
||||
} catch (err) {
|
||||
console.warn('[concierge][ticketing] ticket load failed:', err?.message || err);
|
||||
}
|
||||
|
||||
if (Array.isArray(tickets) && tickets.length) {
|
||||
const cards = tickets.slice(0, 6).map((t) => ({
|
||||
type: 'ticket',
|
||||
data: {
|
||||
ticketId: String(t._id),
|
||||
name: t.name,
|
||||
price: formatPrice(t),
|
||||
currency: (t.currency || 'INR').toUpperCase(),
|
||||
description: t.description || '',
|
||||
keyPoints: Array.isArray(t.keyPoints) ? t.keyPoints.slice(0, 5) : [],
|
||||
},
|
||||
}));
|
||||
|
||||
return {
|
||||
reply: `Here are the ticket options for ${event?.name || 'this event'}.`,
|
||||
cards,
|
||||
usedCall2: false,
|
||||
};
|
||||
}
|
||||
|
||||
const call2 = await generateConciergeResponse({ bot, event, session, plan, turnLog });
|
||||
return { reply: call2.reply, usedCall2: true, usage: call2.usage, modelId: call2.modelId };
|
||||
};
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
const { runConciergePipeline } = require('../conciergePipeline.service');
|
||||
|
||||
/**
|
||||
* tourist_places: reuse the grounded places selection pipeline (Google
|
||||
* Places-backed DB + LLM selection) and surface the results as place cards.
|
||||
*/
|
||||
module.exports = async function touristPlacesHandler({ bot, event, session }) {
|
||||
const result = await runConciergePipeline({
|
||||
eventId: String(bot.event),
|
||||
eventName: event?.name || 'Event',
|
||||
eventLocation: String(event?.location || '').trim(),
|
||||
bot,
|
||||
sessionHistory: (session?.history || []).map((m) => ({ role: m.role, text: m.text })),
|
||||
intentResult: { category: 'Places', databaseSearchNeeded: true },
|
||||
chatSession: session,
|
||||
organizationId: String(bot.organization),
|
||||
});
|
||||
|
||||
const places = Array.isArray(result?.places) ? result.places.slice(0, 5) : [];
|
||||
const cards = places.map((data) => ({ type: 'place', data }));
|
||||
|
||||
return {
|
||||
reply: result?.text || '',
|
||||
places,
|
||||
cards,
|
||||
usedCall2: false,
|
||||
usage: result?.usage || null,
|
||||
modelId: result?.modelId || null,
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
const Event = require('../../models/Event');
|
||||
const { joinCodeUrl } = require('../../utils/conciergeEventKnowledge');
|
||||
const { generateConciergeResponse } = require('../conciergeCall2.service');
|
||||
|
||||
/**
|
||||
* virtual_join: cards-first. Surface the event-level online join link (the
|
||||
* configured virtualLink, or the join-code URL as a fallback) as a `join` card
|
||||
* so the widget can render a one-tap action. Only when no link can be resolved
|
||||
* do we fall back to Call 2 for a knowledge-grounded answer (e.g. platform FAQ).
|
||||
*/
|
||||
module.exports = async function virtualJoinHandler({ plan, bot, event, session, turnLog }) {
|
||||
let ev = null;
|
||||
try {
|
||||
ev = await Event.findById(bot.event).select('virtualLink joinCode name').lean();
|
||||
} catch (err) {
|
||||
console.warn('[concierge][virtual-join] event load failed:', err?.message || err);
|
||||
}
|
||||
|
||||
const virtualLink = ev?.virtualLink ? String(ev.virtualLink).trim() : '';
|
||||
const url = virtualLink || joinCodeUrl(ev?.joinCode);
|
||||
const name = event?.name || ev?.name || 'this event';
|
||||
|
||||
if (url) {
|
||||
const cards = [
|
||||
{
|
||||
type: 'join',
|
||||
data: { kind: 'event', url, label: 'Join online', virtualLink: virtualLink || null },
|
||||
},
|
||||
];
|
||||
return {
|
||||
reply: `Here's your link to join ${name} online: ${url}`,
|
||||
cards,
|
||||
usedCall2: false,
|
||||
};
|
||||
}
|
||||
|
||||
const call2 = await generateConciergeResponse({ bot, event, session, plan, turnLog });
|
||||
const reply =
|
||||
call2.noContext || !call2.reply
|
||||
? `The online joining details for ${name} aren't available yet. Please check your registration email or contact the organizer for the access link.`
|
||||
: call2.reply;
|
||||
return { reply, cards: [], usedCall2: true, usage: call2.usage, modelId: call2.modelId };
|
||||
};
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
const Session = require('../../models/Session');
|
||||
const { joinCodeUrl } = require('../../utils/conciergeEventKnowledge');
|
||||
const { generateConciergeResponse } = require('../conciergeCall2.service');
|
||||
|
||||
/** Classify a session relative to `now` using its start/end times and status. */
|
||||
function sessionState(s, now) {
|
||||
if (String(s.status || '').toLowerCase() === 'cancelled') return 'cancelled';
|
||||
const start = s.startTime ? new Date(s.startTime).getTime() : null;
|
||||
const end = s.endTime ? new Date(s.endTime).getTime() : null;
|
||||
if (start != null && end != null && now >= start && now <= end) return 'live';
|
||||
if (start != null && now < start) return 'upcoming';
|
||||
return 'past';
|
||||
}
|
||||
|
||||
function toCard(s) {
|
||||
return {
|
||||
type: 'session_join',
|
||||
data: {
|
||||
sessionId: String(s._id),
|
||||
title: s.title || 'Session',
|
||||
startTime: s.startTime ? new Date(s.startTime).toISOString() : null,
|
||||
endTime: s.endTime ? new Date(s.endTime).toISOString() : null,
|
||||
url: joinCodeUrl(s.joinCode),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* virtual_session_join: cards-first. Show `session_join` cards for sessions the
|
||||
* participant can join online right now (live), or the next upcoming sessions
|
||||
* when nothing is live. Each session carries its own join-code URL. Falls back
|
||||
* to Call 2 only when there is nothing joinable to surface.
|
||||
*/
|
||||
module.exports = async function virtualSessionJoinHandler({ plan, bot, event, session, turnLog }) {
|
||||
let sessions = [];
|
||||
try {
|
||||
sessions = await Session.find({ event: bot.event, joinCode: { $nin: [null, ''] } })
|
||||
.select('title startTime endTime status joinCode')
|
||||
.sort({ startTime: 1 })
|
||||
.lean();
|
||||
} catch (err) {
|
||||
console.warn('[concierge][virtual-session-join] session load failed:', err?.message || err);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const withCode = (sessions || []).filter((s) => s.joinCode && sessionState(s, now) !== 'cancelled');
|
||||
const live = withCode.filter((s) => sessionState(s, now) === 'live');
|
||||
const upcoming = withCode.filter((s) => sessionState(s, now) === 'upcoming');
|
||||
const selected = (live.length ? live : upcoming).slice(0, 5);
|
||||
const name = event?.name || 'this event';
|
||||
|
||||
if (selected.length) {
|
||||
return {
|
||||
reply: live.length
|
||||
? 'These sessions are live now — tap a card to join:'
|
||||
: `No sessions are live right now. Here are the next sessions you'll be able to join for ${name}:`,
|
||||
cards: selected.map(toCard),
|
||||
usedCall2: false,
|
||||
};
|
||||
}
|
||||
|
||||
const call2 = await generateConciergeResponse({ bot, event, session, plan, turnLog });
|
||||
const reply =
|
||||
call2.noContext || !call2.reply
|
||||
? 'There are no sessions open to join right now. A join link will appear here once a session goes live.'
|
||||
: call2.reply;
|
||||
return { reply, cards: [], usedCall2: true, usage: call2.usage, modelId: call2.modelId };
|
||||
};
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* virtual_troubleshooting: common fixes for joining/AV issues on a virtual
|
||||
* event. Static guidance — no Call 2. A later phase can enrich this with the
|
||||
* event's specific platform/support details.
|
||||
*/
|
||||
module.exports = async function virtualTroubleshootingHandler({ plan }) {
|
||||
const direct = plan?.directReply && plan.directReply.trim();
|
||||
const reply = direct
|
||||
|| [
|
||||
"Sorry you're having trouble joining. A few quick things to try:",
|
||||
'1. Refresh the page and rejoin using the latest link from your confirmation email.',
|
||||
'2. Use the latest Chrome or Edge and check your internet connection.',
|
||||
'3. Allow the browser to use your microphone/camera if it asks.',
|
||||
'4. Turn off any VPN or ad-blocker and close other video apps.',
|
||||
'5. Try an incognito window or a different device.',
|
||||
"If it still doesn't work, contact event support and they'll help you get in.",
|
||||
].join('\n');
|
||||
return { reply, usedCall2: false };
|
||||
};
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
jest.mock('../utils/bedrockComplete', () => ({
|
||||
completeText: jest.fn(),
|
||||
DEFAULT_RECOMMENDATION_MODEL_ID: 'amazon.nova-micro-v1:0',
|
||||
getRecommendationModelCandidates: jest.fn(() => ['amazon.nova-micro-v1:0']),
|
||||
}));
|
||||
jest.mock('./foodDb.service', () => ({
|
||||
fetchFoodRecordForEvent: jest.fn(),
|
||||
}));
|
||||
jest.mock('./foodRecommendationService', () => ({
|
||||
getFoodRecommendations: jest.fn(),
|
||||
}));
|
||||
|
||||
const { completeText } = require('../utils/bedrockComplete');
|
||||
const { fetchFoodRecordForEvent } = require('./foodDb.service');
|
||||
const {
|
||||
runConciergePipeline,
|
||||
inferFoodQueryMode,
|
||||
parseFoodLlmDecision,
|
||||
selectFoodsByIds,
|
||||
} = require('./conciergePipeline.service');
|
||||
|
||||
const restaurants = [
|
||||
{
|
||||
place_id: 'ChIJ_place_a',
|
||||
name: 'Spice Villa',
|
||||
rating: 4.6,
|
||||
user_ratings_total: 200,
|
||||
cuisines: ['Goan', 'Vegetarian'],
|
||||
address: 'Panaji',
|
||||
location: { lat: 15.4, lng: 73.8 },
|
||||
},
|
||||
{
|
||||
place_id: 'ChIJ_place_b',
|
||||
name: 'Coastal Catch',
|
||||
rating: 4.4,
|
||||
user_ratings_total: 150,
|
||||
cuisines: ['Seafood'],
|
||||
address: 'Candolim',
|
||||
location: { lat: 15.5, lng: 73.7 },
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
fetchFoodRecordForEvent.mockResolvedValue({
|
||||
schemaVersion: 4,
|
||||
generatedAt: new Date().toISOString(),
|
||||
restaurants,
|
||||
});
|
||||
});
|
||||
|
||||
describe('inferFoodQueryMode', () => {
|
||||
test('treats regional cuisine overview as general', () => {
|
||||
const result = inferFoodQueryMode('what are the regional cuisines');
|
||||
expect(result.mode).toBe('general');
|
||||
expect(result.specificTerms).toEqual([]);
|
||||
});
|
||||
|
||||
test('keeps vegetarian as a specific diet term', () => {
|
||||
const result = inferFoodQueryMode('what kind of food will I get in Goa, any veg options', 'Goa');
|
||||
expect(result.mode).toBe('specific');
|
||||
expect(result.specificTerms).toContain('vegetarian');
|
||||
expect(result.specificTerms).not.toContain('goa');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseFoodLlmDecision', () => {
|
||||
test('parses places / text / both objects', () => {
|
||||
expect(parseFoodLlmDecision('{"mode":"places","foodIds":["ChIJ_place_a"],"reply":"Nearby spots."}')).toEqual({
|
||||
mode: 'places',
|
||||
foodIds: ['ChIJ_place_a'],
|
||||
reply: 'Nearby spots.',
|
||||
parseFailed: false,
|
||||
});
|
||||
expect(parseFoodLlmDecision('{"mode":"text","foodIds":[],"reply":"Goan food mixes coconut and seafood."}')).toEqual({
|
||||
mode: 'text',
|
||||
foodIds: [],
|
||||
reply: 'Goan food mixes coconut and seafood.',
|
||||
parseFailed: false,
|
||||
});
|
||||
expect(parseFoodLlmDecision('{"mode":"both","foodIds":["ChIJ_place_a","ChIJ_place_b"],"reply":"Veg is widely available."}')).toEqual({
|
||||
mode: 'both',
|
||||
foodIds: ['ChIJ_place_a', 'ChIJ_place_b'],
|
||||
reply: 'Veg is widely available.',
|
||||
parseFailed: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('treats a legacy ID array as places', () => {
|
||||
expect(parseFoodLlmDecision('["ChIJ_place_a","ChIJ_place_b"]')).toEqual({
|
||||
mode: 'places',
|
||||
foodIds: ['ChIJ_place_a', 'ChIJ_place_b'],
|
||||
reply: '',
|
||||
parseFailed: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('returns parseFailed for truncated/invalid JSON', () => {
|
||||
expect(parseFoodLlmDecision('["ChIJ_place_a", "ChIJ').parseFailed).toBe(true);
|
||||
expect(parseFoodLlmDecision('not json').parseFailed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectFoodsByIds', () => {
|
||||
test('hydrates only known IDs', () => {
|
||||
const foods = selectFoodsByIds(restaurants, ['ChIJ_place_a', 'missing', 'ChIJ_place_b'], 5);
|
||||
expect(foods).toHaveLength(2);
|
||||
expect(foods[0].name).toBe('Spice Villa');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runConciergePipeline Food modes', () => {
|
||||
const bot = { event: 'evt-1', organization: 'org-1', knowledge: {} };
|
||||
|
||||
test('cuisine question can return text with no food cards', async () => {
|
||||
completeText.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
mode: 'text',
|
||||
foodIds: [],
|
||||
reply: 'Goan cuisine is known for seafood, coconut, and Konkani spices. Vegetarian thalis are common too.',
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runConciergePipeline({
|
||||
eventId: 'evt-1',
|
||||
eventName: 'Summit',
|
||||
eventLocation: 'Goa',
|
||||
bot,
|
||||
sessionHistory: [{ role: 'user', text: 'what are the regional cuisines' }],
|
||||
intentResult: { category: 'Food', databaseSearchNeeded: true },
|
||||
});
|
||||
|
||||
expect(completeText).toHaveBeenCalled();
|
||||
const [userPrompt, options] = completeText.mock.calls[0];
|
||||
expect(userPrompt).toMatch(/EVENT LOCATION:\s*Goa/i);
|
||||
expect(options.system).toMatch(/held in \/ near:\s*Goa/i);
|
||||
expect(options.system).toMatch(/LOCATION GROUNDING/i);
|
||||
expect(result.foods).toEqual([]);
|
||||
expect(result.text).toMatch(/Goan cuisine/i);
|
||||
});
|
||||
|
||||
test('nearby restaurants returns places cards', async () => {
|
||||
completeText.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
mode: 'places',
|
||||
foodIds: ['ChIJ_place_a', 'ChIJ_place_b'],
|
||||
reply: 'Here are some highly rated spots near the venue.',
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runConciergePipeline({
|
||||
eventId: 'evt-1',
|
||||
eventName: 'Summit',
|
||||
bot,
|
||||
sessionHistory: [{ role: 'user', text: 'recommend restaurants near the venue' }],
|
||||
intentResult: { category: 'Food', databaseSearchNeeded: true },
|
||||
});
|
||||
|
||||
expect(result.foods).toHaveLength(2);
|
||||
expect(result.text).toMatch(/highly rated/i);
|
||||
});
|
||||
|
||||
test('both mode hydrates a subset of IDs with prose', async () => {
|
||||
completeText.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
mode: 'both',
|
||||
foodIds: ['ChIJ_place_a'],
|
||||
reply: 'Vegetarian meals are easy to find. Try these nearby:',
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runConciergePipeline({
|
||||
eventId: 'evt-1',
|
||||
eventName: 'Summit',
|
||||
bot,
|
||||
sessionHistory: [{ role: 'user', text: 'any veg options' }],
|
||||
intentResult: { category: 'Food', databaseSearchNeeded: true },
|
||||
});
|
||||
|
||||
expect(result.foods).toHaveLength(1);
|
||||
expect(result.foods[0].foodId).toBe('ChIJ_place_a');
|
||||
expect(result.text).toMatch(/Vegetarian/i);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,229 @@
|
|||
jest.mock('../utils/bedrockComplete', () => ({
|
||||
completeText: jest.fn(),
|
||||
DEFAULT_RECOMMENDATION_MODEL_ID: 'amazon.nova-micro-v1:0',
|
||||
getRecommendationModelCandidates: jest.fn(() => ['amazon.nova-micro-v1:0']),
|
||||
}));
|
||||
jest.mock('../utils/bedrockClient', () => ({
|
||||
createBedrockClient: jest.fn(() => ({})),
|
||||
getBedrockConfig: jest.fn(() => ({ region: 'ap-south-1' })),
|
||||
}));
|
||||
jest.mock('../utils/bedrockConverse', () => ({
|
||||
converse: jest.fn(),
|
||||
responseText: jest.fn((response) => response?.text || ''),
|
||||
}));
|
||||
jest.mock('./placesDb.service', () => ({
|
||||
fetchPlacesRecordForEvent: jest.fn(),
|
||||
}));
|
||||
jest.mock('./placesRecommendationService', () => {
|
||||
const actual = jest.requireActual('./placesRecommendationService');
|
||||
return {
|
||||
...actual,
|
||||
getPlacesRecommendations: jest.fn(),
|
||||
};
|
||||
});
|
||||
jest.mock('./foodDb.service', () => ({
|
||||
fetchFoodRecordForEvent: jest.fn(),
|
||||
}));
|
||||
jest.mock('./foodRecommendationService', () => ({
|
||||
getFoodRecommendations: jest.fn(),
|
||||
}));
|
||||
|
||||
const { converse, responseText } = require('../utils/bedrockConverse');
|
||||
const { fetchPlacesRecordForEvent } = require('./placesDb.service');
|
||||
const { getPlacesRecommendations } = require('./placesRecommendationService');
|
||||
const {
|
||||
runConciergePipeline,
|
||||
inferRequestedPlaceCategory,
|
||||
filterPlacesForQuestion,
|
||||
rankPlaces,
|
||||
wantsNearbyPlaces,
|
||||
} = require('./conciergePipeline.service');
|
||||
|
||||
const places = [
|
||||
{
|
||||
place_id: 'ChIJ_fort',
|
||||
name: 'Aguada Fort',
|
||||
rating: 4.6,
|
||||
user_ratings_total: 900,
|
||||
tags: ['tourist attraction', 'fort'],
|
||||
address: 'Candolim, Goa',
|
||||
distance_meters: 18000,
|
||||
},
|
||||
{
|
||||
place_id: 'ChIJ_beach',
|
||||
name: 'Calangute Beach',
|
||||
rating: 4.4,
|
||||
user_ratings_total: 1200,
|
||||
tags: ['beach'],
|
||||
address: 'Calangute, Goa',
|
||||
distance_meters: 15000,
|
||||
},
|
||||
{
|
||||
place_id: 'ChIJ_church',
|
||||
name: 'Basilica of Bom Jesus',
|
||||
rating: 4.7,
|
||||
user_ratings_total: 25000,
|
||||
tags: ['church', 'basilica', 'tourist attraction'],
|
||||
address: 'Old Goa',
|
||||
distance_meters: 12000,
|
||||
},
|
||||
{
|
||||
place_id: 'ChIJ_near',
|
||||
name: 'Miramar View Point',
|
||||
rating: 4.5,
|
||||
user_ratings_total: 298,
|
||||
tags: ['tourist attraction'],
|
||||
address: 'Miramar, Panaji',
|
||||
distance_meters: 400,
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
fetchPlacesRecordForEvent.mockResolvedValue({
|
||||
generatedAt: new Date().toISOString(),
|
||||
location: 'Goa',
|
||||
destination: 'Goa',
|
||||
searchMode: 'destination',
|
||||
schemaVersion: 5,
|
||||
places,
|
||||
recommendations: '',
|
||||
});
|
||||
converse.mockResolvedValue({ text: '["ChIJ_fort","ChIJ_beach"]' });
|
||||
responseText.mockImplementation((response) => response?.text || '');
|
||||
});
|
||||
|
||||
describe('inferRequestedPlaceCategory', () => {
|
||||
test('matches "what places can I visit" as tourist attractions', () => {
|
||||
const category = inferRequestedPlaceCategory(
|
||||
'I will stay back 1 more day in Goa after the event, what places can I visit',
|
||||
);
|
||||
expect(category?.label).toBe('tourist attractions');
|
||||
});
|
||||
|
||||
test('still prefers temples over broad visit phrasing', () => {
|
||||
expect(inferRequestedPlaceCategory('any temples to visit nearby')?.label).toBe('temples');
|
||||
});
|
||||
|
||||
test('matches churches / basilica / cathedral', () => {
|
||||
expect(inferRequestedPlaceCategory('any churches nearby')?.label).toBe('churches');
|
||||
expect(inferRequestedPlaceCategory('famous basilicas to see')?.label).toBe('churches');
|
||||
});
|
||||
|
||||
test('matches forts', () => {
|
||||
expect(inferRequestedPlaceCategory('any forts')?.label).toBe('forts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterPlacesForQuestion', () => {
|
||||
test('broad visit questions keep places even when tags are sparse', () => {
|
||||
const sparse = [
|
||||
{ place_id: 'a', name: 'Old Goa Churches', rating: 4.5, tags: ['point of interest'] },
|
||||
];
|
||||
const { places: filtered } = filterPlacesForQuestion(
|
||||
sparse,
|
||||
'what places can I visit',
|
||||
);
|
||||
expect(filtered).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('church questions return only church-tagged places', () => {
|
||||
const { requestedCategory, places: filtered } = filterPlacesForQuestion(
|
||||
places,
|
||||
'any churches nearby',
|
||||
);
|
||||
expect(requestedCategory?.label).toBe('churches');
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0].place_id).toBe('ChIJ_church');
|
||||
});
|
||||
|
||||
test('fort questions return only fort-tagged places', () => {
|
||||
const { requestedCategory, places: filtered } = filterPlacesForQuestion(places, 'any forts');
|
||||
expect(requestedCategory?.label).toBe('forts');
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0].place_id).toBe('ChIJ_fort');
|
||||
});
|
||||
});
|
||||
|
||||
describe('wantsNearbyPlaces', () => {
|
||||
test('detects nearby intent', () => {
|
||||
expect(wantsNearbyPlaces('any forts nearby')).toBe(true);
|
||||
expect(wantsNearbyPlaces('places close to the venue')).toBe(true);
|
||||
expect(wantsNearbyPlaces('any forts')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rankPlaces', () => {
|
||||
test('prefers high review count over slightly higher rating with few reviews', () => {
|
||||
const ranked = rankPlaces(
|
||||
[
|
||||
{ place_id: 'niche', name: 'Quiet Spot', rating: 4.9, user_ratings_total: 12 },
|
||||
{ place_id: 'famous', name: 'Famous Spot', rating: 4.4, user_ratings_total: 18000 },
|
||||
{ place_id: 'mid', name: 'Mid Spot', rating: 4.6, user_ratings_total: 800 },
|
||||
],
|
||||
3,
|
||||
);
|
||||
expect(ranked.map((p) => p.place_id)).toEqual(['famous', 'mid', 'niche']);
|
||||
});
|
||||
|
||||
test('preferNearby sorts by distance then reviews', () => {
|
||||
const ranked = rankPlaces(places, 3, { preferNearby: true });
|
||||
expect(ranked[0].place_id).toBe('ChIJ_near');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runConciergePipeline Places', () => {
|
||||
const bot = { event: 'evt-1', organization: 'org-1', knowledge: {} };
|
||||
|
||||
test('does not short-circuit broad visit questions to empty fallback', async () => {
|
||||
const result = await runConciergePipeline({
|
||||
eventId: 'evt-1',
|
||||
eventName: 'Summit',
|
||||
eventLocation: 'Goa Marriott Resort & Spa',
|
||||
bot,
|
||||
sessionHistory: [{
|
||||
role: 'user',
|
||||
text: 'I will stay back 1 more day in Goa after the event, what places can I visit',
|
||||
}],
|
||||
intentResult: { category: 'Places', databaseSearchNeeded: true },
|
||||
});
|
||||
|
||||
expect(result.text).not.toMatch(/No places available nearby right now/i);
|
||||
expect(result.text).toMatch(/in Goa/i);
|
||||
expect(result.text).not.toMatch(/near Goa Marriott/i);
|
||||
expect(result.places.length).toBeGreaterThan(0);
|
||||
expect(getPlacesRecommendations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('legacy cache without destination triggers refresh', async () => {
|
||||
fetchPlacesRecordForEvent
|
||||
.mockResolvedValueOnce({
|
||||
generatedAt: new Date().toISOString(),
|
||||
location: 'Goa Marriott Resort & Spa',
|
||||
places,
|
||||
recommendations: '',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
generatedAt: new Date().toISOString(),
|
||||
location: 'Goa',
|
||||
destination: 'Goa',
|
||||
searchMode: 'destination',
|
||||
schemaVersion: 5,
|
||||
places,
|
||||
recommendations: '',
|
||||
});
|
||||
getPlacesRecommendations.mockResolvedValue('refreshed');
|
||||
|
||||
const result = await runConciergePipeline({
|
||||
eventId: 'evt-1',
|
||||
eventName: 'Summit',
|
||||
eventLocation: 'Goa Marriott Resort & Spa',
|
||||
bot,
|
||||
sessionHistory: [{ role: 'user', text: 'any forts' }],
|
||||
intentResult: { category: 'Places', databaseSearchNeeded: true },
|
||||
});
|
||||
|
||||
expect(getPlacesRecommendations).toHaveBeenCalled();
|
||||
expect(result.places.some((p) => p.placeId === 'ChIJ_fort' || p.name === 'Aguada Fort')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
const { executeConciergeRag } = require('./conciergeRag.service');
|
||||
const { determineIntentCategory } = require('../utils/conciergeIntent');
|
||||
const { getFoodRecommendations } = require('./foodRecommendationService');
|
||||
const { fetchFoodRecordForEvent } = require('./foodDb.service');
|
||||
const { fetchPlacesRecordForEvent } = require('./placesDb.service');
|
||||
|
|
@ -140,16 +139,51 @@ function normalizedFoodWords(value) {
|
|||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function inferFoodQueryMode(question) {
|
||||
function resolveEventLocationLabel({ eventLocation, bot, foodRecord, placesRecord } = {}) {
|
||||
return String(
|
||||
eventLocation
|
||||
|| bot?.eventLocation
|
||||
|| bot?.location
|
||||
|| foodRecord?.location
|
||||
|| placesRecord?.location
|
||||
|| '',
|
||||
).trim();
|
||||
}
|
||||
|
||||
/** Destination city/region for tourist places (prefer cached destination over venue/hotel). */
|
||||
function resolvePlacesDestinationLabel({ eventLocation, bot, placesRecord } = {}) {
|
||||
return String(
|
||||
placesRecord?.destination
|
||||
|| placesRecord?.location
|
||||
|| eventLocation
|
||||
|| bot?.eventLocation
|
||||
|| bot?.location
|
||||
|| '',
|
||||
).trim();
|
||||
}
|
||||
|
||||
function wantsNearbyPlaces(question) {
|
||||
const q = String(question || '').toLowerCase();
|
||||
return /\b(near(by)?|close to|walking distance|around the venue|near the (venue|hotel|resort)|near me)\b/i.test(q);
|
||||
}
|
||||
|
||||
function inferFoodQueryMode(question, eventLocation = '') {
|
||||
const words = normalizedFoodWords(question);
|
||||
const genericWords = new Set([
|
||||
'a', 'an', 'and', 'any', 'at', 'best', 'can', 'could', 'eat', 'food', 'foods',
|
||||
'for', 'get', 'good', 'have', 'i', 'in', 'is', 'lunch', 'dinner', 'breakfast',
|
||||
'find', 'give', 'looking', 'me', 'meal', 'meals', 'near', 'nearby', 'place',
|
||||
'places', 'please', 'recommend', 'show', 'some', 'suggest', 'shop', 'shops',
|
||||
'recommendation', 'recommendations', 'restaurant', 'restaurants', 'the', 'to',
|
||||
'try', 'venue', 'want', 'where', 'which', 'would', 'you', 'around',
|
||||
'a', 'an', 'and', 'any', 'at', 'about', 'are', 'best', 'can', 'could', 'cuisine', 'cuisines',
|
||||
'do', 'does', 'eat', 'food', 'foods', 'for', 'get', 'give',
|
||||
'good', 'have', 'i', 'in', 'is', 'it', 'kind', 'kinds', 'know', 'known',
|
||||
'lunch', 'dinner', 'breakfast',
|
||||
'find', 'looking', 'me', 'meal', 'meals', 'more', 'near', 'nearby', 'of', 'on', 'option', 'options',
|
||||
'or', 'place', 'places', 'please', 'recommend', 'regional', 'show', 'some', 'suggest', 'shop', 'shops',
|
||||
'recommendation', 'recommendations', 'restaurant', 'restaurants', 'tell', 'the', 'there', 'this', 'that',
|
||||
'to', 'try', 'type', 'types', 'venue', 'want', 'what', 'where', 'which', 'will', 'with', 'would', 'you',
|
||||
'around',
|
||||
]);
|
||||
// Treat the event city/region tokens as filler so "food in Goa" stays general for a Goa event.
|
||||
for (const token of normalizedFoodWords(eventLocation)) {
|
||||
if (token.length >= 3) genericWords.add(token);
|
||||
}
|
||||
const specificTerms = [...new Set(words.filter((word) => !genericWords.has(word)))];
|
||||
|
||||
return {
|
||||
|
|
@ -177,7 +211,8 @@ function filterFoodRecordsByTerms(records, terms) {
|
|||
if (!Array.isArray(records) || !Array.isArray(terms) || !terms.length) return [];
|
||||
return records.filter((record) => {
|
||||
const evidence = foodRecordEvidenceWords(record);
|
||||
return terms.every((term) => evidence.has(normalizeFoodWord(term)));
|
||||
// Match if any specific term appears (AND across noisy multi-term queries was too strict).
|
||||
return terms.some((term) => evidence.has(normalizeFoodWord(term)));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -231,6 +266,75 @@ function parseFoodIdsFromLlmText(text) {
|
|||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse food LLM decision: places | text | both (+ reply + foodIds).
|
||||
* Legacy bare JSON arrays are treated as mode "places".
|
||||
*/
|
||||
function parseFoodLlmDecision(text) {
|
||||
const empty = { mode: null, foodIds: [], reply: '', parseFailed: true };
|
||||
const raw = String(text || '').trim();
|
||||
if (!raw) return empty;
|
||||
|
||||
const candidates = [raw];
|
||||
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||||
if (fenced?.[1]) candidates.push(fenced[1].trim());
|
||||
const objectMatch = raw.match(/\{[\s\S]*\}/);
|
||||
if (objectMatch?.[0]) candidates.push(objectMatch[0]);
|
||||
const arrayMatch = raw.match(/\[[\s\S]*\]/);
|
||||
if (arrayMatch?.[0]) candidates.push(arrayMatch[0]);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(candidate);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
const foodIds = parsed
|
||||
.map((item) => (typeof item === 'string' ? item : item?.food_id || item?.foodId || item?.place_id || item?.id))
|
||||
.map(normalizePlaceId)
|
||||
.filter(Boolean)
|
||||
.slice(0, 5);
|
||||
return { mode: 'places', foodIds, reply: '', parseFailed: false };
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object') continue;
|
||||
|
||||
const modeRaw = String(parsed.mode || '').toLowerCase().trim();
|
||||
const mode = ['places', 'text', 'both'].includes(modeRaw) ? modeRaw : null;
|
||||
const idSource = parsed.foodIds || parsed.food_ids || parsed.ids || [];
|
||||
const foodIds = (Array.isArray(idSource) ? idSource : [])
|
||||
.map((item) => (typeof item === 'string' ? item : item?.food_id || item?.foodId || item?.place_id || item?.id))
|
||||
.map(normalizePlaceId)
|
||||
.filter(Boolean)
|
||||
.slice(0, 5);
|
||||
const reply = typeof parsed.reply === 'string'
|
||||
? parsed.reply.trim()
|
||||
: typeof parsed.text === 'string'
|
||||
? parsed.text.trim()
|
||||
: '';
|
||||
|
||||
if (!mode && foodIds.length) {
|
||||
return { mode: 'places', foodIds, reply, parseFailed: false };
|
||||
}
|
||||
if (!mode && reply) {
|
||||
return { mode: 'text', foodIds: [], reply, parseFailed: false };
|
||||
}
|
||||
if (!mode) continue;
|
||||
|
||||
return {
|
||||
mode,
|
||||
foodIds: mode === 'text' ? [] : foodIds,
|
||||
reply,
|
||||
parseFailed: false,
|
||||
};
|
||||
}
|
||||
|
||||
return empty;
|
||||
}
|
||||
|
||||
function rankFoodRecords(records, limit = 5) {
|
||||
return (Array.isArray(records) ? records : [])
|
||||
.slice()
|
||||
|
|
@ -288,12 +392,18 @@ function isTouristAttractionLike(place) {
|
|||
'zoo',
|
||||
'temple',
|
||||
'church',
|
||||
'cathedral',
|
||||
'basilica',
|
||||
'mosque',
|
||||
'gurudwara',
|
||||
'monument',
|
||||
'memorial',
|
||||
'smark',
|
||||
'smarak',
|
||||
'beach',
|
||||
'waterfall',
|
||||
'landmark',
|
||||
'point of interest',
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -338,10 +448,29 @@ function inferRequestedPlaceCategory(question) {
|
|||
regex: /\b(temples?|mandirs?)\b/i,
|
||||
include: (place) => hasAnyPlaceTerm(place, ['temple', 'mandir', 'hindu temple']),
|
||||
},
|
||||
{
|
||||
label: 'churches',
|
||||
noMatch: "I couldn't find any churches in the available recommendations for this event.",
|
||||
regex: /\b(churches?|basilicas?|cathedrals?)\b/i,
|
||||
include: (place) => hasAnyPlaceTerm(place, ['church', 'cathedral', 'basilica']),
|
||||
},
|
||||
{
|
||||
label: 'beaches',
|
||||
noMatch: "I couldn't find any beaches in the available recommendations for this event.",
|
||||
regex: /\b(beaches?|seaside|coast)\b/i,
|
||||
include: (place) => hasAnyPlaceTerm(place, ['beach', 'seaside', 'coast']),
|
||||
},
|
||||
{
|
||||
label: 'forts',
|
||||
noMatch: "I couldn't find any forts in the available recommendations for this event.",
|
||||
regex: /\b(forts?|castles?)\b/i,
|
||||
include: (place) => hasAnyPlaceTerm(place, ['fort', 'castle']),
|
||||
},
|
||||
{
|
||||
label: 'tourist attractions',
|
||||
noMatch: "I couldn't find any tourist attractions in the available recommendations for this event.",
|
||||
regex: /\b(tourists?|attractions?|sightseeing|places to visit|things to do)\b/i,
|
||||
// Broad "what to visit / places can I visit / sightseeing" — keep last so specific categories win first.
|
||||
regex: /\b(tourists?|attractions?|sightseeing|landmarks?|things to do|places to visit|what places|can i visit|to visit|to explore|to see)\b/i,
|
||||
include: isTouristAttractionLike,
|
||||
},
|
||||
];
|
||||
|
|
@ -354,19 +483,36 @@ function filterPlacesForQuestion(places, question) {
|
|||
const source = Array.isArray(places) ? places : [];
|
||||
if (!requestedCategory) return { requestedCategory: null, places: source };
|
||||
|
||||
const filtered = source.filter((place) => requestedCategory.include(place));
|
||||
// Broad visit questions: if tags don't match tourist-like filters, still show top places.
|
||||
if (!filtered.length && requestedCategory.label === 'tourist attractions') {
|
||||
return { requestedCategory: null, places: source };
|
||||
}
|
||||
|
||||
return {
|
||||
requestedCategory,
|
||||
places: source.filter((place) => requestedCategory.include(place)),
|
||||
places: filtered,
|
||||
};
|
||||
}
|
||||
|
||||
function rankPlaces(places, limit = 5) {
|
||||
function rankPlaces(places, limit = 5, options = {}) {
|
||||
const preferNearby = Boolean(options?.preferNearby);
|
||||
return (Array.isArray(places) ? places : [])
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const ratingDiff = Number(b?.rating || 0) - Number(a?.rating || 0);
|
||||
if (ratingDiff) return ratingDiff;
|
||||
return Number(b?.user_ratings_total || 0) - Number(a?.user_ratings_total || 0);
|
||||
if (preferNearby) {
|
||||
const distA = Number(a?.distance_meters);
|
||||
const distB = Number(b?.distance_meters);
|
||||
const hasA = Number.isFinite(distA);
|
||||
const hasB = Number.isFinite(distB);
|
||||
if (hasA && hasB && distA !== distB) return distA - distB;
|
||||
if (hasA && !hasB) return -1;
|
||||
if (!hasA && hasB) return 1;
|
||||
}
|
||||
// Fame / prominence first (review volume), then star rating.
|
||||
const reviewDiff = Number(b?.user_ratings_total || 0) - Number(a?.user_ratings_total || 0);
|
||||
if (reviewDiff) return reviewDiff;
|
||||
return Number(b?.rating || 0) - Number(a?.rating || 0);
|
||||
})
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
|
@ -385,6 +531,7 @@ function rankPlaces(places, limit = 5) {
|
|||
async function runConciergePipeline({
|
||||
eventId,
|
||||
eventName,
|
||||
eventLocation,
|
||||
bot,
|
||||
sessionHistory,
|
||||
intentResult: suppliedIntentResult,
|
||||
|
|
@ -396,9 +543,9 @@ async function runConciergePipeline({
|
|||
// IMPORTANT: Always use the latest user message in the current request.
|
||||
const userQuestion = historyForIntent.slice(-1)?.[0]?.text || '';
|
||||
|
||||
const intentResult = suppliedIntentResult || await determineIntentCategory(
|
||||
historyForIntent.map((m) => ({ role: m.role, text: m.text })),
|
||||
);
|
||||
// Callers (food/tourist-place handlers) always supply an intentResult. The
|
||||
// legacy self-classification path was removed in the two-stage cutover.
|
||||
const intentResult = suppliedIntentResult || { category: 'unrecognized', databaseSearchNeeded: false };
|
||||
|
||||
const intentCategory = intentResult?.category || 'unrecognized';
|
||||
|
||||
|
|
@ -418,7 +565,7 @@ async function runConciergePipeline({
|
|||
const isLegacyFoodRecord = hasStructuredRestaurants && Number(foodRecord?.schemaVersion || 0) < 4;
|
||||
|
||||
if (!foodRecord || isExpired || !hasStructuredRestaurants || isLegacyFoodRecord) {
|
||||
const location = bot?.eventLocation || bot?.location || '';
|
||||
const location = resolveEventLocationLabel({ eventLocation, bot, foodRecord });
|
||||
await getFoodRecommendations(location, eventId);
|
||||
foodRecord = await fetchFoodRecordForEvent(eventId);
|
||||
}
|
||||
|
|
@ -439,7 +586,8 @@ async function runConciergePipeline({
|
|||
};
|
||||
}
|
||||
|
||||
const { mode: foodQueryMode, specificTerms } = inferFoodQueryMode(userQuestion);
|
||||
const locationLabel = resolveEventLocationLabel({ eventLocation, bot, foodRecord }) || 'the event venue';
|
||||
const { mode: foodQueryMode, specificTerms } = inferFoodQueryMode(userQuestion, locationLabel);
|
||||
const evidenceMatches = foodQueryMode === 'specific'
|
||||
? filterFoodRecordsByTerms(restaurants, specificTerms)
|
||||
: [];
|
||||
|
|
@ -461,71 +609,124 @@ async function runConciergePipeline({
|
|||
return `${index + 1}) Food ID: ${foodId}\nName: ${record.name || 'Unnamed restaurant'}\nCuisines: ${cuisines}\nRating: ${record.rating ?? 'N/A'} (${record.user_ratings_total ?? 0} reviews)\nAddress: ${record.address || record.formatted_address || 'N/A'}\nDirect query evidence: ${directEvidence}\nReview evidence: ${reviews || 'N/A'}`;
|
||||
}).join('\n\n');
|
||||
|
||||
const responseModelId = 'meta.llama3-8b-instruct-v1:0';
|
||||
const { createBedrockClient, getBedrockConfig } = require('../utils/bedrockClient');
|
||||
const { converse, responseText } = require('../utils/bedrockConverse');
|
||||
const { region } = getBedrockConfig();
|
||||
const client = createBedrockClient(region);
|
||||
const { completeText, DEFAULT_RECOMMENDATION_MODEL_ID } = require('../utils/bedrockComplete');
|
||||
|
||||
const foodSystemPrompt = `You are the EventBuzz food recommendation selector.
|
||||
const foodSystemPrompt = `You are the EventBuzz food assistant for the event "${eventName || 'Event'}" held in / near: ${locationLabel}.
|
||||
|
||||
Select restaurants that best answer the current user question using ONLY the available food records.
|
||||
Decide how to answer the CURRENT USER QUESTION using the available food records and your general cuisine knowledge for THIS EVENT LOCATION ONLY.
|
||||
|
||||
Return ONLY one minified JSON object (no markdown, no prose outside JSON):
|
||||
{"mode":"places"|"text"|"both","foodIds":["id1"],"reply":"..."}
|
||||
|
||||
Modes:
|
||||
- "places": user wants venues / where to eat / lunch-dinner nearby / a dish that maps to the list. Put 1-5 Food IDs from the available records into foodIds. reply = short intro for the cards.
|
||||
- "text": cuisine culture, regional food overview, diet explanations, comparisons, or when no good restaurant match exists. foodIds must be []. reply = helpful prose. Do NOT invent restaurant names, addresses, or ratings.
|
||||
- "both": short cuisine/diet guidance PLUS 1-3 supporting Food IDs from the list when useful (e.g. veg overview + a few spots).
|
||||
|
||||
STRICT RULES:
|
||||
1. Never invent a restaurant, dish, food ID, address, rating, or fact.
|
||||
2. Select only Food IDs that appear exactly in the available records.
|
||||
3. The backend supplies a QUERY MODE. Follow the rules for that mode exactly.
|
||||
4. GENERAL mode means the user wants restaurants or a meal such as lunch/dinner without naming a dish, cuisine, or dietary restriction. In GENERAL mode, select 3 to 5 of the best available restaurants. Exact dish evidence is not required. Rank using rating and review count.
|
||||
5. SPECIFIC mode means the user named a dish, cuisine, or dietary restriction. In SPECIFIC mode, select only records with clear evidence for that request in the restaurant name, cuisines, review evidence, or supplemental context. Never assume every restaurant serves the requested item.
|
||||
6. In SPECIFIC mode, return only the strong matches (normally 1 to 3). Do not add weak or unrelated records merely to reach a count.
|
||||
7. If SPECIFIC mode has no evidence-backed match, return an empty array.
|
||||
8. Return ONLY a valid JSON array of Food ID strings, such as ["id_1","id_2"].
|
||||
9. Do not return markdown, names, explanations, or any text outside the JSON array.
|
||||
1. foodIds may ONLY contain Food IDs that appear exactly in AVAILABLE FOOD RECORDS. Never invent IDs.
|
||||
2. Cap foodIds at 5.
|
||||
3. Prefer "text" for questions about regional cuisines, what food a city is known for, or general diet culture without asking for places.
|
||||
4. Prefer "places" for "restaurants near me/venue", "where can I eat", meal requests without a cuisine essay.
|
||||
5. Prefer "both" when the user asks about veg/diet AND nearby places would help.
|
||||
6. QUERY MODE is a hint: GENERAL leans places (3-5 best-rated IDs). SPECIFIC leans evidence-backed IDs or text if none match.
|
||||
7. reply must be concise (under ~400 characters), friendly, and must not mention internal systems.
|
||||
8. LOCATION GROUNDING (critical): Answer for EVENT LOCATION ("${locationLabel}") only. For "regional cuisines", local food, street food, famous dishes, or diet culture with no other city named, describe THIS event city's cuisine. Never substitute another city (e.g. the user's hometown). Only discuss a different place if the user explicitly names that place.
|
||||
9. When writing cuisine overviews, name the event location in the reply so it is clear which city you mean.
|
||||
10. FOLLOW-UPS like "what's good there", "any of those good?", or asking about places already discussed: prefer mode "places" or "both" and reuse Food IDs from AVAILABLE FOOD RECORDS. Do NOT invent dish names unless they appear in the Cuisines or Review evidence fields for those records.
|
||||
11. Never invent menu items, signature dishes, or street-food names that are not supported by the provided cuisines/review evidence.
|
||||
|
||||
Examples:
|
||||
- "Recommend restaurants near the venue" is GENERAL: return 3 to 5 highly rated IDs.
|
||||
- "Where can I have lunch?" is GENERAL: return 3 to 5 highly rated IDs.
|
||||
- "Best biryani near the venue" is SPECIFIC: return only IDs explicitly supported for biryani.
|
||||
- "Where can I get pizza?" is SPECIFIC: return only IDs explicitly supported for pizza, or [] if none are supported.`;
|
||||
- "Recommend restaurants near the venue" -> {"mode":"places","foodIds":["id_1","id_2","id_3"],"reply":"Here are some highly rated spots near the venue."}
|
||||
- "What are the regional cuisines?" -> {"mode":"text","foodIds":[],"reply":"${locationLabel} cuisine highlights local flavours and must-try dishes near the event."}
|
||||
- "Any veg options?" -> {"mode":"both","foodIds":["id_1"],"reply":"Vegetarian meals are widely available near ${locationLabel}. Here are nearby options:"}
|
||||
- "What's good there?" (after listing places) -> {"mode":"both","foodIds":["id_1","id_2"],"reply":"Among the spots above, these are strong picks based on ratings and reviews."}`;
|
||||
|
||||
const userText = `CURRENT USER QUESTION:\n${userQuestion}\n\nQUERY MODE: ${foodQueryMode.toUpperCase()}\nSPECIFIC TERMS: ${specificTerms.length ? specificTerms.join(', ') : 'none'}\n\nAVAILABLE FOOD RECORDS:\n${foodContext}${foodRecord?.recommendations ? `\n\nSUPPLEMENTAL FOOD CONTEXT:\n${String(foodRecord.recommendations).slice(0, 5000)}` : ''}\n\nReturn only the selected Food IDs as a JSON array.`;
|
||||
const userText = `EVENT LOCATION: ${locationLabel}
|
||||
EVENT NAME: ${eventName || 'Event'}
|
||||
|
||||
CURRENT USER QUESTION:
|
||||
${userQuestion}
|
||||
|
||||
QUERY MODE: ${foodQueryMode.toUpperCase()}
|
||||
SPECIFIC TERMS: ${specificTerms.length ? specificTerms.join(', ') : 'none'}
|
||||
|
||||
AVAILABLE FOOD RECORDS (near ${locationLabel}):
|
||||
${foodContext}${foodRecord?.recommendations ? `\n\nSUPPLEMENTAL FOOD CONTEXT:\n${String(foodRecord.recommendations).slice(0, 5000)}` : ''}
|
||||
|
||||
Return JSON only. Ground every cuisine/culture answer in EVENT LOCATION.`;
|
||||
|
||||
try {
|
||||
const response = await converse(client, responseModelId, {
|
||||
system: [{ text: foodSystemPrompt }],
|
||||
messages: [{ role: 'user', content: [{ text: userText }] }],
|
||||
inferenceConfig: { temperature: 0, topP: 0.1, maxTokens: 150 },
|
||||
const rawDecision = await completeText(userText, {
|
||||
system: foodSystemPrompt,
|
||||
maxTokens: 700,
|
||||
temperature: 0.2,
|
||||
});
|
||||
|
||||
const rawIds = responseText(response).trim();
|
||||
const selectedIds = parseFoodIdsFromLlmText(rawIds);
|
||||
const decision = parseFoodLlmDecision(rawDecision);
|
||||
const selectableRecords = foodQueryMode === 'specific' && evidenceMatches.length
|
||||
? evidenceMatches
|
||||
: restaurants;
|
||||
let foods = selectFoodsByIds(selectableRecords, selectedIds, 5);
|
||||
|
||||
let foods = [];
|
||||
let usedDatabaseFallback = false;
|
||||
let usedEvidenceFallback = false;
|
||||
let mode = decision.mode;
|
||||
|
||||
// Obvious lexical matches (for example cafe/cafes or Pizza Hut/pizza)
|
||||
// remain grounded even when the model returns an empty array.
|
||||
if (!foods.length && foodQueryMode === 'specific' && evidenceMatches.length) {
|
||||
foods = rankFoodRecords(evidenceMatches, 3);
|
||||
usedEvidenceFallback = foods.length > 0;
|
||||
if (!decision.parseFailed && (mode === 'places' || mode === 'both')) {
|
||||
foods = selectFoodsByIds(selectableRecords, decision.foodIds, 5);
|
||||
if (!foods.length && foodQueryMode === 'specific' && evidenceMatches.length) {
|
||||
foods = rankFoodRecords(evidenceMatches, 3);
|
||||
usedEvidenceFallback = foods.length > 0;
|
||||
}
|
||||
if (!foods.length && foodQueryMode === 'general') {
|
||||
foods = rankFoodRecords(restaurants, 5);
|
||||
usedDatabaseFallback = foods.length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
// A broad request does not require semantic dish matching. If the model
|
||||
// returns an empty/malformed array, return grounded, DB-ranked cards.
|
||||
if (!foods.length && foodQueryMode === 'general') {
|
||||
foods = rankFoodRecords(restaurants, 5);
|
||||
usedDatabaseFallback = foods.length > 0;
|
||||
// Parse failure: general → DB rank; specific with evidence → evidence rank; else leave empty for text/clarification.
|
||||
if (decision.parseFailed) {
|
||||
mode = null;
|
||||
if (foodQueryMode === 'general') {
|
||||
foods = rankFoodRecords(restaurants, 5);
|
||||
usedDatabaseFallback = foods.length > 0;
|
||||
} else if (evidenceMatches.length) {
|
||||
foods = rankFoodRecords(evidenceMatches, 3);
|
||||
usedEvidenceFallback = foods.length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'text') {
|
||||
foods = [];
|
||||
}
|
||||
|
||||
let reply = String(decision.reply || '').trim();
|
||||
if (!reply) {
|
||||
if (foods.length) {
|
||||
reply = foodQueryMode === 'general'
|
||||
? 'Here are some of the best food places near the venue.'
|
||||
: 'Here are the best matching food places I found for you.';
|
||||
} else if (mode === 'text' || decision.parseFailed) {
|
||||
reply = decision.parseFailed
|
||||
? 'Tell me if you want cuisine tips or restaurant suggestions nearby (or name a dish/diet like vegetarian or pizza).'
|
||||
: "I don't have a detailed answer for that yet. Ask about restaurants nearby or a specific dish.";
|
||||
} else {
|
||||
reply = "I couldn't find a matching food place in the available recommendations for this event.";
|
||||
}
|
||||
}
|
||||
|
||||
// places/both with no cards but a model reply → keep the prose (avoid dead-end empty cards).
|
||||
if (!foods.length && reply && (mode === 'places' || mode === 'both')) {
|
||||
mode = 'text';
|
||||
}
|
||||
|
||||
console.log('[pipeline][food][llm]', {
|
||||
queryMode: foodQueryMode,
|
||||
specificTerms,
|
||||
evidenceMatches: evidenceMatches.length,
|
||||
rawOutput: rawIds.slice(0, 300),
|
||||
selectedIds: selectedIds.length,
|
||||
mode: mode || 'parse_failed',
|
||||
rawOutput: String(rawDecision || '').slice(0, 300),
|
||||
selectedIds: decision.foodIds.length,
|
||||
validatedFoods: foods.length,
|
||||
usedDatabaseFallback,
|
||||
usedEvidenceFallback,
|
||||
|
|
@ -533,14 +734,10 @@ Examples:
|
|||
|
||||
return {
|
||||
intent: intentCategory,
|
||||
text: foods.length
|
||||
? foodQueryMode === 'general'
|
||||
? 'Here are some of the best food places near the venue.'
|
||||
: 'Here are the best matching food places I found for you.'
|
||||
: "I couldn't find a matching food place in the available recommendations for this event.",
|
||||
text: reply,
|
||||
foods,
|
||||
usage: null,
|
||||
modelId: responseModelId,
|
||||
modelId: DEFAULT_RECOMMENDATION_MODEL_ID,
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn('[pipeline][food][llm] failed:', e?.message || e);
|
||||
|
|
@ -622,66 +819,81 @@ Examples:
|
|||
}
|
||||
|
||||
|
||||
const touristPlacesRegex =
|
||||
/\b(tourists?|attractions?|sightseeing|landmarks?|monuments?|museums?|galleries?|temples?|parks?|viewpoints?|malls?|shopping|gardens?|forts?|lakes?|zoos?|churches?|mosques?|gurudwaras?|palaces?|beaches?|waterfalls?|places to visit|things to do)\b/i;
|
||||
// Call 1 already routed tourist_places → Places. Always run the places pipeline
|
||||
// (do not re-gate on keyword regex — phrases like "what places can I visit" were missed).
|
||||
const { PLACES_SCHEMA_VERSION, PLACES_SEARCH_MODE } = require('./placesRecommendationService');
|
||||
const existing = await fetchPlacesRecordForEvent(eventId);
|
||||
const preferNearby = wantsNearbyPlaces(userQuestion);
|
||||
const destinationLabel =
|
||||
resolvePlacesDestinationLabel({ eventLocation, bot, placesRecord: existing }) || 'this destination';
|
||||
|
||||
const shouldUseTouristPlaces = touristPlacesRegex.test(questionLower);
|
||||
if (shouldUseTouristPlaces) {
|
||||
const existing = await fetchPlacesRecordForEvent(eventId);
|
||||
const generatedAtMs = existing?.generatedAt ? new Date(existing.generatedAt).getTime() : 0;
|
||||
const isExpired = generatedAtMs > 0 && Date.now() - generatedAtMs > 24 * 60 * 60 * 1000;
|
||||
const structuredPlaces = Array.isArray(existing?.places) ? existing.places : [];
|
||||
const hasStructuredPlaces = structuredPlaces.length > 0;
|
||||
const isLegacyPlacesRecord =
|
||||
!existing?.destination
|
||||
|| existing?.searchMode !== PLACES_SEARCH_MODE
|
||||
|| Number(existing?.schemaVersion || 0) < PLACES_SCHEMA_VERSION;
|
||||
|
||||
const isExpired =
|
||||
existing?.generatedAt &&
|
||||
Date.now() - new Date(existing.generatedAt).getTime() > 24 * 60 * 60 * 1000;
|
||||
const placesIntro = (label) => (
|
||||
preferNearby
|
||||
? `Here are some places worth visiting near the venue in ${label}.`
|
||||
: `Here are some places worth visiting in ${label}.`
|
||||
);
|
||||
|
||||
// If not expired and we have DB data, use it.
|
||||
if (existing && !isExpired) {
|
||||
const responseModelId = 'meta.llama3-8b-instruct-v1:0';
|
||||
// Cached destination-mode places: select via LLM (with deterministic fallback).
|
||||
if (existing && !isExpired && hasStructuredPlaces && !isLegacyPlacesRecord) {
|
||||
const responseModelId = 'meta.llama3-8b-instruct-v1:0';
|
||||
|
||||
const { createBedrockClient, getBedrockConfig } = require('../utils/bedrockClient');
|
||||
const { converse, responseText } = require('../utils/bedrockConverse');
|
||||
const { createBedrockClient, getBedrockConfig } = require('../utils/bedrockClient');
|
||||
const { converse, responseText } = require('../utils/bedrockConverse');
|
||||
|
||||
const { region } = getBedrockConfig();
|
||||
const client = createBedrockClient(region);
|
||||
const { region } = getBedrockConfig();
|
||||
const client = createBedrockClient(region);
|
||||
|
||||
const recommendationsText = existing?.recommendations || '';
|
||||
const structuredPlaces = Array.isArray(existing?.places) ? existing.places : [];
|
||||
const { requestedCategory, places: candidatePlaces } = filterPlacesForQuestion(structuredPlaces, userQuestion);
|
||||
const recommendationsText = existing?.recommendations || '';
|
||||
const { requestedCategory, places: candidatePlaces } = filterPlacesForQuestion(structuredPlaces, userQuestion);
|
||||
|
||||
if (requestedCategory && candidatePlaces.length === 0) {
|
||||
return {
|
||||
intent: intentCategory,
|
||||
text: requestedCategory.noMatch,
|
||||
places: [],
|
||||
usage: null,
|
||||
modelId: null,
|
||||
};
|
||||
}
|
||||
if (requestedCategory && candidatePlaces.length === 0) {
|
||||
return {
|
||||
intent: intentCategory,
|
||||
text: requestedCategory.noMatch,
|
||||
places: [],
|
||||
usage: null,
|
||||
modelId: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Structured, question-aware grounding from `existing.places[]` including reviewsSummary.
|
||||
const placesContextBlock = candidatePlaces.length
|
||||
? candidatePlaces
|
||||
.slice(0, 40)
|
||||
.map((p, i) => {
|
||||
const name = p?.name || 'Unnamed place';
|
||||
const placeId = p?.place_id || p?.placeId || p?.id || '';
|
||||
const tags = Array.isArray(p?.tags) ? p.tags.join(', ') : (p?.tags || '');
|
||||
const rating = p?.rating != null ? String(p.rating) : 'N/A';
|
||||
const address = p?.address || 'N/A';
|
||||
const reviewsSummary = p?.reviewsSummary || p?.reviewSummary || 'N/A';
|
||||
const placesContextBlock = candidatePlaces.length
|
||||
? candidatePlaces
|
||||
.slice(0, 40)
|
||||
.map((p, i) => {
|
||||
const name = p?.name || 'Unnamed place';
|
||||
const placeId = p?.place_id || p?.placeId || p?.id || '';
|
||||
const tags = Array.isArray(p?.tags) ? p.tags.join(', ') : (p?.tags || '');
|
||||
const rating = p?.rating != null ? String(p.rating) : 'N/A';
|
||||
const address = p?.address || 'N/A';
|
||||
const reviewsSummary = p?.reviewsSummary || p?.reviewSummary || 'N/A';
|
||||
const distance = p?.distance_meters != null ? `${Math.round(Number(p.distance_meters))} m from venue` : 'N/A';
|
||||
|
||||
return `\n${i + 1}) ${name}\n Place ID: ${placeId || 'N/A'}\n Tags: ${tags || 'N/A'}\n Rating: ${rating}\n Address: ${address}\n Reviews summary: ${reviewsSummary}`;
|
||||
})
|
||||
.join('')
|
||||
: '';
|
||||
return `\n${i + 1}) ${name}\n Place ID: ${placeId || 'N/A'}\n Tags: ${tags || 'N/A'}\n Rating: ${rating}\n Address: ${address}\n Distance from venue: ${distance}\n Reviews summary: ${reviewsSummary}`;
|
||||
})
|
||||
.join('')
|
||||
: '';
|
||||
|
||||
const dataBlockTourist = placesContextBlock
|
||||
? `Tourist places (structured with reviews summary):\n${placesContextBlock}${recommendationsText ? `\n\nSupplemental recommendations text:\n${recommendationsText}` : ''}`
|
||||
: `Tourist places recommendations (ground truth / fetched):\n${recommendationsText}`;
|
||||
const dataBlockTourist = placesContextBlock
|
||||
? `Tourist places in ${destinationLabel} (structured with reviews summary):\n${placesContextBlock}${recommendationsText ? `\n\nSupplemental recommendations text:\n${recommendationsText}` : ''}`
|
||||
: `Tourist places recommendations in ${destinationLabel}:\n${recommendationsText}`;
|
||||
|
||||
const placesSystemPrompt = `
|
||||
You are an EventBuzz Tourist Places Concierge.
|
||||
const rankingHint = preferNearby
|
||||
? 'The user asked for nearby / close-to-venue options — prefer closer places when choosing IDs.'
|
||||
: 'Prefer famous / highly reviewed places across the destination; do not prefer proximity unless asked.';
|
||||
|
||||
Your job is to answer the user's question ONLY using the tourist places provided in the context.
|
||||
const placesSystemPrompt = `
|
||||
You are an EventBuzz Tourist Places Concierge for an event in ${destinationLabel}.
|
||||
|
||||
Your job is to answer the user's question ONLY using the tourist places provided in the context for this destination.
|
||||
|
||||
========================
|
||||
STRICT RULES
|
||||
|
|
@ -694,7 +906,6 @@ STRICT RULES
|
|||
3. If the user asks for a specific category such as:
|
||||
- temple
|
||||
- museum
|
||||
- shopping mall
|
||||
- park
|
||||
- garden
|
||||
- viewpoint
|
||||
|
|
@ -719,15 +930,12 @@ User: Nearby temples?
|
|||
"I couldn't find any temples in the available recommendations."
|
||||
|
||||
5. If multiple matching places exist:
|
||||
- Rank them using rating and review quality.
|
||||
- Rank them using review volume and rating quality (fame), unless the user asked for nearby.
|
||||
- Mention 2-5 places.
|
||||
- Mention why they are recommended using the review summary.
|
||||
- Do not invent additional information.
|
||||
|
||||
6. If the user asks:
|
||||
"What tourist attractions are nearby?"
|
||||
|
||||
then recommend the highest-rated tourist attractions from the context regardless of category.
|
||||
6. If the user asks what places to visit / tourist attractions / things to do (including staying an extra day):
|
||||
recommend the most notable tourist attractions from the context regardless of category.
|
||||
|
||||
7. If no relevant place exists in the provided context, reply:
|
||||
|
||||
|
|
@ -739,120 +947,141 @@ then recommend the highest-rated tourist attractions from the context regardless
|
|||
9. If no relevant place exists, return an empty JSON array: []
|
||||
|
||||
10. Never include markdown, explanations, names, addresses, or any text outside the JSON array.
|
||||
|
||||
${rankingHint}
|
||||
`;
|
||||
|
||||
const userText = `
|
||||
USER QUESTION:
|
||||
${userQuestion}
|
||||
const userText = `
|
||||
DESTINATION: ${destinationLabel}
|
||||
NEARBY REQUESTED: ${preferNearby ? 'yes' : 'no'}
|
||||
|
||||
AVAILABLE TOURIST PLACES:
|
||||
USER QUESTION:
|
||||
${userQuestion}
|
||||
|
||||
${dataBlockTourist}
|
||||
AVAILABLE TOURIST PLACES:
|
||||
|
||||
Answer ONLY with a JSON array of place IDs from the places above.
|
||||
`;
|
||||
${dataBlockTourist}
|
||||
|
||||
console.log('[pipeline][places][llm] modelId=', responseModelId, {
|
||||
hasStructuredPlaces: candidatePlaces.length > 0,
|
||||
structuredCount: candidatePlaces.length,
|
||||
requestedCategory: requestedCategory?.label || null,
|
||||
Answer ONLY with a JSON array of place IDs from the places above.
|
||||
`;
|
||||
|
||||
console.log('[pipeline][places][llm] modelId=', responseModelId, {
|
||||
hasStructuredPlaces: candidatePlaces.length > 0,
|
||||
structuredCount: candidatePlaces.length,
|
||||
requestedCategory: requestedCategory?.label || null,
|
||||
destinationLabel,
|
||||
preferNearby,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await converse(client, responseModelId, {
|
||||
system: [{ text: placesSystemPrompt }],
|
||||
messages: [{ role: 'user', content: [{ text: userText }] }],
|
||||
inferenceConfig: { temperature: 0.4, topP: 0.9, maxTokens: 500 },
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await converse(client, responseModelId, {
|
||||
system: [{ text: placesSystemPrompt }],
|
||||
messages: [{ role: 'user', content: [{ text: userText }] }],
|
||||
inferenceConfig: { temperature: 0.4, topP: 0.9, maxTokens: 500 },
|
||||
});
|
||||
|
||||
const text = responseText(response).trim();
|
||||
const selectedPlaceIds = parsePlaceIdsFromLlmText(text);
|
||||
const selectedPlaces = selectPlacesByIds(candidatePlaces, selectedPlaceIds, 5);
|
||||
console.log('[pipeline][places][llm] success:', text.slice(0, 120), {
|
||||
selectedPlaceIds: selectedPlaceIds.length,
|
||||
selectedPlaces: selectedPlaces.length,
|
||||
});
|
||||
|
||||
if (selectedPlaces.length) {
|
||||
return {
|
||||
intent: intentCategory,
|
||||
text: 'Here are the best matching places I found for you.',
|
||||
places: selectedPlaces,
|
||||
usage: null,
|
||||
modelId: responseModelId,
|
||||
};
|
||||
}
|
||||
|
||||
const deterministicPlaces = rankPlaces(candidatePlaces, 5)
|
||||
.map(publicPlaceFromRecord)
|
||||
.filter(Boolean);
|
||||
|
||||
if (deterministicPlaces.length) {
|
||||
return {
|
||||
intent: intentCategory,
|
||||
text: 'Here are the best matching places I found for you.',
|
||||
places: deterministicPlaces,
|
||||
usage: null,
|
||||
modelId: null,
|
||||
};
|
||||
}
|
||||
const text = responseText(response).trim();
|
||||
const selectedPlaceIds = parsePlaceIdsFromLlmText(text);
|
||||
const selectedPlaces = selectPlacesByIds(candidatePlaces, selectedPlaceIds, 5);
|
||||
console.log('[pipeline][places][llm] success:', text.slice(0, 120), {
|
||||
selectedPlaceIds: selectedPlaceIds.length,
|
||||
selectedPlaces: selectedPlaces.length,
|
||||
});
|
||||
|
||||
if (selectedPlaces.length) {
|
||||
return {
|
||||
intent: intentCategory,
|
||||
text: requestedCategory?.noMatch || "I couldn't find any matching places in the available recommendations for this event.",
|
||||
places: [],
|
||||
text: placesIntro(destinationLabel),
|
||||
places: selectedPlaces,
|
||||
usage: null,
|
||||
modelId: responseModelId,
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn('[pipeline][places][llm] failed; falling back:', e?.message || e);
|
||||
}
|
||||
|
||||
const fallback = recommendationsText
|
||||
? recommendationsText
|
||||
: 'Tourist place suggestions are currently unavailable for this event.';
|
||||
const deterministicPlaces = rankPlaces(candidatePlaces, 5, { preferNearby })
|
||||
.map(publicPlaceFromRecord)
|
||||
.filter(Boolean);
|
||||
|
||||
if (deterministicPlaces.length) {
|
||||
return {
|
||||
intent: intentCategory,
|
||||
text: String(fallback).trim(),
|
||||
places: [],
|
||||
text: placesIntro(destinationLabel),
|
||||
places: deterministicPlaces,
|
||||
usage: null,
|
||||
modelId: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
intent: intentCategory,
|
||||
text: requestedCategory?.noMatch || `I couldn't find matching places in ${destinationLabel} right now.`,
|
||||
places: [],
|
||||
usage: null,
|
||||
modelId: responseModelId,
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn('[pipeline][places][llm] failed; falling back:', e?.message || e);
|
||||
|
||||
const deterministicPlaces = rankPlaces(candidatePlaces, 5, { preferNearby })
|
||||
.map(publicPlaceFromRecord)
|
||||
.filter(Boolean);
|
||||
|
||||
if (deterministicPlaces.length) {
|
||||
return {
|
||||
intent: intentCategory,
|
||||
text: placesIntro(destinationLabel),
|
||||
places: deterministicPlaces,
|
||||
usage: null,
|
||||
modelId: null,
|
||||
};
|
||||
}
|
||||
|
||||
const fallback = recommendationsText
|
||||
? recommendationsText
|
||||
: `Tourist place suggestions are currently unavailable for ${destinationLabel}.`;
|
||||
|
||||
return {
|
||||
intent: intentCategory,
|
||||
text: String(fallback).trim(),
|
||||
places: [],
|
||||
usage: null,
|
||||
modelId: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Expired or missing DB: fetch fresh recommendations and return.
|
||||
const location = bot?.eventLocation || bot?.location || '';
|
||||
const placesText = location ? await getPlacesRecommendations(location, eventId) : await getPlacesRecommendations('', eventId);
|
||||
const refreshed = await fetchPlacesRecordForEvent(eventId);
|
||||
const { requestedCategory, places: refreshedCandidates } = filterPlacesForQuestion(
|
||||
Array.isArray(refreshed?.places) ? refreshed.places : [],
|
||||
userQuestion,
|
||||
);
|
||||
const refreshedPlaces = refreshedCandidates.length
|
||||
? rankPlaces(refreshedCandidates, 5)
|
||||
.map(publicPlaceFromRecord)
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
return {
|
||||
intent: intentCategory,
|
||||
text: refreshedPlaces.length
|
||||
? 'Here are the best matching places I found for you.'
|
||||
: requestedCategory?.noMatch || placesText,
|
||||
places: refreshedPlaces,
|
||||
usage: null,
|
||||
modelId: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if intent was correctly classified as Places, don't overwrite with the generic greeting.
|
||||
if (intentCategory === 'Places') {
|
||||
// Missing / expired / legacy / empty structured places: fetch fresh city-wide recommendations.
|
||||
// Use venue/event location for geocoding; destination city is derived inside the ingest service.
|
||||
const venueForRefresh = resolveEventLocationLabel({ eventLocation, bot, placesRecord: existing });
|
||||
let placesText = '';
|
||||
try {
|
||||
placesText = venueForRefresh
|
||||
? await getPlacesRecommendations(venueForRefresh, eventId)
|
||||
: await getPlacesRecommendations('', eventId);
|
||||
} catch (e) {
|
||||
console.warn('[pipeline][places] refresh failed:', e?.message || e);
|
||||
}
|
||||
const refreshed = await fetchPlacesRecordForEvent(eventId);
|
||||
const refreshedDestination =
|
||||
resolvePlacesDestinationLabel({ eventLocation, bot, placesRecord: refreshed }) || destinationLabel;
|
||||
const { requestedCategory, places: refreshedCandidates } = filterPlacesForQuestion(
|
||||
Array.isArray(refreshed?.places) ? refreshed.places : [],
|
||||
userQuestion,
|
||||
);
|
||||
const refreshedPlaces = refreshedCandidates.length
|
||||
? rankPlaces(refreshedCandidates, 5, { preferNearby })
|
||||
.map(publicPlaceFromRecord)
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
return {
|
||||
intent: intentCategory,
|
||||
text: 'No places available nearby right now.',
|
||||
places: [],
|
||||
text: refreshedPlaces.length
|
||||
? placesIntro(refreshedDestination)
|
||||
: requestedCategory?.noMatch
|
||||
|| (typeof placesText === 'string' && placesText.trim() ? placesText.trim() : null)
|
||||
|| `I couldn't find places to visit in ${refreshedDestination} right now.`,
|
||||
places: refreshedPlaces,
|
||||
usage: null,
|
||||
modelId: null,
|
||||
};
|
||||
|
|
@ -868,5 +1097,15 @@ then recommend the highest-rated tourist attractions from the context regardless
|
|||
|
||||
module.exports = {
|
||||
runConciergePipeline,
|
||||
inferFoodQueryMode,
|
||||
resolveEventLocationLabel,
|
||||
resolvePlacesDestinationLabel,
|
||||
wantsNearbyPlaces,
|
||||
inferRequestedPlaceCategory,
|
||||
filterPlacesForQuestion,
|
||||
rankPlaces,
|
||||
parseFoodLlmDecision,
|
||||
parseFoodIdsFromLlmText,
|
||||
selectFoodsByIds,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ const {
|
|||
createBedrockClient,
|
||||
getBedrockConfig,
|
||||
buildModelIdCandidates,
|
||||
isInvalidModelError,
|
||||
isRetryableModelError,
|
||||
} = require('../utils/bedrockClient');
|
||||
const { converse, responseText } = require('../utils/bedrockConverse');
|
||||
const { buildConciergeSystemPrompt } = require('../../../chatbot/src/prompts/concierge');
|
||||
|
|
@ -51,15 +51,19 @@ function extractUsage(response) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Stage A of RAG: embed the query and return the top-K most similar active
|
||||
* context chunks for the event.
|
||||
*
|
||||
* @param {string} eventId
|
||||
* @param {string} userQuery
|
||||
* @param {string} eventName
|
||||
* @param {{ includeEventDetails?: boolean, includeFoodRecommendations?: boolean, history?: { role: 'user'|'assistant', text: string }[] }} [options]
|
||||
* @param {{ includeEventDetails?: boolean, includeFoodRecommendations?: boolean, topK?: number, promptIntent?: string }} [options]
|
||||
* @returns {Promise<{ chunks: { text: string, score: number }[], embedModelId: string } | null>}
|
||||
* null when the query could not be embedded; { chunks: [] } when nothing matched.
|
||||
*/
|
||||
async function executeConciergeRag(eventId, userQuery, eventName, options = {}) {
|
||||
async function retrieveEventChunks(eventId, userQuery, options = {}) {
|
||||
const includeEventDetails = options.includeEventDetails !== false;
|
||||
const includeFoodRecommendations = options.includeFoodRecommendations !== false;
|
||||
const history = Array.isArray(options.history) ? options.history : [];
|
||||
const topK = Number(options.topK) > 0 ? Number(options.topK) : 4;
|
||||
|
||||
const { embeddings, modelId: embedModelId } = await embedTexts([userQuery]);
|
||||
const queryEmbedding = embeddings[0];
|
||||
|
|
@ -154,35 +158,65 @@ async function executeConciergeRag(eventId, userQuery, eventName, options = {})
|
|||
excludedSourceIds: chunkFilter?.sourceId?.$nin,
|
||||
chunkFilterKeys: Object.keys(chunkFilter),
|
||||
});
|
||||
return null;
|
||||
return { chunks: [], embedModelId };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
const ranked = activeChunks
|
||||
.map((chunk) => ({ text: chunk.rawText, score: cosineSimilarity(queryEmbedding, chunk.embedding) }))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 4);
|
||||
.slice(0, topK);
|
||||
|
||||
return { chunks: ranked, embedModelId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage B of RAG: generate a grounded answer from retrieved chunks.
|
||||
*
|
||||
* @param {{ eventId: string, userQuery: string, eventName: string,
|
||||
* chunks: { text: string, score: number }[],
|
||||
* history?: { role: 'user'|'assistant', text: string }[],
|
||||
* promptIntent?: string }} params
|
||||
* @returns {Promise<{ text: string, modelId: string, usage: object } | null>}
|
||||
*/
|
||||
async function answerFromEventChunks({
|
||||
eventId,
|
||||
userQuery,
|
||||
eventName,
|
||||
chunks,
|
||||
history = [],
|
||||
promptIntent = 'general',
|
||||
continueAffirmative = false,
|
||||
searchQuery = null,
|
||||
}) {
|
||||
const contextBlock = (Array.isArray(chunks) ? chunks : []).map((c) => c.text).join('\n\n');
|
||||
if (!contextBlock.trim()) return null;
|
||||
|
||||
const contextBlock = ranked.map((c) => c.text).join('\n\n');
|
||||
const { region, modelId: configuredId } = getBedrockConfig();
|
||||
const client = createBedrockClient(region);
|
||||
// Pass intent/category so the concierge system prompt can be specialized.
|
||||
const system = buildConciergeSystemPrompt(eventName, options.promptIntent || 'general');
|
||||
|
||||
const system = buildConciergeSystemPrompt(eventName, promptIntent || 'general');
|
||||
|
||||
const eventFacts = await buildEventFactsBlock(eventId);
|
||||
|
||||
const historyBlock = history
|
||||
const historyBlock = (Array.isArray(history) ? history : [])
|
||||
.slice(-6)
|
||||
.map((m) => `${m.role === 'assistant' ? 'Assistant' : 'User'}: ${String(m.text || '').slice(0, 800)}`)
|
||||
.join('\n');
|
||||
|
||||
const questionParts = [];
|
||||
if (continueAffirmative) {
|
||||
questionParts.push(
|
||||
'User confirmed the previous offer. Expand with the next useful details. Do not repeat the previous overview.',
|
||||
);
|
||||
if (searchQuery) questionParts.push(`Focus on: ${searchQuery}`);
|
||||
}
|
||||
questionParts.push(`Question: ${userQuery}`);
|
||||
|
||||
const userText = [
|
||||
eventFacts,
|
||||
historyBlock ? `Conversation so far:\n${historyBlock}\n\n` : '',
|
||||
`Context:\n${contextBlock}\n\nQuestion: ${userQuery}`,
|
||||
`Context:\n${contextBlock}\n\n${questionParts.join('\n')}`,
|
||||
].join('');
|
||||
|
||||
for (const modelId of buildModelIdCandidates(configuredId)) {
|
||||
|
|
@ -190,16 +224,42 @@ async function executeConciergeRag(eventId, userQuery, eventName, options = {})
|
|||
const response = await converse(client, modelId, {
|
||||
system: [{ text: system }],
|
||||
messages: [{ role: 'user', content: [{ text: userText }] }],
|
||||
logLabel: 'call2',
|
||||
});
|
||||
return { text: responseText(response), modelId, usage: extractUsage(response) };
|
||||
} catch (err) {
|
||||
if (!isInvalidModelError(err)) throw err;
|
||||
console.warn('[concierge][call2] model failed:', modelId, err?.name || err?.Code || 'Error', err?.message || err);
|
||||
if (!isRetryableModelError(err)) throw err;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full single-stage RAG (retrieve + answer). Retained for the legacy pipeline.
|
||||
*
|
||||
* @param {string} eventId
|
||||
* @param {string} userQuery
|
||||
* @param {string} eventName
|
||||
* @param {{ includeEventDetails?: boolean, includeFoodRecommendations?: boolean, history?: { role: 'user'|'assistant', text: string }[], promptIntent?: string }} [options]
|
||||
*/
|
||||
async function executeConciergeRag(eventId, userQuery, eventName, options = {}) {
|
||||
const retrieval = await retrieveEventChunks(eventId, userQuery, options);
|
||||
if (!retrieval || !retrieval.chunks.length) return null;
|
||||
|
||||
return answerFromEventChunks({
|
||||
eventId,
|
||||
userQuery,
|
||||
eventName,
|
||||
chunks: retrieval.chunks,
|
||||
history: Array.isArray(options.history) ? options.history : [],
|
||||
promptIntent: options.promptIntent,
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
retrieveEventChunks,
|
||||
answerFromEventChunks,
|
||||
executeConciergeRag,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* Concierge response orchestration (two-stage).
|
||||
*
|
||||
* The controller stays thin: it handles HTTP/session bookkeeping and delegates
|
||||
* "given this session, produce a reply" to runTwoStage() here.
|
||||
*
|
||||
* Flow: Call 1 (router) -> intent handler registry -> optional Call 2.
|
||||
* Every routable intent has a registered handler. If Call 1 fails to route
|
||||
* (e.g. the classifier is unavailable), we return a safe generic fallback
|
||||
* without a second LLM call — there is no legacy pipeline bridge anymore.
|
||||
*/
|
||||
|
||||
const { routeConciergeMessage } = require('./conciergeRouter.service');
|
||||
const { getIntentHandler } = require('./conciergeIntentHandlers');
|
||||
const { recordHandler, recordCall2 } = require('../utils/conciergePipelineLog');
|
||||
|
||||
const FALLBACK_RESULT = {
|
||||
reply: '',
|
||||
cards: [],
|
||||
places: [],
|
||||
foods: [],
|
||||
usage: null,
|
||||
modelId: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Two-stage orchestration: Call 1 (router) -> handler dispatch -> optional
|
||||
* Call 2. Returns a normalized result the controller persists and returns.
|
||||
*
|
||||
* @returns {{ reply: string, cards: object[], places: object[], foods: object[],
|
||||
* usage: object|null, modelId: string|null, intent: string, source: string }}
|
||||
*/
|
||||
async function runTwoStage({ bot, event, session, turnLog }) {
|
||||
const historyForRoute = session.history.slice(-12).map((m) => ({ role: m.role, text: m.text }));
|
||||
|
||||
const plan = await routeConciergeMessage({ history: historyForRoute, event, bot, session, turnLog });
|
||||
|
||||
const handler = getIntentHandler(plan?.intent);
|
||||
if (handler) {
|
||||
const startedAt = Date.now();
|
||||
const result = (await handler({ plan, bot, event, session, turnLog })) || {};
|
||||
const cards = Array.isArray(result.cards) ? result.cards : [];
|
||||
recordHandler(turnLog, {
|
||||
name: plan.intent,
|
||||
skippedCall2: !result.usedCall2,
|
||||
cardCount: cards.length,
|
||||
cardTypes: [...new Set(cards.map((c) => String(c?.type || 'unknown')))],
|
||||
latencyMs: Date.now() - startedAt,
|
||||
});
|
||||
if (!result.usedCall2) {
|
||||
recordCall2(turnLog, { skipped: true, reason: 'handler_direct_response' });
|
||||
}
|
||||
return {
|
||||
reply: String(result.reply || '').trim(),
|
||||
cards,
|
||||
places: Array.isArray(result.places) ? result.places : [],
|
||||
foods: Array.isArray(result.foods) ? result.foods : [],
|
||||
usage: result.usage || null,
|
||||
modelId: result.modelId || null,
|
||||
intent: plan.intent,
|
||||
source: 'handler',
|
||||
};
|
||||
}
|
||||
|
||||
// No handler resolved (router could not classify — e.g. Call 1 unavailable).
|
||||
// Degrade gracefully with a generic reply and no further LLM call.
|
||||
recordHandler(turnLog, { name: 'unrouted_fallback', skippedCall2: true, cardCount: 0, cardTypes: [] });
|
||||
recordCall2(turnLog, { skipped: true, reason: 'unrouted_fallback' });
|
||||
return { ...FALLBACK_RESULT, intent: plan?.intent || 'unrecognized', source: 'fallback' };
|
||||
}
|
||||
|
||||
// Call 2 (grounded RAG answer) lives in its own module to avoid a require
|
||||
// cycle with the intent handler registry. Re-exported here for convenience.
|
||||
const { generateConciergeResponse } = require('./conciergeCall2.service');
|
||||
|
||||
module.exports = {
|
||||
runTwoStage,
|
||||
generateConciergeResponse,
|
||||
};
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
jest.mock('./conciergePipeline.service', () => ({
|
||||
runConciergePipeline: jest.fn(),
|
||||
}));
|
||||
jest.mock('./personalInformation.service', () => ({
|
||||
runPersonalInformation: jest.fn(),
|
||||
}));
|
||||
jest.mock('./conciergeRag.service', () => ({
|
||||
retrieveEventChunks: jest.fn(),
|
||||
answerFromEventChunks: jest.fn(),
|
||||
executeConciergeRag: jest.fn(),
|
||||
}));
|
||||
jest.mock('../utils/bedrockClient', () => ({
|
||||
createBedrockClient: jest.fn(() => ({})),
|
||||
getBedrockConfig: jest.fn(() => ({ region: 'test-region', modelId: 'test-model', routerModelId: 'amazon.nova-micro-v1:0' })),
|
||||
getRouterModelCandidates: jest.fn(() => ['amazon.nova-micro-v1:0', 'test-model']),
|
||||
isRetryableModelError: jest.fn(() => true),
|
||||
}));
|
||||
jest.mock('../utils/bedrockConverse', () => ({
|
||||
converse: jest.fn(),
|
||||
responseText: jest.fn(),
|
||||
}));
|
||||
|
||||
const { runConciergePipeline } = require('./conciergePipeline.service');
|
||||
const { runPersonalInformation } = require('./personalInformation.service');
|
||||
const { retrieveEventChunks, answerFromEventChunks } = require('./conciergeRag.service');
|
||||
const { converse, responseText } = require('../utils/bedrockConverse');
|
||||
const { runTwoStage } = require('./conciergeResponse.service');
|
||||
const { createTurnLog } = require('../utils/conciergePipelineLog');
|
||||
|
||||
function makeSession(text, overrides = {}) {
|
||||
return {
|
||||
_id: 'sess',
|
||||
sessionId: 'sess-1',
|
||||
awaitingPersonalEmail: false,
|
||||
history: [{ role: 'user', text }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const bot = { _id: 'bot-1', event: 'evt-1', organization: 'org-1', knowledge: {} };
|
||||
const event = { name: 'Test Event', locationType: 'physical' };
|
||||
|
||||
describe('conciergeResponse.runTwoStage', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('an unrouted turn (router error) degrades to a generic fallback (no legacy pipeline)', async () => {
|
||||
// Router Call 1 fails -> intent is null -> no handler -> safe fallback.
|
||||
converse.mockRejectedValue(new Error('bedrock down'));
|
||||
|
||||
const session = makeSession('where is hall A?');
|
||||
const turnLog = createTurnLog({ sessionId: 'sess-1', botId: 'bot-1', userMessage: 'where is hall A?' });
|
||||
|
||||
const result = await runTwoStage({ bot, event, session, turnLog });
|
||||
|
||||
expect(runConciergePipeline).not.toHaveBeenCalled();
|
||||
expect(result.source).toBe('fallback');
|
||||
expect(result.reply).toBe('');
|
||||
expect(result.cards).toEqual([]);
|
||||
expect(result.intent).toBe('unrecognized');
|
||||
expect(turnLog.call1.path).toBe('llm_error');
|
||||
expect(turnLog.handler.name).toBe('unrouted_fallback');
|
||||
expect(turnLog.call2.reason).toBe('unrouted_fallback');
|
||||
});
|
||||
|
||||
test('an event-knowledge intent runs Call 2 (RAG) via its handler', async () => {
|
||||
converse.mockResolvedValue({ usage: {} });
|
||||
responseText.mockReturnValue('{"intent":"event_schedule","searchQuery":"today agenda"}');
|
||||
retrieveEventChunks.mockResolvedValue({
|
||||
chunks: [{ text: 'Keynote at 10am in Hall A', score: 0.9 }],
|
||||
embedModelId: 'embed-1',
|
||||
});
|
||||
answerFromEventChunks.mockResolvedValue({
|
||||
text: 'The keynote is at 10am in Hall A.',
|
||||
modelId: 'answer-model',
|
||||
usage: { inputTokens: 200, outputTokens: 45 },
|
||||
});
|
||||
|
||||
const session = makeSession('when and where is the keynote?');
|
||||
const turnLog = createTurnLog({ sessionId: 'sess-1', botId: 'bot-1', userMessage: 'when and where is the keynote?' });
|
||||
|
||||
const result = await runTwoStage({ bot, event, session, turnLog });
|
||||
|
||||
expect(runConciergePipeline).not.toHaveBeenCalled();
|
||||
expect(retrieveEventChunks).toHaveBeenCalledTimes(1);
|
||||
expect(retrieveEventChunks.mock.calls[0][1]).toBe('today agenda'); // uses searchQuery
|
||||
expect(answerFromEventChunks).toHaveBeenCalledTimes(1);
|
||||
expect(result.source).toBe('handler');
|
||||
expect(result.intent).toBe('event_schedule');
|
||||
expect(result.reply).toBe('The keynote is at 10am in Hall A.');
|
||||
expect(result.usage).toEqual({ inputTokens: 200, outputTokens: 45 });
|
||||
expect(turnLog.handler.name).toBe('event_schedule');
|
||||
expect(turnLog.handler.skippedCall2).toBe(false);
|
||||
expect(turnLog.call2.skipped).toBe(false);
|
||||
expect(turnLog.call2.retrievalSources).toBe(1);
|
||||
expect(turnLog.call2.outputTokens).toBe(45);
|
||||
});
|
||||
|
||||
test('an event-knowledge intent with no retrieved context returns the fallback', async () => {
|
||||
converse.mockResolvedValue({ usage: {} });
|
||||
responseText.mockReturnValue('{"intent":"event_general_info"}');
|
||||
retrieveEventChunks.mockResolvedValue({ chunks: [], embedModelId: 'embed-1' });
|
||||
|
||||
const session = makeSession('what is the wifi password?');
|
||||
const turnLog = createTurnLog({ sessionId: 'sess-1', botId: 'bot-1', userMessage: 'what is the wifi password?' });
|
||||
|
||||
const result = await runTwoStage({ bot, event, session, turnLog });
|
||||
|
||||
expect(answerFromEventChunks).not.toHaveBeenCalled();
|
||||
expect(result.reply).toMatch(/don't have that in my knowledge base/i);
|
||||
expect(turnLog.call2.skipped).toBe(true);
|
||||
expect(turnLog.call2.reason).toBe('no_context');
|
||||
});
|
||||
|
||||
test('a greeting is answered by its handler without the legacy pipeline', async () => {
|
||||
const session = makeSession('hello');
|
||||
const turnLog = createTurnLog({ sessionId: 'sess-1', botId: 'bot-1', userMessage: 'hello' });
|
||||
|
||||
const result = await runTwoStage({ bot, event, session, turnLog });
|
||||
|
||||
expect(converse).not.toHaveBeenCalled();
|
||||
expect(runConciergePipeline).not.toHaveBeenCalled();
|
||||
expect(result.source).toBe('handler');
|
||||
expect(result.intent).toBe('greeting');
|
||||
expect(result.reply).toContain('Test Event');
|
||||
expect(turnLog.call1.path).toBe('greeting_fast_path');
|
||||
expect(turnLog.handler.name).toBe('greeting');
|
||||
expect(turnLog.handler.skippedCall2).toBe(true);
|
||||
expect(turnLog.call2.reason).toBe('handler_direct_response');
|
||||
});
|
||||
|
||||
test('awaiting-email override routes to the personal handler (no LLM, no legacy)', async () => {
|
||||
runPersonalInformation.mockResolvedValue({ text: 'Please share the email you registered with.' });
|
||||
|
||||
const session = makeSession('my ticket', { awaitingPersonalEmail: true });
|
||||
const turnLog = createTurnLog({ sessionId: 'sess-1', botId: 'bot-1', userMessage: 'my ticket' });
|
||||
|
||||
const result = await runTwoStage({ bot, event, session, turnLog });
|
||||
|
||||
expect(converse).not.toHaveBeenCalled();
|
||||
expect(runConciergePipeline).not.toHaveBeenCalled();
|
||||
expect(runPersonalInformation).toHaveBeenCalledTimes(1);
|
||||
expect(result.source).toBe('handler');
|
||||
expect(result.intent).toBe('personal_profile');
|
||||
expect(result.reply).toBe('Please share the email you registered with.');
|
||||
expect(turnLog.call1.path).toBe('personal_override');
|
||||
expect(turnLog.handler.name).toBe('personal_profile');
|
||||
});
|
||||
|
||||
test('an LLM-classified personal intent is answered by the personal handler', async () => {
|
||||
converse.mockResolvedValue({ usage: {} });
|
||||
responseText.mockReturnValue('{"intent":"personal_ticket"}');
|
||||
runPersonalInformation.mockResolvedValue({ text: 'Your registration is confirmed.' });
|
||||
|
||||
const session = makeSession('what is my ticket status?');
|
||||
const turnLog = createTurnLog({ sessionId: 'sess-1', botId: 'bot-1', userMessage: 'what is my ticket status?' });
|
||||
|
||||
const result = await runTwoStage({ bot, event, session, turnLog });
|
||||
|
||||
expect(runConciergePipeline).not.toHaveBeenCalled();
|
||||
expect(runPersonalInformation).toHaveBeenCalledTimes(1);
|
||||
expect(result.intent).toBe('personal_ticket');
|
||||
expect(result.reply).toBe('Your registration is confirmed.');
|
||||
expect(turnLog.handler.name).toBe('personal_ticket');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,597 @@
|
|||
/**
|
||||
* Concierge Router (Call 1).
|
||||
*
|
||||
* Classifies the current turn into one intent from the taxonomy, gated by the
|
||||
* event's location type. Direct-response intents (greeting, clarification,
|
||||
* out_of_scope, personal_*, virtual_troubleshooting) are handled without a
|
||||
* Call 2; retrieval intents are marked needsSecondCall=true.
|
||||
*
|
||||
* The greeting fast-path and the awaiting-email override skip the LLM entirely.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Full intent taxonomy for the two-stage concierge.
|
||||
* Values are stable string keys used across the router, handler registry,
|
||||
* and pipeline logs.
|
||||
*/
|
||||
const INTENTS = {
|
||||
// Conversational / control
|
||||
GREETING: 'greeting',
|
||||
CLARIFICATION: 'clarification',
|
||||
OUT_OF_SCOPE: 'out_of_scope',
|
||||
|
||||
// Event knowledge (RAG)
|
||||
EVENT_SCHEDULE: 'event_schedule',
|
||||
SESSION_DETAIL: 'session_detail',
|
||||
SPEAKER_INFO: 'speaker_info',
|
||||
TICKET_PRICING: 'ticket_pricing',
|
||||
REGISTRATION: 'registration',
|
||||
EVENT_GENERAL_INFO: 'event_general_info',
|
||||
|
||||
// Personal (participant DB, identity-gated)
|
||||
PERSONAL_REGISTRATION: 'personal_registration',
|
||||
PERSONAL_SCHEDULE: 'personal_schedule',
|
||||
PERSONAL_TICKET: 'personal_ticket',
|
||||
PERSONAL_PROFILE: 'personal_profile',
|
||||
|
||||
// Logistics — physical events (event KB or external APIs)
|
||||
VENUE_NAVIGATION: 'venue_navigation',
|
||||
CHECK_IN_ON_SITE: 'check_in_on_site',
|
||||
FOOD_RECOMMENDATION: 'food_recommendation',
|
||||
TOURIST_PLACES: 'tourist_places',
|
||||
ACCOMMODATION: 'accommodation',
|
||||
TRAVEL_TRANSPORT: 'travel_transport',
|
||||
|
||||
// Virtual / hybrid
|
||||
VIRTUAL_JOIN: 'virtual_join',
|
||||
VIRTUAL_SESSION_JOIN: 'virtual_session_join',
|
||||
VIRTUAL_TROUBLESHOOTING: 'virtual_troubleshooting',
|
||||
|
||||
// Fallback help
|
||||
GENERAL_HELP: 'general_help',
|
||||
};
|
||||
|
||||
const ALL_INTENTS = Object.values(INTENTS);
|
||||
|
||||
/**
|
||||
* Short descriptions used to build the Call 1 classification prompt.
|
||||
*/
|
||||
const INTENT_DESCRIPTIONS = {
|
||||
[INTENTS.GREETING]: 'greetings, small talk, thanks, or goodbyes with no actual question',
|
||||
[INTENTS.CLARIFICATION]: 'vague or incomplete messages where the goal is unclear and you must ask what they need',
|
||||
[INTENTS.OUT_OF_SCOPE]: 'truly unrelated to this event only: coding help, other products/apps, jokes with no event link, trivia about unrelated topics. NOT registration, approval, organizer contact, travel, or frustration about the bot',
|
||||
[INTENTS.EVENT_SCHEDULE]: "the event agenda, program, timetable, or what is on today/now/next",
|
||||
[INTENTS.SESSION_DETAIL]: 'details about a specific session, talk, or workshop (topic, room, timing)',
|
||||
[INTENTS.SPEAKER_INFO]: 'speakers, presenters, panelists, their bios or which session they are in',
|
||||
[INTENTS.TICKET_PRICING]: 'ticket types, prices, passes, or what is included with a ticket for this event',
|
||||
[INTENTS.REGISTRATION]: 'how to register or sign up for this event, the registration link, deadline, or whether registration is open',
|
||||
[INTENTS.EVENT_GENERAL_INFO]: 'general event facts: dates, venue name, theme, organizer, organizer/support contact (email/phone/human), wifi, policies',
|
||||
[INTENTS.PERSONAL_REGISTRATION]: 'the user asking about THEIR OWN registration status, pending approval, when they will be approved, why approval is delayed, or which events they registered for',
|
||||
[INTENTS.PERSONAL_SCHEDULE]: 'the user asking about THEIR OWN saved/bookmarked sessions or personal agenda',
|
||||
[INTENTS.PERSONAL_TICKET]: 'the user asking about THEIR OWN ticket, badge, QR code, or check-in status',
|
||||
[INTENTS.PERSONAL_PROFILE]: 'the user asking about THEIR OWN profile details, registered email/phone, or payment status',
|
||||
[INTENTS.VENUE_NAVIGATION]: 'directions inside the venue: halls, rooms, booths, parking, restrooms, entrances, stage',
|
||||
[INTENTS.CHECK_IN_ON_SITE]: 'how/where to check in or collect a badge on-site at the venue',
|
||||
[INTENTS.FOOD_RECOMMENDATION]: 'food, restaurants, cafes, meals, cuisines, or where to eat near the venue',
|
||||
[INTENTS.TOURIST_PLACES]: 'tourist attractions, sightseeing, malls, parks, temples, or things to do near the venue',
|
||||
[INTENTS.ACCOMMODATION]: 'hotels, stay, lodging, or where to sleep near the venue',
|
||||
[INTENTS.TRAVEL_TRANSPORT]: 'how to travel to the venue/city: flights, trains, cabs, Uber/Ola/ride apps, local transport, airport transfers, or correcting prior travel advice',
|
||||
[INTENTS.VIRTUAL_JOIN]: 'how to join or access the virtual/online event as a whole, the main stream link, or platform login',
|
||||
[INTENTS.VIRTUAL_SESSION_JOIN]: 'the link to join a specific talk/session online, or which sessions are live now and joinable',
|
||||
[INTENTS.VIRTUAL_TROUBLESHOOTING]: 'audio/video/connection/login problems while attending the virtual event',
|
||||
[INTENTS.GENERAL_HELP]: 'the user asks what the assistant can do, wants general help, or expresses frustration with the assistant while still in an event conversation',
|
||||
};
|
||||
|
||||
/**
|
||||
* Intents that produce a direct, structured response without a Call 2 RAG step.
|
||||
* These map to handlers in the intent handler registry.
|
||||
*/
|
||||
const DIRECT_INTENTS = new Set([
|
||||
INTENTS.GREETING,
|
||||
INTENTS.CLARIFICATION,
|
||||
INTENTS.OUT_OF_SCOPE,
|
||||
INTENTS.PERSONAL_REGISTRATION,
|
||||
INTENTS.PERSONAL_SCHEDULE,
|
||||
INTENTS.PERSONAL_TICKET,
|
||||
INTENTS.PERSONAL_PROFILE,
|
||||
INTENTS.VIRTUAL_TROUBLESHOOTING,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Intents that only make sense for events with a physical presence.
|
||||
* Excluded when the event is purely virtual.
|
||||
*/
|
||||
const PHYSICAL_ONLY_INTENTS = new Set([
|
||||
INTENTS.VENUE_NAVIGATION,
|
||||
INTENTS.CHECK_IN_ON_SITE,
|
||||
INTENTS.TOURIST_PLACES,
|
||||
INTENTS.ACCOMMODATION,
|
||||
INTENTS.TRAVEL_TRANSPORT,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Intents that only make sense for events with a virtual/online component.
|
||||
* Excluded when the event is purely physical.
|
||||
*/
|
||||
const VIRTUAL_ONLY_INTENTS = new Set([
|
||||
INTENTS.VIRTUAL_JOIN,
|
||||
INTENTS.VIRTUAL_SESSION_JOIN,
|
||||
INTENTS.VIRTUAL_TROUBLESHOOTING,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Normalize an event's location type into 'physical' | 'virtual' | 'hybrid'.
|
||||
* Defensive: the Event doc may not carry an explicit type yet (Phase 1 will
|
||||
* widen the controller's projection). Unknown → 'physical'.
|
||||
*/
|
||||
function resolveLocationType(event) {
|
||||
const raw = String(
|
||||
event?.locationType ||
|
||||
event?.location?.type ||
|
||||
event?.mode ||
|
||||
event?.eventType ||
|
||||
'',
|
||||
).toLowerCase();
|
||||
|
||||
if (raw.includes('hybrid')) return 'hybrid';
|
||||
if (raw.includes('virtual') || raw.includes('online')) return 'virtual';
|
||||
if (raw.includes('physical') || raw.includes('in-person') || raw.includes('onsite') || raw.includes('on-site')) {
|
||||
return 'physical';
|
||||
}
|
||||
return 'physical';
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the list of intents the router is allowed to classify into for a
|
||||
* given event, based on its location type.
|
||||
*/
|
||||
function allowedIntentsForEvent(event) {
|
||||
const locationType = resolveLocationType(event);
|
||||
return ALL_INTENTS.filter((intent) => {
|
||||
if (locationType === 'virtual' && PHYSICAL_ONLY_INTENTS.has(intent)) return false;
|
||||
if (locationType === 'physical' && VIRTUAL_ONLY_INTENTS.has(intent)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const GREETING_RE = /^\s*(hi|hii+|hey|hello|helo|yo|hola|namaste|good\s+(morning|afternoon|evening)|greetings)\b[\s!.]*$/i;
|
||||
|
||||
/** Whole-message / short-phrase affirmatives used to continue a prior offer. */
|
||||
const AFFIRMATIVE_RE =
|
||||
/^\s*(yes|yeah|yep|yup|ya|sure|ok|okay|please|go\s+ahead|sounds\s+good|that\s+works|absolutely|definitely|of\s+course|yes\s+please|ok\s+please|sure\s+please)[\s!.]*$/i;
|
||||
|
||||
function isGreeting(message) {
|
||||
return GREETING_RE.test(String(message || '').trim());
|
||||
}
|
||||
|
||||
function isAffirmative(message) {
|
||||
return AFFIRMATIVE_RE.test(String(message || '').trim());
|
||||
}
|
||||
|
||||
function latestUserMessage(history) {
|
||||
if (!Array.isArray(history)) return '';
|
||||
for (let i = history.length - 1; i >= 0; i -= 1) {
|
||||
if (history[i]?.role === 'user') return String(history[i]?.text || '').trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function lastAssistantMessage(history) {
|
||||
if (!Array.isArray(history)) return '';
|
||||
for (let i = history.length - 1; i >= 0; i -= 1) {
|
||||
if (history[i]?.role === 'assistant') return String(history[i]?.text || '').trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Most recent non-affirmative user message (skips the current "yes"). */
|
||||
function priorUserTopic(history) {
|
||||
if (!Array.isArray(history)) return '';
|
||||
let skippedLatestAffirmative = false;
|
||||
for (let i = history.length - 1; i >= 0; i -= 1) {
|
||||
if (history[i]?.role !== 'user') continue;
|
||||
const text = String(history[i]?.text || '').trim();
|
||||
if (!text) continue;
|
||||
if (!skippedLatestAffirmative && isAffirmative(text)) {
|
||||
skippedLatestAffirmative = true;
|
||||
continue;
|
||||
}
|
||||
if (isAffirmative(text)) continue;
|
||||
return text.slice(0, 160);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull a short expansion hint from the last assistant offer
|
||||
* (e.g. "Would you like more information on flights or airport transfer?").
|
||||
*/
|
||||
function extractOfferHint(assistantText) {
|
||||
const text = String(assistantText || '').trim();
|
||||
if (!text) return '';
|
||||
const offerMatch = text.match(
|
||||
/(?:would you like(?:\s+me\s+to)?|want me to|shall i|can i)\s+(?:provide\s+)?(?:more\s+)?(?:information|details|info)?\s*(?:on|about|regarding)?\s*(.+?)(?:\??\s*$)/i,
|
||||
);
|
||||
if (offerMatch?.[1]) {
|
||||
return String(offerMatch[1])
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/[.?!]+$/, '')
|
||||
.trim()
|
||||
.slice(0, 120);
|
||||
}
|
||||
const lastSentence = text.split(/(?<=[.!?])\s+/).filter(Boolean).pop() || '';
|
||||
if (/\?/.test(lastSentence) && /more|detail|flight|option|transfer|info/i.test(lastSentence)) {
|
||||
return lastSentence.replace(/\?+/g, '').trim().slice(0, 120);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function buildContinuationSearchQuery(history) {
|
||||
const topic = priorUserTopic(history);
|
||||
const hint = extractOfferHint(lastAssistantMessage(history)) || 'more details';
|
||||
if (topic && hint) return `${topic} — ${hint}`.slice(0, 220);
|
||||
if (topic) return `${topic} — more details`.slice(0, 220);
|
||||
return hint.slice(0, 220) || 'more details';
|
||||
}
|
||||
|
||||
/** Whether an intent should skip Call 2 (direct handler response). */
|
||||
function isDirectIntent(intent) {
|
||||
return DIRECT_INTENTS.has(intent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Call 1 system prompt from the allowed intents for this event.
|
||||
* Tuned for compact classifiers (Nova Micro): short rules, explicit
|
||||
* disambiguation, and a hard JSON-only contract.
|
||||
*/
|
||||
function buildRouterPrompt(allowedIntents) {
|
||||
const lines = allowedIntents
|
||||
.map((intent) => `- ${intent}: ${INTENT_DESCRIPTIONS[intent] || ''}`)
|
||||
.join('\n');
|
||||
|
||||
return `You classify one user message for an event concierge chatbot.
|
||||
|
||||
Task: choose exactly ONE intent key from INTENTS for the CURRENT USER MESSAGE.
|
||||
Use RECENT CONVERSATION only to resolve follow-ups (e.g. "what about speakers?" after an agenda question).
|
||||
Short confirmations like "yes please send flight options" continue the previous topic — pick that same information intent and set searchQuery to the confirmed detail.
|
||||
|
||||
INTENTS:
|
||||
${lines}
|
||||
|
||||
Disambiguation (important):
|
||||
- personal_* = the user's OWN registration/ticket/profile/saved agenda only. "What sessions are today?" is event_schedule, not personal_schedule.
|
||||
- personal_registration ALSO covers: when will my registration be approved, why the delay, pending approval process, urgency about booking travel because registration is pending.
|
||||
- ticket_pricing = ticket types/prices for the event. registration = how to sign up / registration link.
|
||||
- event_schedule = overall agenda/timetable. session_detail = one specific talk/workshop.
|
||||
- event_general_info = organizer/support/human contact details, event dates/venue/theme/policies.
|
||||
- virtual_join = event-wide online access link. virtual_session_join = join a specific live/upcoming session. virtual_troubleshooting = AV/login problems.
|
||||
- venue_navigation = inside the venue (rooms/halls). travel_transport = getting to the city/venue (flights/cabs/Uber/Ola) OR correcting prior travel advice.
|
||||
- food_recommendation = restaurants/meals. tourist_places = sightseeing/attractions nearby.
|
||||
- greeting = hi/thanks/bye with no question. clarification = too vague.
|
||||
- general_help = what can you do, OR frustration with the assistant ("you're rude", "I'm tired of you") while still in an event chat — acknowledge and steer; NOT out_of_scope.
|
||||
- out_of_scope = ONLY truly unrelated (coding, other products, unrelated trivia). NEVER use for registration approval, organizer contact, travel corrections, or complaints about the bot.
|
||||
|
||||
Rules:
|
||||
1. Output the intent key exactly as listed (snake_case). Never invent a new key.
|
||||
2. Prefer the most specific matching intent.
|
||||
3. Include "searchQuery" only for information intents: a short retrieval query (3-12 words). Omit it for greeting, clarification, out_of_scope, and personal_*.
|
||||
4. Do not answer the user. Do not explain. Do not use markdown.
|
||||
|
||||
Output format (mandatory):
|
||||
Return ONLY one minified JSON object:
|
||||
{"intent":"<intent_key>","searchQuery":"<optional>"}
|
||||
or when searchQuery is not needed:
|
||||
{"intent":"<intent_key>"}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tolerant parser for the Call 1 router LLM output. Extracts a plan object
|
||||
* from raw text that may be wrapped in prose or ```json fences.
|
||||
* needsSecondCall is derived deterministically from the intent (direct intents
|
||||
* never trigger Call 2). Returns null when nothing parseable is found.
|
||||
*/
|
||||
function parseRouterPlan(raw, allowedIntents = ALL_INTENTS) {
|
||||
const trimmed = String(raw || '').trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const candidates = [trimmed];
|
||||
const fenced =
|
||||
trimmed.match(/```json\s*([\s\S]*?)\s*```/i) || trimmed.match(/```\s*([\s\S]*?)\s*```/i);
|
||||
if (fenced?.[1]) candidates.push(fenced[1].trim());
|
||||
const objectMatch = trimmed.match(/\{[\s\S]*\}/);
|
||||
if (objectMatch?.[0]) candidates.push(objectMatch[0]);
|
||||
|
||||
const allowed = new Set(allowedIntents);
|
||||
for (const candidate of candidates) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(candidate);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') continue;
|
||||
|
||||
const intent = allowed.has(parsed.intent) ? parsed.intent : INTENTS.CLARIFICATION;
|
||||
return {
|
||||
intent,
|
||||
needsSecondCall: !isDirectIntent(intent),
|
||||
searchQuery: typeof parsed.searchQuery === 'string' ? parsed.searchQuery : null,
|
||||
directReply: typeof parsed.directReply === 'string' ? parsed.directReply : null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildPlan(intent, path, extra = {}) {
|
||||
return {
|
||||
intent,
|
||||
needsSecondCall: !isDirectIntent(intent),
|
||||
directReply: null,
|
||||
searchQuery: null,
|
||||
path,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* High-confidence keyword hints used before Call 1 LLM for known failure modes
|
||||
* (pending approval, human contact, travel corrections, bot frustration).
|
||||
* Returns null when no strong match — fall through to the classifier.
|
||||
*/
|
||||
function hintIntentFromMessage(message, allowedIntents) {
|
||||
const q = String(message || '').toLowerCase().trim();
|
||||
if (!q) return null;
|
||||
const allowed = allowedIntents instanceof Set ? allowedIntents : new Set(allowedIntents || []);
|
||||
|
||||
const pick = (intent) => (allowed.has(intent) ? intent : null);
|
||||
|
||||
if (
|
||||
/\b(when|why|how long|eta)\b/.test(q)
|
||||
&& /\b(approv\w*|pending|registration|registered|delay(?:ed)?|waiting)\b/.test(q)
|
||||
) {
|
||||
return pick(INTENTS.PERSONAL_REGISTRATION);
|
||||
}
|
||||
if (/\b(pending approval|still pending|not (yet )?approved|awaiting approval)\b/.test(q)) {
|
||||
return pick(INTENTS.PERSONAL_REGISTRATION);
|
||||
}
|
||||
if (
|
||||
/\b(book|booking)\b/.test(q)
|
||||
&& /\b(travel|flight|ticket)s?\b/.test(q)
|
||||
&& /\b(approv\w*|pending|registration|delay(?:ed)?)\b/.test(q)
|
||||
) {
|
||||
return pick(INTENTS.PERSONAL_REGISTRATION);
|
||||
}
|
||||
if (
|
||||
/\b(contact|reach|speak|talk|call|email)\b/.test(q)
|
||||
&& /\b(human|person|organi[sz]er|support|staff|team|someone)\b/.test(q)
|
||||
) {
|
||||
return pick(INTENTS.EVENT_GENERAL_INFO);
|
||||
}
|
||||
if (/\b(support (email|phone|number)|organi[sz]er (email|phone|contact)|helpline)\b/.test(q)) {
|
||||
return pick(INTENTS.EVENT_GENERAL_INFO);
|
||||
}
|
||||
if (
|
||||
/\b(uber|ola|rapido|cab|taxi|ride[\s-]?app)\b/.test(q)
|
||||
&& /\b(not available|unavailable|doesn't work|does not work|can't|cannot|no )\b/.test(q)
|
||||
) {
|
||||
return pick(INTENTS.TRAVEL_TRANSPORT);
|
||||
}
|
||||
if (/\b(uber|ola)\b/.test(q) && /\b(book|available|work in|in goa)\b/.test(q)) {
|
||||
return pick(INTENTS.TRAVEL_TRANSPORT);
|
||||
}
|
||||
if (
|
||||
/\b(rude|useless|terrible|awful|frustrated|frustrating)\b/.test(q)
|
||||
|| /\b(i'?m tired of you|tired of this|you don'?t understand)\b/.test(q)
|
||||
) {
|
||||
return pick(INTENTS.GENERAL_HELP);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify the current turn (Call 1).
|
||||
*
|
||||
* Order of resolution:
|
||||
* 1. awaiting-email override (mid personal-verification) -> personal intent, no LLM
|
||||
* 2. greeting fast-path -> greeting, no LLM
|
||||
* 3. affirmative continue -> inherit session.lastIntent + searchQuery from history
|
||||
* 4. high-confidence keyword hints (approval / contact / travel / frustration)
|
||||
* 5. Call 1 LLM classification over the event's allowed intents
|
||||
*
|
||||
* The turnLog call1 stage is always recorded so the pipeline path is auditable.
|
||||
*
|
||||
* @returns {Promise<{ intent: string, needsSecondCall: boolean, directReply: string|null, searchQuery: string|null, path: string }>}
|
||||
*/
|
||||
async function routeConciergeMessage({ history, event, bot, session, turnLog } = {}) {
|
||||
const { recordCall1 } = require('../utils/conciergePipelineLog');
|
||||
const message = latestUserMessage(history);
|
||||
|
||||
// 1. Mid-verification: the user was asked for their email. Force personal.
|
||||
if (session?.awaitingPersonalEmail) {
|
||||
const plan = buildPlan(INTENTS.PERSONAL_PROFILE, 'personal_override');
|
||||
recordCall1(turnLog, {
|
||||
skipped: true,
|
||||
path: plan.path,
|
||||
intent: plan.intent,
|
||||
needsSecondCall: plan.needsSecondCall,
|
||||
});
|
||||
return plan;
|
||||
}
|
||||
|
||||
// 2. Greeting fast-path.
|
||||
if (isGreeting(message)) {
|
||||
const plan = buildPlan(INTENTS.GREETING, 'greeting_fast_path');
|
||||
recordCall1(turnLog, {
|
||||
skipped: true,
|
||||
path: plan.path,
|
||||
intent: plan.intent,
|
||||
needsSecondCall: plan.needsSecondCall,
|
||||
});
|
||||
return plan;
|
||||
}
|
||||
|
||||
// 3. Affirmative continuation ("yes" after the bot offered more detail).
|
||||
if (isAffirmative(message)) {
|
||||
const allowed = new Set(allowedIntentsForEvent(event));
|
||||
const lastIntent = session?.lastIntent ? String(session.lastIntent).trim() : '';
|
||||
if (lastIntent && allowed.has(lastIntent) && !isDirectIntent(lastIntent)) {
|
||||
const searchQuery = buildContinuationSearchQuery(history);
|
||||
const plan = buildPlan(lastIntent, 'affirmative_continue', { searchQuery });
|
||||
recordCall1(turnLog, {
|
||||
skipped: true,
|
||||
path: plan.path,
|
||||
intent: plan.intent,
|
||||
needsSecondCall: plan.needsSecondCall,
|
||||
});
|
||||
return plan;
|
||||
}
|
||||
const plan = buildPlan(INTENTS.CLARIFICATION, 'affirmative_no_context', {
|
||||
directReply: 'What would you like more detail on? I can help with travel, schedule, sessions, speakers, tickets, food, and places nearby.',
|
||||
});
|
||||
recordCall1(turnLog, {
|
||||
skipped: true,
|
||||
path: plan.path,
|
||||
intent: plan.intent,
|
||||
needsSecondCall: plan.needsSecondCall,
|
||||
});
|
||||
return plan;
|
||||
}
|
||||
|
||||
// 4. High-confidence keyword hints (avoid false out_of_scope for known cases).
|
||||
const allowedIntents = allowedIntentsForEvent(event);
|
||||
const hinted = hintIntentFromMessage(message, allowedIntents);
|
||||
if (hinted) {
|
||||
const plan = buildPlan(hinted, 'keyword_hint', {
|
||||
searchQuery: hinted === INTENTS.EVENT_GENERAL_INFO ? 'organizer support contact email phone' : null,
|
||||
});
|
||||
recordCall1(turnLog, {
|
||||
skipped: true,
|
||||
path: plan.path,
|
||||
intent: plan.intent,
|
||||
needsSecondCall: plan.needsSecondCall,
|
||||
});
|
||||
return plan;
|
||||
}
|
||||
|
||||
// 5. Call 1 LLM classification (Nova Micro by default; falls back to chat model).
|
||||
const {
|
||||
createBedrockClient,
|
||||
getBedrockConfig,
|
||||
getRouterModelCandidates,
|
||||
isRetryableModelError,
|
||||
} = require('../utils/bedrockClient');
|
||||
const { converse, responseText } = require('../utils/bedrockConverse');
|
||||
const { region } = getBedrockConfig();
|
||||
const modelCandidates = getRouterModelCandidates();
|
||||
|
||||
const recentContext = (Array.isArray(history) ? history.slice(-6) : [])
|
||||
.map((m) => `${m?.role === 'assistant' ? 'Assistant' : 'User'}: ${String(m?.text || '').trim()}`)
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
const startedAt = Date.now();
|
||||
let usedModelId = modelCandidates[0] || null;
|
||||
try {
|
||||
const client = createBedrockClient(region);
|
||||
const system = [{ text: buildRouterPrompt(allowedIntents) }];
|
||||
const messages = [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
text: `RECENT CONVERSATION:\n${recentContext || '(none)'}\n\nCURRENT USER MESSAGE:\n${message}\n\nRespond with JSON only.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const inferenceConfig = { temperature: 0, topP: 0.1, maxTokens: 100 };
|
||||
|
||||
let response;
|
||||
let lastError;
|
||||
for (const candidateId of modelCandidates) {
|
||||
try {
|
||||
usedModelId = candidateId;
|
||||
response = await converse(client, candidateId, {
|
||||
system,
|
||||
messages,
|
||||
inferenceConfig,
|
||||
logLabel: 'call1',
|
||||
});
|
||||
if (candidateId !== modelCandidates[0]) {
|
||||
console.warn(
|
||||
`[concierge][router] primary router model "${modelCandidates[0]}" unavailable; used "${candidateId}"`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
if (!isRetryableModelError(err)) throw err;
|
||||
console.warn(
|
||||
`[concierge][router] model "${candidateId}" unavailable (${err.name || err.Code || 'Error'}); trying next`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!response) throw lastError || new Error('Call 1 failed with no response');
|
||||
|
||||
const raw = responseText(response);
|
||||
const parsed = parseRouterPlan(raw, allowedIntents);
|
||||
const plan = parsed
|
||||
? { ...parsed, path: 'llm' }
|
||||
: buildPlan(INTENTS.CLARIFICATION, 'llm_unparsed');
|
||||
|
||||
recordCall1(turnLog, {
|
||||
skipped: false,
|
||||
path: plan.path,
|
||||
modelId: usedModelId,
|
||||
intent: plan.intent,
|
||||
needsSecondCall: plan.needsSecondCall,
|
||||
inputTokens: response?.usage?.inputTokens || 0,
|
||||
outputTokens: response?.usage?.outputTokens || 0,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
raw,
|
||||
});
|
||||
return plan;
|
||||
} catch (err) {
|
||||
console.warn('[concierge][router] Call 1 failed; returning unrouted plan:', err?.message || err);
|
||||
// On failure, return an unrouted plan (intent=null). The orchestrator degrades
|
||||
// to a generic fallback reply — there is no legacy pipeline to defer to.
|
||||
const plan = {
|
||||
intent: null,
|
||||
needsSecondCall: true,
|
||||
directReply: null,
|
||||
searchQuery: null,
|
||||
path: 'llm_error',
|
||||
};
|
||||
recordCall1(turnLog, {
|
||||
skipped: false,
|
||||
path: plan.path,
|
||||
modelId: usedModelId,
|
||||
intent: plan.intent,
|
||||
needsSecondCall: plan.needsSecondCall,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
});
|
||||
return plan;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
INTENTS,
|
||||
ALL_INTENTS,
|
||||
INTENT_DESCRIPTIONS,
|
||||
DIRECT_INTENTS,
|
||||
PHYSICAL_ONLY_INTENTS,
|
||||
VIRTUAL_ONLY_INTENTS,
|
||||
resolveLocationType,
|
||||
allowedIntentsForEvent,
|
||||
isDirectIntent,
|
||||
isGreeting,
|
||||
isAffirmative,
|
||||
hintIntentFromMessage,
|
||||
latestUserMessage,
|
||||
lastAssistantMessage,
|
||||
priorUserTopic,
|
||||
buildContinuationSearchQuery,
|
||||
buildRouterPrompt,
|
||||
parseRouterPlan,
|
||||
routeConciergeMessage,
|
||||
};
|
||||
|
|
@ -0,0 +1,335 @@
|
|||
jest.mock('../utils/bedrockClient', () => ({
|
||||
createBedrockClient: jest.fn(() => ({})),
|
||||
getBedrockConfig: jest.fn(() => ({
|
||||
region: 'test-region',
|
||||
modelId: 'meta.llama3-8b-instruct-v1:0',
|
||||
routerModelId: 'amazon.nova-micro-v1:0',
|
||||
})),
|
||||
getRouterModelCandidates: jest.fn(() => ['amazon.nova-micro-v1:0', 'meta.llama3-8b-instruct-v1:0']),
|
||||
isRetryableModelError: jest.fn(() => true),
|
||||
}));
|
||||
jest.mock('../utils/bedrockConverse', () => ({
|
||||
converse: jest.fn(),
|
||||
responseText: jest.fn(),
|
||||
}));
|
||||
|
||||
const { converse, responseText } = require('../utils/bedrockConverse');
|
||||
const {
|
||||
INTENTS,
|
||||
routeConciergeMessage,
|
||||
parseRouterPlan,
|
||||
allowedIntentsForEvent,
|
||||
isGreeting,
|
||||
} = require('./conciergeRouter.service');
|
||||
const { createTurnLog } = require('../utils/conciergePipelineLog');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('conciergeRouter greeting fast-path', () => {
|
||||
test.each(['hi', 'Hello!', 'hey', 'Good morning', 'namaste'])(
|
||||
'treats %s as a greeting',
|
||||
(message) => {
|
||||
expect(isGreeting(message)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
test.each(['where can I eat', 'hi, what sessions are today?', 'help me find parking'])(
|
||||
'does not treat %s as a bare greeting',
|
||||
(message) => {
|
||||
expect(isGreeting(message)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
test('routes a greeting without an LLM call', async () => {
|
||||
const turnLog = createTurnLog({ sessionId: 's', botId: 'b', userMessage: 'hello' });
|
||||
const plan = await routeConciergeMessage({
|
||||
history: [{ role: 'user', text: 'hello' }],
|
||||
event: {},
|
||||
bot: {},
|
||||
session: {},
|
||||
turnLog,
|
||||
});
|
||||
|
||||
expect(converse).not.toHaveBeenCalled();
|
||||
expect(plan.intent).toBe(INTENTS.GREETING);
|
||||
expect(plan.needsSecondCall).toBe(false);
|
||||
expect(plan.path).toBe('greeting_fast_path');
|
||||
expect(turnLog.call1.skipped).toBe(true);
|
||||
expect(turnLog.call1.path).toBe('greeting_fast_path');
|
||||
});
|
||||
});
|
||||
|
||||
describe('conciergeRouter awaiting-email override', () => {
|
||||
test('forces a personal intent without an LLM call while awaiting email', async () => {
|
||||
const turnLog = createTurnLog({ sessionId: 's', botId: 'b', userMessage: 'jane@example.com' });
|
||||
const plan = await routeConciergeMessage({
|
||||
history: [{ role: 'user', text: 'jane@example.com' }],
|
||||
event: {},
|
||||
bot: {},
|
||||
session: { awaitingPersonalEmail: true },
|
||||
turnLog,
|
||||
});
|
||||
|
||||
expect(converse).not.toHaveBeenCalled();
|
||||
expect(plan.intent).toBe(INTENTS.PERSONAL_PROFILE);
|
||||
expect(plan.needsSecondCall).toBe(false);
|
||||
expect(plan.path).toBe('personal_override');
|
||||
expect(turnLog.call1.path).toBe('personal_override');
|
||||
});
|
||||
|
||||
test('yes while awaiting email still uses the personal override (not affirmative continue)', async () => {
|
||||
const turnLog = createTurnLog({ sessionId: 's', botId: 'b', userMessage: 'yes' });
|
||||
const plan = await routeConciergeMessage({
|
||||
history: [{ role: 'user', text: 'yes' }],
|
||||
event: { locationType: 'physical' },
|
||||
bot: {},
|
||||
session: { awaitingPersonalEmail: true, lastIntent: INTENTS.TRAVEL_TRANSPORT },
|
||||
turnLog,
|
||||
});
|
||||
|
||||
expect(converse).not.toHaveBeenCalled();
|
||||
expect(plan.intent).toBe(INTENTS.PERSONAL_PROFILE);
|
||||
expect(plan.path).toBe('personal_override');
|
||||
});
|
||||
});
|
||||
|
||||
describe('conciergeRouter affirmative continuation', () => {
|
||||
test('inherits lastIntent and builds searchQuery without an LLM call', async () => {
|
||||
const turnLog = createTurnLog({ sessionId: 's', botId: 'b', userMessage: 'yes' });
|
||||
const plan = await routeConciergeMessage({
|
||||
history: [
|
||||
{ role: 'user', text: 'how to travel from Thane to the event location' },
|
||||
{
|
||||
role: 'assistant',
|
||||
text: 'Fly into Goa and take a taxi to the Marriott. Would you like me to provide more information on the flight options or transportation from the airport to the resort?',
|
||||
},
|
||||
{ role: 'user', text: 'yes' },
|
||||
],
|
||||
event: { locationType: 'physical' },
|
||||
bot: {},
|
||||
session: { lastIntent: INTENTS.TRAVEL_TRANSPORT },
|
||||
turnLog,
|
||||
});
|
||||
|
||||
expect(converse).not.toHaveBeenCalled();
|
||||
expect(plan.intent).toBe(INTENTS.TRAVEL_TRANSPORT);
|
||||
expect(plan.path).toBe('affirmative_continue');
|
||||
expect(plan.needsSecondCall).toBe(true);
|
||||
expect(plan.searchQuery).toMatch(/Thane/i);
|
||||
expect(plan.searchQuery).toMatch(/flight|airport|transport/i);
|
||||
expect(turnLog.call1.skipped).toBe(true);
|
||||
expect(turnLog.call1.path).toBe('affirmative_continue');
|
||||
});
|
||||
|
||||
test('falls back to clarification when yes has no usable lastIntent', async () => {
|
||||
const turnLog = createTurnLog({ sessionId: 's', botId: 'b', userMessage: 'sure' });
|
||||
const plan = await routeConciergeMessage({
|
||||
history: [{ role: 'user', text: 'sure' }],
|
||||
event: { locationType: 'physical' },
|
||||
bot: {},
|
||||
session: {},
|
||||
turnLog,
|
||||
});
|
||||
|
||||
expect(converse).not.toHaveBeenCalled();
|
||||
expect(plan.intent).toBe(INTENTS.CLARIFICATION);
|
||||
expect(plan.path).toBe('affirmative_no_context');
|
||||
expect(plan.directReply).toMatch(/more detail/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('conciergeRouter Call 1 LLM classification', () => {
|
||||
test('classifies an information intent and marks needsSecondCall', async () => {
|
||||
converse.mockResolvedValue({ usage: { inputTokens: 40, outputTokens: 8 } });
|
||||
responseText.mockReturnValue('{"intent":"event_schedule","searchQuery":"today agenda"}');
|
||||
|
||||
const turnLog = createTurnLog({ sessionId: 's', botId: 'b', userMessage: 'what is on today?' });
|
||||
const plan = await routeConciergeMessage({
|
||||
history: [{ role: 'user', text: 'what is on today?' }],
|
||||
event: { locationType: 'physical' },
|
||||
bot: {},
|
||||
session: {},
|
||||
turnLog,
|
||||
});
|
||||
|
||||
expect(converse).toHaveBeenCalledTimes(1);
|
||||
expect(converse.mock.calls[0][1]).toBe('amazon.nova-micro-v1:0');
|
||||
expect(converse.mock.calls[0][2].logLabel).toBe('call1');
|
||||
expect(plan.intent).toBe(INTENTS.EVENT_SCHEDULE);
|
||||
expect(plan.needsSecondCall).toBe(true);
|
||||
expect(plan.searchQuery).toBe('today agenda');
|
||||
expect(plan.path).toBe('llm');
|
||||
expect(turnLog.call1.skipped).toBe(false);
|
||||
expect(turnLog.call1.modelId).toBe('amazon.nova-micro-v1:0');
|
||||
expect(turnLog.call1.inputTokens).toBe(40);
|
||||
expect(turnLog.call1.outputTokens).toBe(8);
|
||||
});
|
||||
|
||||
test('falls back to the chat model when Nova Micro is unavailable', async () => {
|
||||
const { isRetryableModelError } = require('../utils/bedrockClient');
|
||||
isRetryableModelError.mockReturnValue(true);
|
||||
converse
|
||||
.mockRejectedValueOnce(Object.assign(new Error('denied'), { name: 'AccessDeniedException' }))
|
||||
.mockResolvedValueOnce({ usage: { inputTokens: 10, outputTokens: 4 } });
|
||||
responseText.mockReturnValue('{"intent":"food_recommendation"}');
|
||||
|
||||
const turnLog = createTurnLog({ sessionId: 's', botId: 'b', userMessage: 'where to eat?' });
|
||||
const plan = await routeConciergeMessage({
|
||||
history: [{ role: 'user', text: 'where to eat?' }],
|
||||
event: { locationType: 'physical' },
|
||||
bot: {},
|
||||
session: {},
|
||||
turnLog,
|
||||
});
|
||||
|
||||
expect(converse).toHaveBeenCalledTimes(2);
|
||||
expect(converse.mock.calls[0][1]).toBe('amazon.nova-micro-v1:0');
|
||||
expect(converse.mock.calls[1][1]).toBe('meta.llama3-8b-instruct-v1:0');
|
||||
expect(plan.intent).toBe(INTENTS.FOOD_RECOMMENDATION);
|
||||
expect(turnLog.call1.modelId).toBe('meta.llama3-8b-instruct-v1:0');
|
||||
});
|
||||
|
||||
test('direct intents from the LLM skip Call 2', async () => {
|
||||
converse.mockResolvedValue({ usage: {} });
|
||||
responseText.mockReturnValue('{"intent":"out_of_scope"}');
|
||||
|
||||
const turnLog = createTurnLog({ sessionId: 's', botId: 'b', userMessage: 'tell me a joke' });
|
||||
const plan = await routeConciergeMessage({
|
||||
history: [{ role: 'user', text: 'tell me a joke' }],
|
||||
event: {},
|
||||
bot: {},
|
||||
session: {},
|
||||
turnLog,
|
||||
});
|
||||
|
||||
expect(plan.intent).toBe(INTENTS.OUT_OF_SCOPE);
|
||||
expect(plan.needsSecondCall).toBe(false);
|
||||
});
|
||||
|
||||
test('falls back to clarification when the LLM output is unparseable', async () => {
|
||||
converse.mockResolvedValue({ usage: {} });
|
||||
responseText.mockReturnValue('sorry, not sure');
|
||||
|
||||
const turnLog = createTurnLog({ sessionId: 's', botId: 'b', userMessage: '???' });
|
||||
const plan = await routeConciergeMessage({
|
||||
history: [{ role: 'user', text: '???' }],
|
||||
event: {},
|
||||
bot: {},
|
||||
session: {},
|
||||
turnLog,
|
||||
});
|
||||
|
||||
expect(plan.intent).toBe(INTENTS.CLARIFICATION);
|
||||
expect(plan.path).toBe('llm_unparsed');
|
||||
});
|
||||
|
||||
test('returns an unrouted plan when Call 1 throws', async () => {
|
||||
converse.mockRejectedValue(new Error('bedrock down'));
|
||||
|
||||
const turnLog = createTurnLog({ sessionId: 's', botId: 'b', userMessage: 'what is on today?' });
|
||||
const plan = await routeConciergeMessage({
|
||||
history: [{ role: 'user', text: 'what is on today?' }],
|
||||
event: {},
|
||||
bot: {},
|
||||
session: {},
|
||||
turnLog,
|
||||
});
|
||||
|
||||
expect(plan.intent).toBeNull();
|
||||
expect(plan.path).toBe('llm_error');
|
||||
expect(turnLog.call1.path).toBe('llm_error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('conciergeRouter parseRouterPlan', () => {
|
||||
test('parses a bare JSON object and derives needsSecondCall', () => {
|
||||
const plan = parseRouterPlan('{"intent":"event_schedule"}');
|
||||
expect(plan).toEqual({
|
||||
intent: 'event_schedule',
|
||||
needsSecondCall: true,
|
||||
searchQuery: null,
|
||||
directReply: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('direct intents are marked needsSecondCall=false', () => {
|
||||
const plan = parseRouterPlan('```json\n{"intent":"clarification"}\n```');
|
||||
expect(plan.intent).toBe('clarification');
|
||||
expect(plan.needsSecondCall).toBe(false);
|
||||
});
|
||||
|
||||
test('falls back to clarification for an unknown intent', () => {
|
||||
const plan = parseRouterPlan('{"intent":"launch_rockets"}');
|
||||
expect(plan.intent).toBe(INTENTS.CLARIFICATION);
|
||||
});
|
||||
|
||||
test('returns null for unparseable text', () => {
|
||||
expect(parseRouterPlan('not json at all')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('conciergeRouter event-type gating', () => {
|
||||
test('virtual events exclude physical-only intents', () => {
|
||||
const allowed = allowedIntentsForEvent({ locationType: 'virtual' });
|
||||
expect(allowed).not.toContain(INTENTS.VENUE_NAVIGATION);
|
||||
expect(allowed).not.toContain(INTENTS.TOURIST_PLACES);
|
||||
expect(allowed).not.toContain(INTENTS.ACCOMMODATION);
|
||||
expect(allowed).toContain(INTENTS.VIRTUAL_JOIN);
|
||||
});
|
||||
|
||||
test('physical events exclude virtual-only intents', () => {
|
||||
const allowed = allowedIntentsForEvent({ locationType: 'physical' });
|
||||
expect(allowed).toContain(INTENTS.VENUE_NAVIGATION);
|
||||
expect(allowed).not.toContain(INTENTS.VIRTUAL_JOIN);
|
||||
expect(allowed).not.toContain(INTENTS.VIRTUAL_TROUBLESHOOTING);
|
||||
});
|
||||
|
||||
test('hybrid events keep both physical and virtual intents', () => {
|
||||
const allowed = allowedIntentsForEvent({ locationType: 'hybrid' });
|
||||
expect(allowed).toContain(INTENTS.VENUE_NAVIGATION);
|
||||
expect(allowed).toContain(INTENTS.VIRTUAL_JOIN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('conciergeRouter keyword hints (false out_of_scope prevention)', () => {
|
||||
const { hintIntentFromMessage, buildRouterPrompt } = require('./conciergeRouter.service');
|
||||
const allowed = allowedIntentsForEvent({ locationType: 'physical' });
|
||||
|
||||
test.each([
|
||||
['when will it be approved?', INTENTS.PERSONAL_REGISTRATION],
|
||||
['why is the delay. i need to book travel tickets', INTENTS.PERSONAL_REGISTRATION],
|
||||
['Can you atleast provide the contact details of a human who can provide me informaion', INTENTS.EVENT_GENERAL_INFO],
|
||||
['my friend just told ola and uber is not available in goa', INTENTS.TRAVEL_TRANSPORT],
|
||||
['You are very rude', INTENTS.GENERAL_HELP],
|
||||
["I'm tired of you.", INTENTS.GENERAL_HELP],
|
||||
])('maps %s → %s', (message, intent) => {
|
||||
expect(hintIntentFromMessage(message, allowed)).toBe(intent);
|
||||
});
|
||||
|
||||
test('does not treat coding asks as a keyword hint', () => {
|
||||
expect(hintIntentFromMessage('write me a python script', allowed)).toBeNull();
|
||||
});
|
||||
|
||||
test('router prompt forbids false out_of_scope cases', () => {
|
||||
const prompt = buildRouterPrompt(allowed);
|
||||
expect(prompt).toMatch(/NEVER use for registration approval/i);
|
||||
expect(prompt).toMatch(/pending approval/i);
|
||||
});
|
||||
|
||||
test('routes approval follow-up without LLM', async () => {
|
||||
const turnLog = createTurnLog({ sessionId: 's', botId: 'b', userMessage: 'when will it be approved?' });
|
||||
const plan = await routeConciergeMessage({
|
||||
history: [{ role: 'user', text: 'when will it be approved?' }],
|
||||
event: { locationType: 'physical' },
|
||||
bot: {},
|
||||
session: {},
|
||||
turnLog,
|
||||
});
|
||||
expect(converse).not.toHaveBeenCalled();
|
||||
expect(plan.intent).toBe(INTENTS.PERSONAL_REGISTRATION);
|
||||
expect(plan.path).toBe('keyword_hint');
|
||||
expect(plan.intent).not.toBe(INTENTS.OUT_OF_SCOPE);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,57 +1,9 @@
|
|||
const { MongoClient } = require('mongodb');
|
||||
|
||||
const DEFAULT_EXTERNAL_DB_NAME = 'reallives_serverless_eb';
|
||||
const DEFAULT_EXTERNAL_COLLECTION = 'Food';
|
||||
|
||||
function getExternalMongoUri() {
|
||||
return process.env.MONGODB_URI || '';
|
||||
}
|
||||
|
||||
function getCollection() {
|
||||
return process.env.FOOD_EXTERNAL_COLLECTION || DEFAULT_EXTERNAL_COLLECTION;
|
||||
}
|
||||
|
||||
function getDbName() {
|
||||
return process.env.FOOD_EXTERNAL_DB || DEFAULT_EXTERNAL_DB_NAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch existing food record for an event from external DB.
|
||||
*
|
||||
* @param {string} eventId
|
||||
* @returns {Promise<{ location?: string, recommendations?: string, restaurants?: any[], raw?: any } | null>}
|
||||
*/
|
||||
async function fetchFoodRecordForEvent(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 foodRecommendationService uses { eventId: <string> }
|
||||
const doc = await coll.findOne({ eventId: eventIdStr });
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
location: doc.location,
|
||||
recommendations: doc.recommendations,
|
||||
restaurants: doc.restaurants,
|
||||
schemaVersion: doc.schemaVersion,
|
||||
generatedAt: doc.generatedAt || doc.createdAt,
|
||||
raw: doc,
|
||||
};
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
const {
|
||||
fetchFoodRecordForEvent,
|
||||
upsertFoodRecord,
|
||||
} = require('../utils/conciergeRecommendationDb');
|
||||
|
||||
module.exports = {
|
||||
fetchFoodRecordForEvent,
|
||||
upsertFoodRecord,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,10 @@
|
|||
const axios = require("axios");
|
||||
const readline = require("readline");
|
||||
const { spawn } = require("child_process");
|
||||
const { MongoClient } = require('mongodb');
|
||||
const { completeText } = require('../utils/bedrockComplete');
|
||||
const { upsertFoodRecord } = require('../utils/conciergeRecommendationDb');
|
||||
|
||||
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";
|
||||
|
|
@ -311,47 +308,6 @@ async function searchFoodPlaces(
|
|||
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 buildRecommendationPrompt(
|
||||
places,
|
||||
location,
|
||||
|
|
@ -420,32 +376,12 @@ async function recommendFoodWithLLM(
|
|||
radius
|
||||
) {
|
||||
try {
|
||||
const model =
|
||||
getOllamaModelName();
|
||||
|
||||
const prompt =
|
||||
buildRecommendationPrompt(
|
||||
places,
|
||||
location,
|
||||
radius
|
||||
);
|
||||
|
||||
const response =
|
||||
await runOllamaModel(
|
||||
prompt,
|
||||
model
|
||||
);
|
||||
|
||||
console.log(
|
||||
"\nFood suggestions from Ollama:\n"
|
||||
);
|
||||
|
||||
const prompt = buildRecommendationPrompt(places, location, radius);
|
||||
const response = await completeText(prompt, { maxTokens: 1200, temperature: 0.5 });
|
||||
console.log('\nFood suggestions from Bedrock:\n');
|
||||
console.log(response);
|
||||
} catch (err) {
|
||||
console.log(
|
||||
"\nUnable to generate recommendations:"
|
||||
);
|
||||
|
||||
console.log('\nUnable to generate recommendations:');
|
||||
console.log(err.message);
|
||||
}
|
||||
}
|
||||
|
|
@ -539,12 +475,7 @@ async function getFoodRecommendations(location, eventId = null) {
|
|||
const fallbackText = `Food recommendations unavailable: missing Google Places API key for ${location}.`;
|
||||
// Save empty places doc with note
|
||||
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('Food');
|
||||
const doc = {
|
||||
await upsertFoodRecord({
|
||||
eventId: eventId ? String(eventId) : null,
|
||||
location,
|
||||
origin: geo,
|
||||
|
|
@ -553,11 +484,9 @@ async function getFoodRecommendations(location, eventId = null) {
|
|||
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) {
|
||||
console.warn('Failed to save food places to external DB:', dbErr.message || dbErr);
|
||||
console.warn('Failed to save food recommendations:', dbErr.message || dbErr);
|
||||
}
|
||||
return fallbackText + ' Set GOOGLE_PLACES_API_KEY to enable real recommendations.';
|
||||
}
|
||||
|
|
@ -591,10 +520,7 @@ async function getFoodRecommendations(location, eventId = null) {
|
|||
DEFAULT_RADIUS_METERS
|
||||
);
|
||||
|
||||
recommendationText = await runOllamaModel(
|
||||
prompt,
|
||||
getOllamaModelName()
|
||||
);
|
||||
recommendationText = await completeText(prompt, { maxTokens: 1200, temperature: 0.5 });
|
||||
} catch (e) {
|
||||
console.warn('[getFoodRecommendations] LLM recommendation failed:', e.message, '— generating fallback text');
|
||||
|
||||
|
|
@ -613,16 +539,9 @@ async function getFoodRecommendations(location, eventId = null) {
|
|||
}
|
||||
}
|
||||
|
||||
// Save structured places and generated recommendation into external MongoDB
|
||||
try {
|
||||
console.log('[getFoodRecommendations] Saving to external DB');
|
||||
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('Food');
|
||||
|
||||
const doc = {
|
||||
console.log('[getFoodRecommendations] Saving to DB');
|
||||
await upsertFoodRecord({
|
||||
eventId: eventId ? String(eventId) : null,
|
||||
location: geo.formattedAddress,
|
||||
origin: { lat: geo.lat, lng: geo.lng },
|
||||
|
|
@ -630,18 +549,10 @@ async function getFoodRecommendations(location, eventId = null) {
|
|||
restaurants: places,
|
||||
schemaVersion: 4,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
if (doc.eventId) {
|
||||
await coll.updateOne({ eventId: doc.eventId }, { $set: doc }, { upsert: true });
|
||||
} else {
|
||||
await coll.insertOne(doc);
|
||||
}
|
||||
|
||||
await client.close();
|
||||
});
|
||||
console.log('[getFoodRecommendations] Saved to DB');
|
||||
} catch (dbErr) {
|
||||
console.warn('Failed to save food places to external DB:', dbErr.message || dbErr);
|
||||
console.warn('Failed to save food recommendations:', dbErr.message || dbErr);
|
||||
}
|
||||
|
||||
const restaurantSummary = places
|
||||
|
|
|
|||
|
|
@ -3,6 +3,11 @@ const Event = require('../models/Event');
|
|||
const Session = require('../models/Session');
|
||||
const SessionBookmark = require('../models/SessionBookmark');
|
||||
const { getMembership, membershipEmailQuery } = require('../utils/participantMembership');
|
||||
const {
|
||||
loadOrganizerContact,
|
||||
formatOrganizerContactLine,
|
||||
missingOrganizerContactFallback,
|
||||
} = require('../utils/organizerContact');
|
||||
|
||||
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;
|
||||
|
|
@ -55,6 +60,39 @@ function priorQuestion(history, currentQuestion) {
|
|||
return '';
|
||||
}
|
||||
|
||||
function isPendingFollowUp(question) {
|
||||
const q = String(question || '').toLowerCase();
|
||||
return (
|
||||
/\b(when|why|how long|eta|delay(?:ed)?|waiting|approv\w*|pending)\b/.test(q)
|
||||
|| (/\b(book|booking)\b/.test(q) && /\b(travel|flight|ticket)s?\b/.test(q))
|
||||
);
|
||||
}
|
||||
|
||||
async function pendingApprovalReply({ participant, membership, eventId, question }) {
|
||||
const registeredAt = membership?.registrationDate
|
||||
? `, registered on ${formatDateTime(membership.registrationDate)}`
|
||||
: '';
|
||||
const name = attendeeName(participant);
|
||||
const contact = await loadOrganizerContact(eventId);
|
||||
const contactLine = formatOrganizerContactLine(contact) || missingOrganizerContactFallback();
|
||||
|
||||
const base =
|
||||
`${name}, your registration for this event is pending approval${registeredAt}. `
|
||||
+ 'Approval is handled by the organizer, so I can’t give an exact ETA or speed it up.';
|
||||
|
||||
if (isPendingFollowUp(question)) {
|
||||
return (
|
||||
`${base} ${contactLine} `
|
||||
+ 'Meanwhile I can help with the event schedule, venue, or travel planning for the event dates.'
|
||||
).replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
return (
|
||||
`${base} ${contactLine} `
|
||||
+ 'I can also help with the schedule, venue, or travel for the event dates while you wait.'
|
||||
).replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
async function getSavedSchedule(participantId, eventId, question, membership) {
|
||||
const bookmarks = await SessionBookmark.find({ participant: participantId, event: eventId })
|
||||
.select('session')
|
||||
|
|
@ -114,6 +152,15 @@ async function getRegisteredEvents(participant, organizationId) {
|
|||
async function answerPersonalQuestion({ participant, eventId, organizationId, question }) {
|
||||
const q = String(question || '').toLowerCase();
|
||||
const membership = getMembership(participant, eventId);
|
||||
const status = String(membership?.status || '').toLowerCase();
|
||||
|
||||
if (status === 'pending' && (
|
||||
isPendingFollowUp(question)
|
||||
|| /\b(ticket|badge|qr|registration|registered|status)\b/.test(q)
|
||||
|| !q
|
||||
)) {
|
||||
return pendingApprovalReply({ participant, membership, eventId, question });
|
||||
}
|
||||
|
||||
if (/\bevents?\b/.test(q) && /\b(my|registered|registrations?)\b/.test(q)) {
|
||||
return getRegisteredEvents(participant, organizationId);
|
||||
|
|
@ -135,12 +182,13 @@ async function answerPersonalQuestion({ participant, eventId, organizationId, qu
|
|||
? `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)) {
|
||||
if (/\b(phone|mobile|contact number)\b/.test(q) && !/\b(organi[sz]er|support|human|staff)\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)) {
|
||||
if (/\b(profile|name|email|designation|company|organization)\b/.test(q)
|
||||
&& !/\b(organi[sz]er|support|human|staff)\b/.test(q)) {
|
||||
return [
|
||||
`Name: ${attendeeName(participant)}`,
|
||||
`Email: ${participant.email}`,
|
||||
|
|
@ -150,6 +198,9 @@ async function answerPersonalQuestion({ participant, eventId, organizationId, qu
|
|||
`Event registration: ${statusLabel(membership?.status)}`,
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
if (status === 'pending') {
|
||||
return pendingApprovalReply({ participant, membership, eventId, question });
|
||||
}
|
||||
if (/\b(ticket|badge|qr|registration|registered|status)\b/.test(q)) {
|
||||
const registeredAt = membership?.registrationDate
|
||||
? `, registered on ${formatDateTime(membership.registrationDate)}`
|
||||
|
|
@ -217,4 +268,10 @@ async function runPersonalInformation({ eventId, organizationId, session, sessio
|
|||
};
|
||||
}
|
||||
|
||||
module.exports = { runPersonalInformation, extractEmail, answerPersonalQuestion };
|
||||
module.exports = {
|
||||
runPersonalInformation,
|
||||
extractEmail,
|
||||
answerPersonalQuestion,
|
||||
isPendingFollowUp,
|
||||
pendingApprovalReply,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
jest.mock('../models/Participant', () => ({ findOne: jest.fn() }));
|
||||
jest.mock('../utils/organizerContact', () => ({
|
||||
loadOrganizerContact: jest.fn(async () => ({ name: 'Org', email: 'support@example.com', phone: '' })),
|
||||
formatOrganizerContactLine: jest.fn((c) => (c?.email ? `You can reach ${c.name} at ${c.email}.` : '')),
|
||||
missingOrganizerContactFallback: jest.fn(() => 'I don’t have a support email or phone on file.'),
|
||||
}));
|
||||
|
||||
const Participant = require('../models/Participant');
|
||||
const { runPersonalInformation, extractEmail, answerPersonalQuestion } = require('./personalInformation.service');
|
||||
|
|
@ -41,7 +46,7 @@ describe('personalInformation service', () => {
|
|||
|
||||
it('binds a matching event participant to only the current chat session', async () => {
|
||||
Participant.findOne.mockReturnValue({ lean: jest.fn().mockResolvedValue(participant()) });
|
||||
const session = {};
|
||||
const session = { save: jest.fn().mockResolvedValue(undefined) };
|
||||
const result = await runPersonalInformation({
|
||||
eventId,
|
||||
organizationId,
|
||||
|
|
@ -77,7 +82,7 @@ describe('personalInformation service', () => {
|
|||
});
|
||||
|
||||
expect(result.text).toMatch(/could not find/i);
|
||||
expect(session.participant).toBeUndefined();
|
||||
expect(session.participant).toBeNull();
|
||||
expect(session.awaitingPersonalEmail).toBe(true);
|
||||
});
|
||||
|
||||
|
|
@ -89,7 +94,7 @@ describe('personalInformation service', () => {
|
|||
eventMemberships: [{ event: eventId, status: 'pending' }],
|
||||
});
|
||||
Participant.findOne.mockReturnValue({ lean: jest.fn().mockResolvedValue(replacement) });
|
||||
const session = { participant: '64b000000000000000000003' };
|
||||
const session = { participant: '64b000000000000000000003', save: jest.fn().mockResolvedValue(undefined) };
|
||||
|
||||
const result = await runPersonalInformation({
|
||||
eventId,
|
||||
|
|
|
|||
|
|
@ -1,59 +1,9 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
const {
|
||||
fetchPlacesRecordForEvent,
|
||||
upsertPlacesRecord,
|
||||
} = require('../utils/conciergeRecommendationDb');
|
||||
|
||||
module.exports = {
|
||||
fetchPlacesRecordForEvent,
|
||||
upsertPlacesRecord,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,8 @@
|
|||
const axios = require("axios");
|
||||
const { spawn } = require("child_process");
|
||||
const { MongoClient } = require('mongodb');
|
||||
const { completeText } = require('../utils/bedrockComplete');
|
||||
const { upsertPlacesRecord } = require('../utils/conciergeRecommendationDb');
|
||||
|
||||
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";
|
||||
|
|
@ -15,29 +11,81 @@ const BASE_PLACES_URL =
|
|||
const BASE_DETAILS_URL =
|
||||
"https://maps.googleapis.com/maps/api/place/details/json";
|
||||
|
||||
const DEFAULT_RADIUS_METERS = 2500;
|
||||
const DEFAULT_RESULT_COUNT = 20;
|
||||
const DEFAULT_RESULT_COUNT = 50;
|
||||
const MAX_RESULTS_PER_QUERY = 4;
|
||||
const PLACES_SCHEMA_VERSION = 5;
|
||||
const PLACES_SEARCH_MODE = 'destination';
|
||||
|
||||
// Category queries first so beaches/churches/forts get reserved slots; broad last.
|
||||
const SEARCH_QUERY_TEMPLATES = [
|
||||
"tourist attractions in {location}",
|
||||
"best landmarks near {location}",
|
||||
"places to visit in {location}",
|
||||
"museums and galleries near {location}",
|
||||
"beaches in {location}",
|
||||
"churches in {location}",
|
||||
"forts in {location}",
|
||||
"temples in {location}",
|
||||
"waterfalls in {location}",
|
||||
"museums in {location}",
|
||||
"markets in {location}",
|
||||
"historical sites in {location}",
|
||||
"monuments and viewpoints near {location}",
|
||||
"parks and gardens near {location}",
|
||||
"parks and gardens in {location}",
|
||||
"monuments and viewpoints in {location}",
|
||||
"famous places to visit in {location}",
|
||||
"best tourist attractions in {location}",
|
||||
"landmarks in {location}",
|
||||
"places to visit in {location}",
|
||||
];
|
||||
|
||||
function getApiKey() {
|
||||
const apiKey = process.env[API_KEY_ENV];
|
||||
const TOURISM_TYPE_ALLOWLIST = new Set([
|
||||
'tourist_attraction',
|
||||
'beach',
|
||||
'church',
|
||||
'hindu_temple',
|
||||
'mosque',
|
||||
'synagogue',
|
||||
'place_of_worship',
|
||||
'museum',
|
||||
'art_gallery',
|
||||
'park',
|
||||
'zoo',
|
||||
'aquarium',
|
||||
'amusement_park',
|
||||
'natural_feature',
|
||||
'campground',
|
||||
'rv_park',
|
||||
]);
|
||||
|
||||
if (!apiKey) {
|
||||
console.error(`Error: environment variable ${API_KEY_ENV} is not set.`);
|
||||
process.exit(1);
|
||||
}
|
||||
const TOURISM_NAME_TERMS = [
|
||||
'fort', 'palace', 'waterfall', 'viewpoint', 'view point', 'landmark',
|
||||
'basilica', 'cathedral', 'monument', 'memorial', 'beach', 'temple', 'church',
|
||||
];
|
||||
|
||||
return apiKey;
|
||||
}
|
||||
const TOURISM_TYPE_BLOCKLIST = new Set([
|
||||
'lodging',
|
||||
'travel_agency',
|
||||
'restaurant',
|
||||
'food',
|
||||
'cafe',
|
||||
'bar',
|
||||
'meal_takeaway',
|
||||
'meal_delivery',
|
||||
'bakery',
|
||||
'store',
|
||||
'shopping_mall',
|
||||
'clothing_store',
|
||||
'supermarket',
|
||||
'real_estate_agency',
|
||||
'spa',
|
||||
'gym',
|
||||
'parking',
|
||||
'hospital',
|
||||
'school',
|
||||
'university',
|
||||
'bank',
|
||||
'atm',
|
||||
'car_rental',
|
||||
'gas_station',
|
||||
'local_government_office',
|
||||
'general_contractor',
|
||||
]);
|
||||
|
||||
async function fetchJson(url, params) {
|
||||
const response = await axios.get(url, {
|
||||
|
|
@ -48,6 +96,33 @@ async function fetchJson(url, params) {
|
|||
return response.data;
|
||||
}
|
||||
|
||||
function componentByType(components, type) {
|
||||
const list = Array.isArray(components) ? components : [];
|
||||
const match = list.find((c) => Array.isArray(c?.types) && c.types.includes(type));
|
||||
return String(match?.long_name || match?.short_name || '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer state/region (e.g. Goa) over locality (e.g. Panaji) for destination tourism search.
|
||||
*/
|
||||
function resolveDestinationLabel(components, fallbackAddress = '') {
|
||||
const admin1 = componentByType(components, 'administrative_area_level_1');
|
||||
if (admin1) return admin1;
|
||||
const locality = componentByType(components, 'locality');
|
||||
if (locality) return locality;
|
||||
const admin2 = componentByType(components, 'administrative_area_level_2');
|
||||
if (admin2) return admin2;
|
||||
const fallback = String(fallbackAddress || '').trim();
|
||||
if (!fallback) return '';
|
||||
// "Goa Marriott Resort & Spa, …, Goa 403001, India" → try last meaningful segment before country
|
||||
const parts = fallback.split(',').map((p) => p.trim()).filter(Boolean);
|
||||
for (let i = parts.length - 1; i >= 0; i -= 1) {
|
||||
const part = parts[i].replace(/\d{4,}/g, '').replace(/\bIndia\b/i, '').trim();
|
||||
if (part && !/^(india|in)$/i.test(part) && part.length >= 3) return part.split(/\s+/)[0] || part;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function geocodeAddress(address, apiKey) {
|
||||
const result = await fetchJson(BASE_GEOCODE_URL, {
|
||||
address,
|
||||
|
|
@ -62,13 +137,17 @@ async function geocodeAddress(address, apiKey) {
|
|||
throw new Error(`Geocoding failed: ${result.status}`);
|
||||
}
|
||||
|
||||
const location = result.results[0].geometry.location;
|
||||
const first = result.results[0];
|
||||
const location = first.geometry.location;
|
||||
const addressComponents = first.address_components || [];
|
||||
const formattedAddress = first.formatted_address || address;
|
||||
|
||||
return {
|
||||
formattedAddress:
|
||||
result.results[0].formatted_address || address,
|
||||
formattedAddress,
|
||||
lat: Number(location.lat),
|
||||
lng: Number(location.lng),
|
||||
addressComponents,
|
||||
destination: resolveDestinationLabel(addressComponents, formattedAddress),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -96,6 +175,35 @@ function formatDistance(meters) {
|
|||
return `${Math.round(meters)} m`;
|
||||
}
|
||||
|
||||
function normalizeType(value) {
|
||||
return String(value || '').trim().toLowerCase().replace(/\s+/g, '_');
|
||||
}
|
||||
|
||||
function isTourismPlace(place) {
|
||||
const types = (Array.isArray(place?.types) ? place.types : [])
|
||||
.map(normalizeType)
|
||||
.filter(Boolean);
|
||||
const typeSet = new Set(types);
|
||||
|
||||
// Strict: never keep lodging / travel agencies / food / retail, etc.
|
||||
for (const blocked of TOURISM_TYPE_BLOCKLIST) {
|
||||
if (typeSet.has(blocked)) return false;
|
||||
}
|
||||
|
||||
for (const allowed of TOURISM_TYPE_ALLOWLIST) {
|
||||
if (typeSet.has(allowed)) return true;
|
||||
}
|
||||
|
||||
const name = String(place?.name || '').toLowerCase();
|
||||
const tagText = [
|
||||
name,
|
||||
...(Array.isArray(place?.tags) ? place.tags : []),
|
||||
...types.map((t) => t.replace(/_/g, ' ')),
|
||||
].join(' ');
|
||||
|
||||
return TOURISM_NAME_TERMS.some((term) => tagText.includes(term));
|
||||
}
|
||||
|
||||
async function getPlaceDetails(placeId, apiKey) {
|
||||
const result = await fetchJson(BASE_DETAILS_URL, {
|
||||
place_id: placeId,
|
||||
|
|
@ -119,28 +227,34 @@ async function getPlaceDetails(placeId, apiKey) {
|
|||
return result.result;
|
||||
}
|
||||
|
||||
/**
|
||||
* City/region Text Search with no venue lat/lng bias.
|
||||
* Per-template quota ensures category queries are not starved by early hits.
|
||||
*/
|
||||
async function searchTouristPlaces(
|
||||
originLat,
|
||||
originLng,
|
||||
apiKey,
|
||||
locationName = "",
|
||||
radius = DEFAULT_RADIUS_METERS,
|
||||
limit = DEFAULT_RESULT_COUNT
|
||||
destinationName = "",
|
||||
limit = DEFAULT_RESULT_COUNT,
|
||||
maxPerQuery = MAX_RESULTS_PER_QUERY,
|
||||
) {
|
||||
const seen = new Set();
|
||||
const places = [];
|
||||
const destination = String(destinationName || '').trim();
|
||||
if (!destination) return places;
|
||||
|
||||
for (const template of SEARCH_QUERY_TEMPLATES) {
|
||||
if (places.length >= limit) break;
|
||||
|
||||
const queryText = template.replace("{location}", locationName);
|
||||
const queryText = template.replace("{location}", destination);
|
||||
let addedForQuery = 0;
|
||||
|
||||
let result;
|
||||
try {
|
||||
// Query only — no location/radius bias toward the hotel pin.
|
||||
result = await fetchJson(BASE_PLACES_URL, {
|
||||
query: queryText,
|
||||
location: `${originLat},${originLng}`,
|
||||
radius,
|
||||
key: apiKey,
|
||||
});
|
||||
} catch {
|
||||
|
|
@ -152,7 +266,7 @@ async function searchTouristPlaces(
|
|||
}
|
||||
|
||||
for (const place of result.results || []) {
|
||||
if (places.length >= limit) break;
|
||||
if (places.length >= limit || addedForQuery >= maxPerQuery) break;
|
||||
|
||||
const placeId = place.place_id;
|
||||
if (!placeId || seen.has(placeId)) continue;
|
||||
|
|
@ -186,7 +300,7 @@ async function searchTouristPlaces(
|
|||
.slice(0, 8)
|
||||
.join(' • ');
|
||||
|
||||
places.push({
|
||||
const record = {
|
||||
name: place.name,
|
||||
address: place.formatted_address,
|
||||
rating: place.rating,
|
||||
|
|
@ -208,74 +322,50 @@ async function searchTouristPlaces(
|
|||
.slice(0, 8)
|
||||
.map((t) => String(t).replace(/_/g, ' ')),
|
||||
distance_meters: haversineDistance(originLat, originLng, lat, lng),
|
||||
});
|
||||
matched_query: queryText,
|
||||
};
|
||||
|
||||
if (!isTourismPlace(record)) continue;
|
||||
|
||||
places.push(record);
|
||||
addedForQuery += 1;
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
function buildTouristRecommendationPrompt(places, destination) {
|
||||
const placeSummary = places
|
||||
.slice(0, 12)
|
||||
.slice(0, 20)
|
||||
.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`;
|
||||
return `\n${index + 1}. ${p.name}\nTags: ${(p.tags || []).join(', ') || 'N/A'}\nRating: ${p.rating || 'N/A'} (${p.user_ratings_total || 0} reviews)\nDistance from venue: ${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}
|
||||
The user is looking for notable / famous tourist places and things to do in:
|
||||
${destination}
|
||||
|
||||
Nearby candidate places:
|
||||
Notable candidate places (prefer well-known and highly reviewed; distance from the event venue is secondary metadata only):
|
||||
${placeSummary}
|
||||
|
||||
Your task:
|
||||
1) Recommend 5 to 7 tourist places that a visitor should visit in this area.
|
||||
1) Recommend 5 to 7 tourist places that a visitor should visit in ${destination} — favour fame and popularity over proximity.
|
||||
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}".
|
||||
5) Start your response with a heading like "🗺️ Top Tourist Places in ${destination}".
|
||||
6) Return ONLY plain text recommendation.
|
||||
`;
|
||||
}
|
||||
|
||||
async function getPlacesRecommendationsWithLLM(places, location) {
|
||||
async function getPlacesRecommendationsWithLLM(places, destination) {
|
||||
try {
|
||||
const model = getOllamaModelName();
|
||||
const prompt = buildTouristRecommendationPrompt(places, location);
|
||||
return await runOllamaModel(prompt, model);
|
||||
const prompt = buildTouristRecommendationPrompt(places, destination);
|
||||
return await completeText(prompt, { maxTokens: 1200, temperature: 0.5 });
|
||||
} catch (err) {
|
||||
console.log('Unable to generate tourist-place recommendations:', err?.message || err);
|
||||
return '';
|
||||
|
|
@ -283,52 +373,54 @@ async function getPlacesRecommendationsWithLLM(places, location) {
|
|||
}
|
||||
|
||||
async function getPlacesRecommendations(location, eventId = null) {
|
||||
|
||||
const apiKey = process.env[API_KEY_ENV];
|
||||
const venueInput = String(location || '').trim();
|
||||
|
||||
let geo = { formattedAddress: location, lat: 0, lng: 0 };
|
||||
let geo = {
|
||||
formattedAddress: venueInput,
|
||||
lat: 0,
|
||||
lng: 0,
|
||||
addressComponents: [],
|
||||
destination: venueInput,
|
||||
};
|
||||
let places = [];
|
||||
|
||||
if (!apiKey) {
|
||||
const fallbackText = `Tourist place recommendations unavailable: missing Google Places API key for ${location}.`;
|
||||
const fallbackText = `Tourist place recommendations unavailable: missing Google Places API key for ${venueInput || 'this event'}.`;
|
||||
|
||||
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 = {
|
||||
await upsertPlacesRecord({
|
||||
eventId: eventId ? String(eventId) : null,
|
||||
location,
|
||||
origin: geo,
|
||||
location: venueInput,
|
||||
destination: venueInput,
|
||||
searchMode: PLACES_SEARCH_MODE,
|
||||
schemaVersion: PLACES_SCHEMA_VERSION,
|
||||
origin: { lat: geo.lat, lng: geo.lng, formattedAddress: venueInput },
|
||||
recommendations: fallbackText,
|
||||
places: [],
|
||||
note: 'no-api-key',
|
||||
generatedAt: new Date(),
|
||||
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);
|
||||
console.warn('Failed to save tourist places:', dbErr.message || dbErr);
|
||||
}
|
||||
|
||||
return fallbackText + ' Set GOOGLE_PLACES_API_KEY to enable real recommendations.';
|
||||
}
|
||||
|
||||
try {
|
||||
geo = await geocodeAddress(location, apiKey);
|
||||
geo = await geocodeAddress(venueInput, apiKey);
|
||||
} catch (e) {
|
||||
console.warn('[getPlacesRecommendations] Geocoding failed:', e.message);
|
||||
geo.formattedAddress = location;
|
||||
geo.formattedAddress = venueInput;
|
||||
geo.destination = resolveDestinationLabel([], venueInput) || venueInput;
|
||||
}
|
||||
|
||||
const destination = geo.destination || venueInput;
|
||||
|
||||
try {
|
||||
places = await searchTouristPlaces(geo.lat, geo.lng, apiKey, geo.formattedAddress);
|
||||
places = await searchTouristPlaces(geo.lat, geo.lng, apiKey, destination);
|
||||
} catch (e) {
|
||||
console.warn('[getPlacesRecommendations] Tourist places search failed:', e.message);
|
||||
}
|
||||
|
|
@ -336,61 +428,67 @@ async function getPlacesRecommendations(location, eventId = null) {
|
|||
let recommendationText = '';
|
||||
|
||||
try {
|
||||
recommendationText = await getPlacesRecommendationsWithLLM(places, geo.formattedAddress);
|
||||
recommendationText = await getPlacesRecommendationsWithLLM(places, destination);
|
||||
} 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}.`;
|
||||
recommendationText = `No tourist places found in ${destination}.`;
|
||||
} else {
|
||||
const top = places.slice(0, 6)
|
||||
.map((p) => `${p.name} (rating: ${p.rating || 'N/A'})`)
|
||||
const top = places
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const reviewDiff = Number(b?.user_ratings_total || 0) - Number(a?.user_ratings_total || 0);
|
||||
if (reviewDiff) return reviewDiff;
|
||||
return Number(b?.rating || 0) - Number(a?.rating || 0);
|
||||
})
|
||||
.slice(0, 6)
|
||||
.map((p) => `${p.name} (rating: ${p.rating || 'N/A'}, ${p.user_ratings_total || 0} reviews)`)
|
||||
.join('\n');
|
||||
recommendationText = `Tourist places near ${geo.formattedAddress}\n\nNearby places:\n${top}`;
|
||||
recommendationText = `Notable tourist places in ${destination}\n\nTop 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 = {
|
||||
await upsertPlacesRecord({
|
||||
eventId: eventId ? String(eventId) : null,
|
||||
location: geo.formattedAddress,
|
||||
origin: { lat: geo.lat, lng: geo.lng },
|
||||
location: destination,
|
||||
destination,
|
||||
venueLocation: geo.formattedAddress,
|
||||
searchMode: PLACES_SEARCH_MODE,
|
||||
schemaVersion: PLACES_SCHEMA_VERSION,
|
||||
origin: {
|
||||
lat: geo.lat,
|
||||
lng: geo.lng,
|
||||
formattedAddress: geo.formattedAddress,
|
||||
},
|
||||
recommendations: recommendationText,
|
||||
places,
|
||||
generatedAt: new Date(),
|
||||
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);
|
||||
console.warn('Failed to save tourist places:', 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`,
|
||||
`\n${index + 1}. ${p.name}\nTags: ${(p.tags || []).join(', ') || 'N/A'}\nRating: ${p.rating || 'N/A'}\nDistance from venue: ${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`;
|
||||
return `\nTOURIST PLACES RECOMMENDATIONS FOR ${destination}\n\n${recommendationText}\n\nPLACES TO TRY\n\n${placesSummary}\n`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
// used by conciergeIngest.js
|
||||
getPlacesRecommendations,
|
||||
resolveDestinationLabel,
|
||||
isTourismPlace,
|
||||
PLACES_SCHEMA_VERSION,
|
||||
PLACES_SEARCH_MODE,
|
||||
TOURISM_TYPE_ALLOWLIST,
|
||||
TOURISM_TYPE_BLOCKLIST,
|
||||
};
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
const {
|
||||
resolveDestinationLabel,
|
||||
isTourismPlace,
|
||||
} = require('./placesRecommendationService');
|
||||
|
||||
describe('resolveDestinationLabel', () => {
|
||||
test('prefers administrative_area_level_1 (state) over locality', () => {
|
||||
const components = [
|
||||
{ long_name: 'Miramar', types: ['sublocality', 'political'] },
|
||||
{ long_name: 'Panaji', types: ['locality', 'political'] },
|
||||
{ long_name: 'Goa', types: ['administrative_area_level_1', 'political'] },
|
||||
{ long_name: 'India', types: ['country', 'political'] },
|
||||
];
|
||||
expect(resolveDestinationLabel(components, 'Goa Marriott, Panaji, Goa, India')).toBe('Goa');
|
||||
});
|
||||
|
||||
test('falls back to locality when state is missing', () => {
|
||||
const components = [
|
||||
{ long_name: 'Panaji', types: ['locality', 'political'] },
|
||||
{ long_name: 'India', types: ['country', 'political'] },
|
||||
];
|
||||
expect(resolveDestinationLabel(components, 'Panaji, India')).toBe('Panaji');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTourismPlace', () => {
|
||||
test('keeps beaches and tourist attractions', () => {
|
||||
expect(isTourismPlace({ name: 'Calangute Beach', types: ['beach', 'natural_feature'] })).toBe(true);
|
||||
expect(isTourismPlace({ name: 'Aguada Fort', types: ['tourist_attraction', 'point_of_interest'] })).toBe(true);
|
||||
expect(isTourismPlace({ name: 'Reis Magos Fort', types: ['point_of_interest'], tags: ['establishment'] })).toBe(true);
|
||||
});
|
||||
|
||||
test('drops lodging and travel agencies', () => {
|
||||
expect(isTourismPlace({ name: 'Miramar Residency', types: ['lodging', 'point_of_interest'] })).toBe(false);
|
||||
expect(isTourismPlace({
|
||||
name: 'Make It Happen Tours',
|
||||
types: ['travel_agency', 'point_of_interest'],
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
test('drops restaurants even with point_of_interest', () => {
|
||||
expect(isTourismPlace({ name: 'Cafe', types: ['restaurant', 'food', 'point_of_interest'] })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,12 +1,14 @@
|
|||
const { BedrockRuntimeClient } = require('@aws-sdk/client-bedrock-runtime');
|
||||
|
||||
const DEFAULT_BEDROCK_MODEL_ID = 'amazon.nova-lite-v1:0';
|
||||
const DEFAULT_BEDROCK_MODEL_ID = 'meta.llama3-8b-instruct-v1:0';
|
||||
|
||||
/** Cheap/fast model for Call 1 intent classification (resolved to apac.* in AP regions). */
|
||||
const DEFAULT_BEDROCK_ROUTER_MODEL_ID = 'amazon.nova-micro-v1:0';
|
||||
|
||||
const DEFAULT_BEDROCK_EMBED_MODEL_ID = 'amazon.titan-embed-text-v2:0';
|
||||
|
||||
const REGION_FALLBACK_MODEL_IDS = [
|
||||
'amazon.nova-micro-v1:0',
|
||||
'meta.llama3-8b-instruct-v1:0',
|
||||
];
|
||||
|
||||
|
||||
|
|
@ -38,7 +40,47 @@ function getBedrockConfig() {
|
|||
'ap-south-1';
|
||||
const modelId = process.env.BEDROCK_MODEL_ID || DEFAULT_BEDROCK_MODEL_ID;
|
||||
const embedModelId = process.env.BEDROCK_EMBED_MODEL_ID || DEFAULT_BEDROCK_EMBED_MODEL_ID;
|
||||
return { region, modelId, embedModelId };
|
||||
const routerModelId = process.env.BEDROCK_ROUTER_MODEL_ID || DEFAULT_BEDROCK_ROUTER_MODEL_ID;
|
||||
return { region, modelId, embedModelId, routerModelId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Model candidates for Call 1 (intent router): Nova Micro first, then chat model.
|
||||
*/
|
||||
function getRouterModelCandidates() {
|
||||
const { modelId: chatModelId, routerModelId } = getBedrockConfig();
|
||||
const fromEnv = (process.env.BEDROCK_ROUTER_MODEL_FALLBACKS || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
return [...new Set([routerModelId, ...fromEnv, chatModelId].filter(Boolean))];
|
||||
}
|
||||
|
||||
/**
|
||||
* Nova models in APAC often require inference profile IDs (apac.*) instead of foundation model IDs.
|
||||
* @param {string} modelId
|
||||
* @param {string} [region]
|
||||
*/
|
||||
function resolveBedrockModelId(modelId, region) {
|
||||
const id = String(modelId || '').trim();
|
||||
if (!id || id.startsWith('arn:aws:bedrock:')) return id;
|
||||
if (/^(us|eu|apac)\./.test(id)) return id;
|
||||
|
||||
const mode = String(process.env.BEDROCK_USE_INFERENCE_PROFILES ?? 'auto').toLowerCase();
|
||||
if (mode === 'false' || mode === '0' || mode === 'no') return id;
|
||||
|
||||
const bedrockRegion =
|
||||
region ||
|
||||
process.env.BEDROCK_REGION ||
|
||||
process.env.AWS_DEFAULT_REGION ||
|
||||
process.env.AWS_REGION ||
|
||||
'ap-south-1';
|
||||
|
||||
if (id.startsWith('amazon.nova-') && bedrockRegion.startsWith('ap-')) {
|
||||
return `apac.${id}`;
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
function buildModelIdCandidates(primaryId) {
|
||||
|
|
@ -76,13 +118,26 @@ function isInvalidModelError(err) {
|
|||
);
|
||||
}
|
||||
|
||||
/** Model unavailable or not permitted for this IAM principal — try next candidate. */
|
||||
function isRetryableModelError(err) {
|
||||
if (isInvalidModelError(err)) return true;
|
||||
const name = err.name || err.Code || '';
|
||||
if (name === 'AccessDeniedException') return true;
|
||||
const msg = String(err.message || '').toLowerCase();
|
||||
return msg.includes('not authorized to perform') && msg.includes('bedrock');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createBedrockClient,
|
||||
getBedrockConfig,
|
||||
getRouterModelCandidates,
|
||||
buildModelIdCandidates,
|
||||
buildEmbedModelIdCandidates,
|
||||
isInvalidModelError,
|
||||
isRetryableModelError,
|
||||
resolveBedrockModelId,
|
||||
DEFAULT_BEDROCK_MODEL_ID,
|
||||
DEFAULT_BEDROCK_ROUTER_MODEL_ID,
|
||||
DEFAULT_BEDROCK_EMBED_MODEL_ID,
|
||||
REGION_FALLBACK_MODEL_IDS,
|
||||
REGION_FALLBACK_EMBED_MODEL_IDS,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
const {
|
||||
createBedrockClient,
|
||||
getBedrockConfig,
|
||||
isRetryableModelError,
|
||||
} = require('./bedrockClient');
|
||||
const { converse, responseText } = require('./bedrockConverse');
|
||||
|
||||
/** Cheapest Nova tier — resolved to apac.* inference profile in AP regions. */
|
||||
const DEFAULT_RECOMMENDATION_MODEL_ID = 'amazon.nova-micro-v1:0';
|
||||
|
||||
const DEFAULT_RECOMMENDATION_FALLBACKS = [
|
||||
'amazon.nova-lite-v1:0',
|
||||
'meta.llama3-8b-instruct-v1:0',
|
||||
];
|
||||
|
||||
function getRecommendationModelCandidates() {
|
||||
const { modelId: configuredChatModel } = getBedrockConfig();
|
||||
const primary =
|
||||
process.env.BEDROCK_RECOMMENDATION_MODEL_ID ||
|
||||
DEFAULT_RECOMMENDATION_MODEL_ID;
|
||||
const fromEnv = (process.env.BEDROCK_RECOMMENDATION_MODEL_FALLBACKS || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
// Always keep default Nova/Llama fallbacks + configured chat model as last resort,
|
||||
// even when BEDROCK_RECOMMENDATION_MODEL_FALLBACKS is set (e.g. only nova-lite).
|
||||
const chain = [
|
||||
primary,
|
||||
...fromEnv,
|
||||
...DEFAULT_RECOMMENDATION_FALLBACKS,
|
||||
configuredChatModel,
|
||||
].filter(Boolean);
|
||||
return [...new Set(chain)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain-text completion via Bedrock Converse (model fallback chain).
|
||||
* @param {string} prompt
|
||||
* @param {{ system?: string, maxTokens?: number, temperature?: number }} [options]
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function completeText(prompt, options = {}) {
|
||||
const { system, maxTokens = 1024, temperature = 0.4 } = options;
|
||||
const { region } = getBedrockConfig();
|
||||
const client = createBedrockClient(region);
|
||||
const modelCandidates = getRecommendationModelCandidates();
|
||||
|
||||
const messages = [{ role: 'user', content: [{ text: prompt }] }];
|
||||
const converseOpts = {
|
||||
messages,
|
||||
inferenceConfig: { maxTokens, temperature },
|
||||
...(system ? { system: [{ text: system }] } : {}),
|
||||
logLabel: 'recommendation',
|
||||
};
|
||||
|
||||
let lastError;
|
||||
for (const modelId of modelCandidates) {
|
||||
try {
|
||||
const response = await converse(client, modelId, converseOpts);
|
||||
const text = responseText(response).trim();
|
||||
if (!text) {
|
||||
lastError = new Error('Bedrock returned empty text');
|
||||
continue;
|
||||
}
|
||||
if (modelId !== modelCandidates[0]) {
|
||||
console.warn(
|
||||
`[bedrock] recommendation model "${modelCandidates[0]}" unavailable; used "${modelId}"`,
|
||||
);
|
||||
}
|
||||
return text;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
if (!isRetryableModelError(err)) {
|
||||
throw err;
|
||||
}
|
||||
console.warn(
|
||||
`[bedrock] recommendation model "${modelId}" unavailable (${err.name || err.Code || 'Error'}: ${String(err.message || '').slice(0, 300)}); trying next`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const err = lastError || new Error('Bedrock completion failed');
|
||||
const name = err.name || err.Code || 'BedrockError';
|
||||
throw new Error(`${name}: ${err.message || 'Bedrock completion failed'}`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
completeText,
|
||||
DEFAULT_RECOMMENDATION_MODEL_ID,
|
||||
getRecommendationModelCandidates,
|
||||
};
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
const { ConverseCommand } = require('@aws-sdk/client-bedrock-runtime');
|
||||
const { getBedrockConfig, resolveBedrockModelId } = require('./bedrockClient');
|
||||
|
||||
function responseText(response) {
|
||||
return (
|
||||
|
|
@ -9,13 +10,41 @@ function responseText(response) {
|
|||
);
|
||||
}
|
||||
|
||||
function isLlmLogEnabled() {
|
||||
const v = String(process.env.BEDROCK_LOG_LLM || process.env.CONCIERGE_DEBUG || '').toLowerCase();
|
||||
return v === 'true' || v === '1' || v === 'yes';
|
||||
}
|
||||
|
||||
function truncate(value, max = 800) {
|
||||
const str = String(value == null ? '' : value);
|
||||
if (str.length <= max) return str;
|
||||
return `${str.slice(0, max)}…[${str.length} chars]`;
|
||||
}
|
||||
|
||||
function summarizeMessages(messages) {
|
||||
if (!Array.isArray(messages)) return [];
|
||||
return messages.map((m) => ({
|
||||
role: m?.role,
|
||||
text: truncate(
|
||||
(Array.isArray(m?.content) ? m.content : [])
|
||||
.map((b) => b?.text || '')
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
1200,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('@aws-sdk/client-bedrock-runtime').BedrockRuntimeClient} client
|
||||
* @param {string} modelId
|
||||
* @param {{ system?: { text: string }[], messages: object[], inferenceConfig?: object }} opts
|
||||
* @param {{ system?: { text: string }[], messages: object[], inferenceConfig?: object, logLabel?: string }} opts
|
||||
*/
|
||||
async function converse(client, modelId, opts) {
|
||||
const { system, messages, inferenceConfig } = opts;
|
||||
const { system, messages, inferenceConfig, logLabel } = opts;
|
||||
const { region } = getBedrockConfig();
|
||||
const resolvedModelId = resolveBedrockModelId(modelId, region);
|
||||
const label = logLabel || 'converse';
|
||||
|
||||
// Allow tuning for different models/environments.
|
||||
// Defaults preserve existing behavior if env vars are not set.
|
||||
|
|
@ -36,23 +65,66 @@ async function converse(client, modelId, opts) {
|
|||
...inferenceConfig,
|
||||
};
|
||||
|
||||
console.log('[bedrock][converse] modelId=', modelId, {
|
||||
temperature: mergedInferenceConfig.temperature,
|
||||
topP: mergedInferenceConfig.topP,
|
||||
maxTokens: mergedInferenceConfig.maxTokens,
|
||||
});
|
||||
const systemText = (Array.isArray(system) ? system : [])
|
||||
.map((s) => s?.text || '')
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
return client.send(
|
||||
new ConverseCommand({
|
||||
modelId,
|
||||
...(system && system.length ? { system } : {}),
|
||||
messages,
|
||||
const shouldLog = Boolean(logLabel) || isLlmLogEnabled();
|
||||
if (shouldLog) {
|
||||
console.log(`[bedrock][llm][${label}][request]`, {
|
||||
region,
|
||||
modelId: resolvedModelId,
|
||||
requestedModelId: modelId !== resolvedModelId ? modelId : undefined,
|
||||
inferenceConfig: mergedInferenceConfig,
|
||||
}),
|
||||
);
|
||||
system: systemText ? truncate(systemText, 600) : null,
|
||||
messages: summarizeMessages(messages),
|
||||
});
|
||||
} else {
|
||||
console.log('[bedrock][converse] modelId=', resolvedModelId, modelId !== resolvedModelId ? `(from ${modelId})` : '', {
|
||||
temperature: mergedInferenceConfig.temperature,
|
||||
topP: mergedInferenceConfig.topP,
|
||||
maxTokens: mergedInferenceConfig.maxTokens,
|
||||
});
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const response = await client.send(
|
||||
new ConverseCommand({
|
||||
modelId: resolvedModelId,
|
||||
...(system && system.length ? { system } : {}),
|
||||
messages,
|
||||
inferenceConfig: mergedInferenceConfig,
|
||||
}),
|
||||
);
|
||||
|
||||
if (shouldLog) {
|
||||
console.log(`[bedrock][llm][${label}][response]`, {
|
||||
modelId: resolvedModelId,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
usage: response?.usage || null,
|
||||
stopReason: response?.stopReason || null,
|
||||
text: truncate(responseText(response), 1200),
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (err) {
|
||||
console.warn(`[bedrock][llm][${label}][error]`, {
|
||||
modelId: resolvedModelId,
|
||||
requestedModelId: modelId !== resolvedModelId ? modelId : undefined,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
name: err?.name || err?.Code || 'Error',
|
||||
message: String(err?.message || err).slice(0, 800),
|
||||
httpStatus: err?.$metadata?.httpStatusCode,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
converse,
|
||||
responseText,
|
||||
isLlmLogEnabled,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ async function loadEventKnowledgeContext(eventId) {
|
|||
tickets,
|
||||
] = await Promise.all([
|
||||
event.organization
|
||||
? Organization.findById(event.organization).select('name').lean()
|
||||
? Organization.findById(event.organization).select('name email phone').lean()
|
||||
: null,
|
||||
loadTracks(eventId),
|
||||
loadAgendaAndSpeakers(eventId, { speakerVisibility: 'all', fullDetail: true }),
|
||||
|
|
@ -93,6 +93,14 @@ function buildEventKnowledgeText(ctx) {
|
|||
|
||||
section('EVENT OVERVIEW');
|
||||
if (organization?.name) push('Organization', organization.name);
|
||||
if (organization?.email) push('Organizer support email', organization.email);
|
||||
if (organization?.phone) push('Organizer support phone', organization.phone);
|
||||
if (organization?.email || organization?.phone) {
|
||||
push(
|
||||
'Human contact',
|
||||
'Attendees can contact the organizer using the support email/phone above for registration approval or other issues the concierge cannot resolve.',
|
||||
);
|
||||
}
|
||||
push('Event name', event.name);
|
||||
push('Subtitle', event.subtitle);
|
||||
push('Description', event.description);
|
||||
|
|
@ -124,6 +132,24 @@ function buildEventKnowledgeText(ctx) {
|
|||
push('Website description', website.description || website.seoDescription);
|
||||
}
|
||||
|
||||
section('PARTICIPANT LOGISTICS');
|
||||
const logistics =
|
||||
event.participantLogistics && typeof event.participantLogistics === 'object'
|
||||
? event.participantLogistics
|
||||
: {};
|
||||
push(
|
||||
'Accommodation',
|
||||
logistics.hostManagedAccommodation
|
||||
? 'The organizer arranges accommodation for participants. Direct attendees to the event team for hotel, check-in dates, and room confirmation.'
|
||||
: 'Participants arrange their own accommodation (the organizer does not provide it).',
|
||||
);
|
||||
push(
|
||||
'Travel and transport',
|
||||
logistics.hostManagedTravel
|
||||
? 'The organizer arranges travel/transport for participants. Direct attendees to the event team for pickup points, timings, and confirmations.'
|
||||
: 'Participants arrange their own travel/transport to the venue (the organizer does not provide it).',
|
||||
);
|
||||
|
||||
section('LINKS');
|
||||
const primaryMicrosite = micrositeUrlForFolder(website.folder);
|
||||
if (primaryMicrosite) push('Primary microsite', primaryMicrosite);
|
||||
|
|
|
|||
|
|
@ -120,4 +120,19 @@ describe('buildEventKnowledgeText', () => {
|
|||
expect(text).toContain('Standard pass');
|
||||
expect(text).toContain('INR 1500.00');
|
||||
});
|
||||
|
||||
it('defaults participant logistics to self-arranged', () => {
|
||||
const text = buildEventKnowledgeText(baseCtx());
|
||||
expect(text).toContain('=== PARTICIPANT LOGISTICS ===');
|
||||
expect(text).toContain('Participants arrange their own accommodation');
|
||||
expect(text).toContain('Participants arrange their own travel/transport');
|
||||
});
|
||||
|
||||
it('reflects host-managed logistics when flags are set', () => {
|
||||
const ctx = baseCtx();
|
||||
ctx.event.participantLogistics = { hostManagedAccommodation: true, hostManagedTravel: true };
|
||||
const text = buildEventKnowledgeText(ctx);
|
||||
expect(text).toContain('The organizer arranges accommodation for participants');
|
||||
expect(text).toContain('The organizer arranges travel/transport for participants');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,211 +0,0 @@
|
|||
const { createBedrockClient, getBedrockConfig } = require('./bedrockClient');
|
||||
const { converse, responseText } = require('./bedrockConverse');
|
||||
|
||||
const INTENT_CATEGORIES = {
|
||||
EVENT_INFORMATION: 'Event Information',
|
||||
PERSONAL_INFORMATION: 'Personal Information',
|
||||
FOOD: 'Food',
|
||||
PLACES: 'Places',
|
||||
UNRECOGNIZED: 'unrecognized',
|
||||
};
|
||||
|
||||
function tryParse(s) {
|
||||
|
||||
if (!s) return null;
|
||||
|
||||
const trimmed = String(s).trim();
|
||||
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
|
||||
const fenced =
|
||||
trimmed.match(/```json\s*([\s\S]*?)\s*```/i) ||
|
||||
trimmed.match(/```\s*([\s\S]*?)\s*```/i);
|
||||
|
||||
if (fenced?.[1]) {
|
||||
try {
|
||||
return JSON.parse(fenced[1]);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const objectMatch = trimmed.match(/\{[\s\S]*\}/);
|
||||
|
||||
if (objectMatch?.[0]) {
|
||||
try {
|
||||
return JSON.parse(objectMatch[0]);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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/;
|
||||
|
||||
// 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 (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 };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildIntentClassificationPrompt() {
|
||||
return `You are an intent classifier for an event assistant.
|
||||
|
||||
Classify the CURRENT USER MESSAGE into exactly ONE category from:
|
||||
- Event Information
|
||||
- Personal Information
|
||||
- Food
|
||||
- Places
|
||||
- unrecognized
|
||||
|
||||
Categories:
|
||||
- Event Information: schedules, sessions, workshops, speakers, agendas, programs, event details
|
||||
- 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 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.
|
||||
- databaseSearchNeeded: false for greetings or non-actionable messages.
|
||||
|
||||
Output format (STRICT):
|
||||
Return ONLY a valid minified JSON object with this exact schema:
|
||||
{"category":"Event Information"|"Personal Information"|"Food"|"Places"|"unrecognized","databaseSearchNeeded":true|false}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function normalizeMessagesToText(messages) {
|
||||
if (!Array.isArray(messages)) return '';
|
||||
return messages
|
||||
.slice(-6)
|
||||
.map((m) => {
|
||||
const role = m?.role === 'assistant' ? 'Assistant' : 'User';
|
||||
const text = String(m?.text || '').trim();
|
||||
return `${role}: ${text}`.trim();
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
async function determineIntentCategory(chatContextMessages = []) {
|
||||
|
||||
const latestUserMessage =
|
||||
[...chatContextMessages]
|
||||
.reverse()
|
||||
.find(m => m.role === 'user')
|
||||
?.text || '';
|
||||
|
||||
// Rule-based classification first
|
||||
const ruleResult = classifyByRules(latestUserMessage);
|
||||
|
||||
if (ruleResult) {
|
||||
return ruleResult;
|
||||
}
|
||||
|
||||
// Fallback to Nova Lite
|
||||
const { region, modelId } = getBedrockConfig();
|
||||
|
||||
const client = createBedrockClient(region);
|
||||
|
||||
const system = buildIntentClassificationPrompt();
|
||||
|
||||
const userText = `
|
||||
CURRENT USER MESSAGE:
|
||||
${latestUserMessage}
|
||||
|
||||
Return JSON only.
|
||||
`;
|
||||
|
||||
const response = await converse(client, modelId, {
|
||||
system: [{ text: system }],
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ text: userText }]
|
||||
}
|
||||
],
|
||||
inferenceConfig: {
|
||||
temperature: 0,
|
||||
topP: 0.01,
|
||||
maxTokens: 50
|
||||
}
|
||||
});
|
||||
|
||||
const raw = responseText(response);
|
||||
|
||||
const parsed = tryParse(raw);
|
||||
|
||||
const category =
|
||||
parsed?.category &&
|
||||
Object.values(INTENT_CATEGORIES).includes(parsed.category)
|
||||
? parsed.category
|
||||
: INTENT_CATEGORIES.UNRECOGNIZED;
|
||||
|
||||
const databaseSearchNeeded =
|
||||
typeof parsed?.databaseSearchNeeded === 'boolean'
|
||||
? parsed.databaseSearchNeeded
|
||||
: false;
|
||||
|
||||
return {
|
||||
category,
|
||||
databaseSearchNeeded
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
determineIntentCategory,
|
||||
INTENT_CATEGORIES,
|
||||
};
|
||||
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
/**
|
||||
* Concierge two-stage pipeline turn log.
|
||||
*
|
||||
* A "turn log" captures one inbound user message as it flows through the
|
||||
* two-stage pipeline: Call 1 (router / intent classification), the intent
|
||||
* handler, and the optional Call 2 (RAG answer generation). It is used for
|
||||
* three things:
|
||||
* 1. Persisted onto WebChatSession.pipelineLog[] for auditing/debugging.
|
||||
* 2. Structured console logging (gated by CONCIERGE_DEBUG).
|
||||
* 3. Optionally returned to the client as `debug` when CONCIERGE_DEBUG is on.
|
||||
*
|
||||
* The shape here is kept in sync with the pipelineTurnSchema in
|
||||
* models/WebChatSession.js. Unknown fields are dropped by Mongoose, but we
|
||||
* still avoid storing raw PII: user messages and raw LLM output are redacted
|
||||
* and truncated before they land in a preview field.
|
||||
*/
|
||||
|
||||
const EMAIL_RE = /[\w.+-]+@[\w-]+\.[\w.-]+/g;
|
||||
const PHONE_RE = /(?<!\d)(?:\+?\d[\d\s().-]{7,}\d)(?!\d)/g;
|
||||
|
||||
function isDebugEnabled() {
|
||||
const v = String(process.env.CONCIERGE_DEBUG || '').toLowerCase();
|
||||
return v === 'true' || v === '1' || v === 'yes';
|
||||
}
|
||||
|
||||
function truncate(value, max = 300) {
|
||||
const str = String(value == null ? '' : value);
|
||||
if (str.length <= max) return str;
|
||||
return `${str.slice(0, max)}…`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip obvious PII (emails, phone numbers) so pipeline logs never persist
|
||||
* a participant's contact details. Not exhaustive — a best-effort guard.
|
||||
*/
|
||||
function redactPII(text) {
|
||||
return String(text == null ? '' : text)
|
||||
.replace(EMAIL_RE, '[email]')
|
||||
.replace(PHONE_RE, '[phone]');
|
||||
}
|
||||
|
||||
function preview(value, max = 300) {
|
||||
return truncate(redactPII(value), max);
|
||||
}
|
||||
|
||||
function createTurnLog({ sessionId, botId, userMessage } = {}) {
|
||||
return {
|
||||
ts: new Date(),
|
||||
sessionId: sessionId ? String(sessionId) : null,
|
||||
botId: botId ? String(botId) : null,
|
||||
userMessagePreview: preview(userMessage, 300),
|
||||
call1: {
|
||||
skipped: false,
|
||||
path: null, // 'greeting_fast_path' | 'router_stub' | 'rules' | 'llm' | 'personal_override'
|
||||
modelId: null,
|
||||
intent: null,
|
||||
needsSecondCall: null,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
latencyMs: 0,
|
||||
rawPreview: null,
|
||||
},
|
||||
handler: {
|
||||
name: null,
|
||||
skippedCall2: false,
|
||||
cardCount: 0,
|
||||
cardTypes: [],
|
||||
latencyMs: 0,
|
||||
},
|
||||
call2: {
|
||||
skipped: true,
|
||||
reason: null,
|
||||
modelId: null,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
latencyMs: 0,
|
||||
retrievalSources: 0,
|
||||
rawPreview: null,
|
||||
},
|
||||
totals: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalTokens: 0,
|
||||
latencyMs: 0,
|
||||
},
|
||||
replyPreview: null,
|
||||
// Non-persisted wall-clock anchor; stripped in finalizeTurnLog.
|
||||
_startedAtMs: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function recordCall1(turnLog, data = {}) {
|
||||
if (!turnLog) return turnLog;
|
||||
const {
|
||||
skipped = false,
|
||||
path = null,
|
||||
modelId = null,
|
||||
intent = null,
|
||||
needsSecondCall = null,
|
||||
inputTokens = 0,
|
||||
outputTokens = 0,
|
||||
latencyMs = 0,
|
||||
raw = null,
|
||||
} = data;
|
||||
turnLog.call1 = {
|
||||
skipped: Boolean(skipped),
|
||||
path,
|
||||
modelId,
|
||||
intent,
|
||||
needsSecondCall,
|
||||
inputTokens: Number(inputTokens) || 0,
|
||||
outputTokens: Number(outputTokens) || 0,
|
||||
latencyMs: Number(latencyMs) || 0,
|
||||
rawPreview: raw == null ? null : preview(raw, 300),
|
||||
};
|
||||
return turnLog;
|
||||
}
|
||||
|
||||
function recordHandler(turnLog, data = {}) {
|
||||
if (!turnLog) return turnLog;
|
||||
const {
|
||||
name = null,
|
||||
skippedCall2 = false,
|
||||
cardCount = 0,
|
||||
cardTypes = [],
|
||||
latencyMs = 0,
|
||||
} = data;
|
||||
turnLog.handler = {
|
||||
name,
|
||||
skippedCall2: Boolean(skippedCall2),
|
||||
cardCount: Number(cardCount) || 0,
|
||||
cardTypes: Array.isArray(cardTypes) ? cardTypes.map(String) : [],
|
||||
latencyMs: Number(latencyMs) || 0,
|
||||
};
|
||||
return turnLog;
|
||||
}
|
||||
|
||||
function recordCall2(turnLog, data = {}) {
|
||||
if (!turnLog) return turnLog;
|
||||
const {
|
||||
skipped = false,
|
||||
reason = null,
|
||||
modelId = null,
|
||||
inputTokens = 0,
|
||||
outputTokens = 0,
|
||||
latencyMs = 0,
|
||||
retrievalSources = 0,
|
||||
raw = null,
|
||||
} = data;
|
||||
turnLog.call2 = {
|
||||
skipped: Boolean(skipped),
|
||||
reason,
|
||||
modelId,
|
||||
inputTokens: Number(inputTokens) || 0,
|
||||
outputTokens: Number(outputTokens) || 0,
|
||||
latencyMs: Number(latencyMs) || 0,
|
||||
retrievalSources: Number(retrievalSources) || 0,
|
||||
rawPreview: raw == null ? null : preview(raw, 300),
|
||||
};
|
||||
return turnLog;
|
||||
}
|
||||
|
||||
function finalizeTurnLog(turnLog, { replyPreview, cards } = {}) {
|
||||
if (!turnLog) return turnLog;
|
||||
|
||||
const inputTokens = (turnLog.call1?.inputTokens || 0) + (turnLog.call2?.inputTokens || 0);
|
||||
const outputTokens = (turnLog.call1?.outputTokens || 0) + (turnLog.call2?.outputTokens || 0);
|
||||
const startedAtMs = turnLog._startedAtMs || Date.now();
|
||||
|
||||
turnLog.totals = {
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
totalTokens: inputTokens + outputTokens,
|
||||
latencyMs: Math.max(0, Date.now() - startedAtMs),
|
||||
};
|
||||
|
||||
if (replyPreview != null) turnLog.replyPreview = preview(replyPreview, 400);
|
||||
|
||||
if (Array.isArray(cards) && cards.length && !turnLog.handler?.cardCount) {
|
||||
turnLog.handler = turnLog.handler || {};
|
||||
turnLog.handler.cardCount = cards.length;
|
||||
turnLog.handler.cardTypes = [...new Set(cards.map((c) => String(c?.type || 'unknown')))];
|
||||
}
|
||||
|
||||
delete turnLog._startedAtMs;
|
||||
return turnLog;
|
||||
}
|
||||
|
||||
function logTurnConsole(turnLog) {
|
||||
if (!turnLog || !isDebugEnabled()) return;
|
||||
const { call1, handler, call2, totals } = turnLog;
|
||||
console.log('[concierge][pipeline]', {
|
||||
sessionId: turnLog.sessionId,
|
||||
message: turnLog.userMessagePreview,
|
||||
call1: {
|
||||
path: call1?.path,
|
||||
skipped: call1?.skipped,
|
||||
intent: call1?.intent,
|
||||
needsSecondCall: call1?.needsSecondCall,
|
||||
tokens: `${call1?.inputTokens || 0}/${call1?.outputTokens || 0}`,
|
||||
latencyMs: call1?.latencyMs,
|
||||
},
|
||||
handler: {
|
||||
name: handler?.name,
|
||||
skippedCall2: handler?.skippedCall2,
|
||||
cards: handler?.cardCount,
|
||||
cardTypes: handler?.cardTypes,
|
||||
latencyMs: handler?.latencyMs,
|
||||
},
|
||||
call2: {
|
||||
skipped: call2?.skipped,
|
||||
reason: call2?.reason,
|
||||
tokens: `${call2?.inputTokens || 0}/${call2?.outputTokens || 0}`,
|
||||
retrievalSources: call2?.retrievalSources,
|
||||
latencyMs: call2?.latencyMs,
|
||||
},
|
||||
totals,
|
||||
reply: turnLog.replyPreview,
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isDebugEnabled,
|
||||
truncate,
|
||||
redactPII,
|
||||
createTurnLog,
|
||||
recordCall1,
|
||||
recordHandler,
|
||||
recordCall2,
|
||||
finalizeTurnLog,
|
||||
logTurnConsole,
|
||||
};
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
const {
|
||||
createTurnLog,
|
||||
recordCall1,
|
||||
recordHandler,
|
||||
recordCall2,
|
||||
finalizeTurnLog,
|
||||
redactPII,
|
||||
} = require('./conciergePipelineLog');
|
||||
|
||||
describe('conciergePipelineLog', () => {
|
||||
test('redactPII strips emails and phone numbers', () => {
|
||||
expect(redactPII('reach me at jane.doe@example.com')).toBe('reach me at [email]');
|
||||
expect(redactPII('call +1 (415) 555-2671 now')).toBe('call [phone] now');
|
||||
});
|
||||
|
||||
test('createTurnLog redacts PII in the message preview and seeds default stages', () => {
|
||||
const turnLog = createTurnLog({
|
||||
sessionId: 'sess-1',
|
||||
botId: 'bot-1',
|
||||
userMessage: 'my email is jane@example.com',
|
||||
});
|
||||
|
||||
expect(turnLog.userMessagePreview).toBe('my email is [email]');
|
||||
expect(turnLog.sessionId).toBe('sess-1');
|
||||
expect(turnLog.call1.skipped).toBe(false);
|
||||
expect(turnLog.call2.skipped).toBe(true);
|
||||
expect(typeof turnLog._startedAtMs).toBe('number');
|
||||
});
|
||||
|
||||
test('finalizeTurnLog aggregates token totals across both calls and strips the anchor', () => {
|
||||
const turnLog = createTurnLog({ sessionId: 's', botId: 'b', userMessage: 'hi' });
|
||||
recordCall1(turnLog, { path: 'llm', intent: 'event_schedule', inputTokens: 100, outputTokens: 20 });
|
||||
recordHandler(turnLog, { name: 'event_schedule', cardCount: 2, cardTypes: ['session', 'session'] });
|
||||
recordCall2(turnLog, { modelId: 'x', inputTokens: 300, outputTokens: 80, retrievalSources: 4 });
|
||||
|
||||
finalizeTurnLog(turnLog, { replyPreview: 'here is your schedule', cards: [] });
|
||||
|
||||
expect(turnLog.totals.inputTokens).toBe(400);
|
||||
expect(turnLog.totals.outputTokens).toBe(100);
|
||||
expect(turnLog.totals.totalTokens).toBe(500);
|
||||
expect(turnLog.totals.latencyMs).toBeGreaterThanOrEqual(0);
|
||||
expect(turnLog.replyPreview).toBe('here is your schedule');
|
||||
expect(turnLog._startedAtMs).toBeUndefined();
|
||||
});
|
||||
|
||||
test('finalizeTurnLog derives card summary when handler did not set one', () => {
|
||||
const turnLog = createTurnLog({ sessionId: 's', botId: 'b', userMessage: 'hi' });
|
||||
finalizeTurnLog(turnLog, {
|
||||
replyPreview: 'ok',
|
||||
cards: [{ type: 'ticket' }, { type: 'ticket' }, { type: 'speaker' }],
|
||||
});
|
||||
expect(turnLog.handler.cardCount).toBe(3);
|
||||
expect(turnLog.handler.cardTypes.sort()).toEqual(['speaker', 'ticket']);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
const { MongoClient } = require('mongodb');
|
||||
|
||||
const DEFAULT_FOOD_COLLECTION = 'concierge_food';
|
||||
const DEFAULT_PLACES_COLLECTION = 'concierge_places';
|
||||
const DEFAULT_ACCOMMODATION_COLLECTION = 'concierge_accommodation';
|
||||
|
||||
function getMongoUri() {
|
||||
return process.env.MONGODB_URI || '';
|
||||
}
|
||||
|
||||
function parseDbNameFromUri(uri) {
|
||||
if (!uri) return null;
|
||||
try {
|
||||
const normalized = uri.replace(/^mongodb(\+srv)?:\/\//, 'http://');
|
||||
const url = new URL(normalized);
|
||||
const name = url.pathname.replace(/^\//, '').split('/')[0];
|
||||
if (name) return decodeURIComponent(name);
|
||||
} catch {
|
||||
const match = String(uri).match(/mongodb(?:\+srv)?:\/\/[^/]+\/([^/?]+)/);
|
||||
if (match?.[1]) return decodeURIComponent(match[1]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getDbName() {
|
||||
const override = process.env.CONCIERGE_RECOMMENDATION_DB || process.env.MONGODB_DB_NAME;
|
||||
if (override) return override.trim();
|
||||
const parsed = parseDbNameFromUri(getMongoUri());
|
||||
if (!parsed) {
|
||||
throw new Error('Could not determine database name from MONGODB_URI');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function getFoodCollectionName() {
|
||||
return process.env.CONCIERGE_FOOD_COLLECTION || DEFAULT_FOOD_COLLECTION;
|
||||
}
|
||||
|
||||
function getPlacesCollectionName() {
|
||||
return process.env.CONCIERGE_PLACES_COLLECTION || DEFAULT_PLACES_COLLECTION;
|
||||
}
|
||||
|
||||
function getAccommodationCollectionName() {
|
||||
return process.env.CONCIERGE_ACCOMMODATION_COLLECTION || DEFAULT_ACCOMMODATION_COLLECTION;
|
||||
}
|
||||
|
||||
async function withCollection(collectionName, fn) {
|
||||
const uri = getMongoUri();
|
||||
if (!uri) {
|
||||
throw new Error('MONGODB_URI is not configured');
|
||||
}
|
||||
|
||||
const client = new MongoClient(uri);
|
||||
await client.connect();
|
||||
try {
|
||||
const coll = client.db(getDbName()).collection(collectionName);
|
||||
return await fn(coll);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertByEventId(coll, doc) {
|
||||
if (doc.eventId) {
|
||||
await coll.updateOne({ eventId: doc.eventId }, { $set: doc }, { upsert: true });
|
||||
return;
|
||||
}
|
||||
await coll.insertOne(doc);
|
||||
}
|
||||
|
||||
async function upsertFoodRecord(doc) {
|
||||
return withCollection(getFoodCollectionName(), async (coll) => {
|
||||
await upsertByEventId(coll, doc);
|
||||
});
|
||||
}
|
||||
|
||||
async function upsertPlacesRecord(doc) {
|
||||
return withCollection(getPlacesCollectionName(), async (coll) => {
|
||||
await upsertByEventId(coll, doc);
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchFoodRecordForEvent(eventId) {
|
||||
const eventIdStr = eventId != null ? String(eventId) : '';
|
||||
if (!eventIdStr || !getMongoUri()) return null;
|
||||
|
||||
return withCollection(getFoodCollectionName(), async (coll) => {
|
||||
const doc = await coll.findOne({ eventId: eventIdStr });
|
||||
if (!doc) return null;
|
||||
return {
|
||||
location: doc.location,
|
||||
recommendations: doc.recommendations,
|
||||
restaurants: doc.restaurants,
|
||||
schemaVersion: doc.schemaVersion,
|
||||
generatedAt: doc.generatedAt || doc.createdAt,
|
||||
raw: doc,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchPlacesRecordForEvent(eventId) {
|
||||
const eventIdStr = eventId != null ? String(eventId) : '';
|
||||
if (!eventIdStr || !getMongoUri()) return null;
|
||||
|
||||
return withCollection(getPlacesCollectionName(), async (coll) => {
|
||||
const doc = await coll.findOne({ eventId: eventIdStr });
|
||||
if (!doc) return null;
|
||||
return {
|
||||
location: doc.location,
|
||||
destination: doc.destination || doc.location,
|
||||
venueLocation: doc.venueLocation || doc.origin?.formattedAddress,
|
||||
searchMode: doc.searchMode,
|
||||
schemaVersion: doc.schemaVersion,
|
||||
recommendations: doc.recommendations,
|
||||
places: doc.places,
|
||||
generatedAt: doc.generatedAt || doc.createdAt,
|
||||
raw: doc,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function upsertAccommodationRecord(doc) {
|
||||
return withCollection(getAccommodationCollectionName(), async (coll) => {
|
||||
await upsertByEventId(coll, doc);
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchAccommodationRecordForEvent(eventId) {
|
||||
const eventIdStr = eventId != null ? String(eventId) : '';
|
||||
if (!eventIdStr || !getMongoUri()) return null;
|
||||
|
||||
return withCollection(getAccommodationCollectionName(), async (coll) => {
|
||||
const doc = await coll.findOne({ eventId: eventIdStr });
|
||||
if (!doc) return null;
|
||||
return {
|
||||
location: doc.location,
|
||||
recommendations: doc.recommendations,
|
||||
hotels: doc.hotels,
|
||||
generatedAt: doc.generatedAt || doc.createdAt,
|
||||
raw: doc,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_FOOD_COLLECTION,
|
||||
DEFAULT_PLACES_COLLECTION,
|
||||
DEFAULT_ACCOMMODATION_COLLECTION,
|
||||
getDbName,
|
||||
getFoodCollectionName,
|
||||
getPlacesCollectionName,
|
||||
getAccommodationCollectionName,
|
||||
upsertFoodRecord,
|
||||
upsertPlacesRecord,
|
||||
upsertAccommodationRecord,
|
||||
fetchFoodRecordForEvent,
|
||||
fetchPlacesRecordForEvent,
|
||||
fetchAccommodationRecordForEvent,
|
||||
};
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { parseTemplateSchema } = require('./templateSchema');
|
||||
const { getGlobalMicrositeV2Template } = require('../config/globalMicrositeV2Templates');
|
||||
|
||||
const BACKEND_ROOT = path.join(__dirname, '..', '..');
|
||||
|
||||
|
|
@ -39,9 +40,118 @@ function readLocalTemplateHtml(slug) {
|
|||
return fs.readFileSync(htmlPath, 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset search roots for local preview. Prefer bundled template assets first
|
||||
* (what developers edit), then optional externalAssetRoots.
|
||||
*/
|
||||
function getLocalAssetRoots(slug) {
|
||||
const roots = [];
|
||||
const dir = resolveTemplateDir(slug);
|
||||
if (dir) {
|
||||
const assets = path.join(dir, 'assets');
|
||||
if (fs.existsSync(assets)) roots.push(assets);
|
||||
}
|
||||
const def = getGlobalMicrositeV2Template(slug);
|
||||
for (const externalRel of def?.externalAssetRoots || []) {
|
||||
const externalRoot = path.isAbsolute(externalRel)
|
||||
? externalRel
|
||||
: path.join(BACKEND_ROOT, '..', externalRel);
|
||||
if (fs.existsSync(externalRoot)) roots.push(externalRoot);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function normalizeAssetRelativePath(relativePath) {
|
||||
const normalized = String(relativePath || '')
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\/+/, '')
|
||||
.split('?')[0]
|
||||
.split('#')[0];
|
||||
if (!normalized || normalized.includes('..') || path.isAbsolute(normalized)) return null;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a local asset file for a template slug. Returns absolute path or null.
|
||||
*/
|
||||
function resolveLocalAssetFile(slug, relativePath) {
|
||||
const normalized = normalizeAssetRelativePath(relativePath);
|
||||
if (!normalized) return null;
|
||||
for (const root of getLocalAssetRoots(slug)) {
|
||||
const rootResolved = path.resolve(root);
|
||||
const full = path.resolve(rootResolved, normalized);
|
||||
const prefix = rootResolved.endsWith(path.sep) ? rootResolved : `${rootResolved}${path.sep}`;
|
||||
if (!full.startsWith(prefix) && full !== rootResolved) continue;
|
||||
if (fs.existsSync(full) && fs.statSync(full).isFile()) return full;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function mimeForLocalAsset(filePath) {
|
||||
const lower = String(filePath || '').toLowerCase();
|
||||
if (lower.endsWith('.css')) return 'text/css; charset=utf-8';
|
||||
if (lower.endsWith('.js')) return 'application/javascript; charset=utf-8';
|
||||
if (lower.endsWith('.mjs')) return 'application/javascript; charset=utf-8';
|
||||
if (lower.endsWith('.map')) return 'application/json';
|
||||
if (lower.endsWith('.json')) return 'application/json';
|
||||
if (lower.endsWith('.woff')) return 'font/woff';
|
||||
if (lower.endsWith('.woff2')) return 'font/woff2';
|
||||
if (lower.endsWith('.ttf')) return 'font/ttf';
|
||||
if (lower.endsWith('.otf')) return 'font/otf';
|
||||
if (lower.endsWith('.eot')) return 'application/vnd.ms-fontobject';
|
||||
if (lower.endsWith('.svg')) return 'image/svg+xml';
|
||||
if (lower.endsWith('.png')) return 'image/png';
|
||||
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
|
||||
if (lower.endsWith('.gif')) return 'image/gif';
|
||||
if (lower.endsWith('.webp')) return 'image/webp';
|
||||
if (lower.endsWith('.ico')) return 'image/x-icon';
|
||||
if (lower.endsWith('.mp4')) return 'video/mp4';
|
||||
if (lower.endsWith('.webm')) return 'video/webm';
|
||||
if (lower.endsWith('.html') || lower.endsWith('.htm')) return 'text/html; charset=utf-8';
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
function resolveApiPublicOrigin(request) {
|
||||
const fromEnv = String(
|
||||
process.env.API_PUBLIC_ORIGIN || process.env.MICROSITE_V2_LOCAL_ASSETS_ORIGIN || '',
|
||||
).trim();
|
||||
if (fromEnv) return fromEnv.replace(/\/+$/, '');
|
||||
if (!request) return 'http://localhost:3001';
|
||||
const proto = String(
|
||||
request.headers['x-forwarded-proto'] || request.server?.info?.protocol || 'http',
|
||||
)
|
||||
.split(',')[0]
|
||||
.trim();
|
||||
const host = String(
|
||||
request.headers['x-forwarded-host'] ||
|
||||
request.headers.host ||
|
||||
`${request.server?.info?.host || 'localhost'}:${request.server?.info?.port || 3001}`,
|
||||
)
|
||||
.split(',')[0]
|
||||
.trim();
|
||||
return `${proto}://${host}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute assetsBaseUrl for preview when reading templates from disk.
|
||||
* Returns null when local mode is off or slug is missing.
|
||||
*/
|
||||
function localPreviewAssetsBaseUrl(slug, request) {
|
||||
if (!isLocalMicrositeV2TemplatesEnabled()) return null;
|
||||
const normalized = String(slug || '').trim().toLowerCase();
|
||||
if (!normalized) return null;
|
||||
const origin = resolveApiPublicOrigin(request);
|
||||
return `${origin}/api/public/microsite-v2-local/${encodeURIComponent(normalized)}/assets`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isLocalMicrositeV2TemplatesEnabled,
|
||||
getTemplatesRoot,
|
||||
readLocalTemplateSchema,
|
||||
readLocalTemplateHtml,
|
||||
getLocalAssetRoots,
|
||||
resolveLocalAssetFile,
|
||||
mimeForLocalAsset,
|
||||
resolveApiPublicOrigin,
|
||||
localPreviewAssetsBaseUrl,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -15,8 +15,13 @@ function eventBuzzPublicBase() {
|
|||
);
|
||||
}
|
||||
|
||||
/** Public web app base (registration / microsite pages). Not the console or join-code host. */
|
||||
function micrositePublicBase() {
|
||||
return (process.env.MICROSITE_PUBLIC_BASE_URL || 'https://web.eventbuzz.live').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Public dashboard form page (fallback when MICROSITE_REGISTRATION_URL_TEMPLATE is unset).
|
||||
* Public web registration/form page (fallback when MICROSITE_REGISTRATION_URL_TEMPLATE is unset).
|
||||
* @param {string} formId
|
||||
* @returns {string}
|
||||
*/
|
||||
|
|
@ -24,8 +29,8 @@ function publicFormUrl(formId, eventId) {
|
|||
const fid = formId != null ? String(formId).trim() : '';
|
||||
if (!fid) return '';
|
||||
const eid = eventId != null ? String(eventId).trim() : '';
|
||||
if (eid) return `${eventBuzzPublicBase()}/event/${eid}/form/${fid}`;
|
||||
return `${eventBuzzPublicBase()}/form/${fid}`;
|
||||
if (eid) return `${micrositePublicBase()}/event/${eid}/form/${fid}`;
|
||||
return `${micrositePublicBase()}/form/${fid}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -42,7 +47,7 @@ function registrationHref(event, website) {
|
|||
}
|
||||
const legacy = w.registrationUrl != null ? String(w.registrationUrl).trim() : '';
|
||||
if (legacy) return legacy;
|
||||
if (eid) return `${eventBuzzPublicBase()}/event/${eid}/register`;
|
||||
if (eid) return `${micrositePublicBase()}/event/${eid}/register`;
|
||||
return '';
|
||||
}
|
||||
|
||||
|
|
@ -68,7 +73,7 @@ function registrationUrlForForm(event, website, explicitFormId) {
|
|||
}
|
||||
const legacy = w.registrationUrl != null ? String(w.registrationUrl).trim() : '';
|
||||
if (legacy) return legacy;
|
||||
if (fid && eid) return `${eventBuzzPublicBase()}/event/${eid}/register`;
|
||||
if (fid && eid) return `${micrositePublicBase()}/event/${eid}/register`;
|
||||
if (fid) return publicFormUrl(fid, eid);
|
||||
return '';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
const Organization = require('../models/Organization');
|
||||
const Event = require('../models/Event');
|
||||
|
||||
/**
|
||||
* Resolve organizer/support contact for an event from its Organization.
|
||||
* @returns {Promise<{ name: string, email: string, phone: string }>}
|
||||
*/
|
||||
async function loadOrganizerContact(eventId) {
|
||||
const empty = { name: '', email: '', phone: '' };
|
||||
if (!eventId) return empty;
|
||||
try {
|
||||
const event = await Event.findById(eventId).select('organization').lean();
|
||||
if (!event?.organization) return empty;
|
||||
const org = await Organization.findById(event.organization).select('name email phone').lean();
|
||||
if (!org) return empty;
|
||||
return {
|
||||
name: String(org.name || '').trim(),
|
||||
email: String(org.email || '').trim(),
|
||||
phone: String(org.phone || '').trim(),
|
||||
};
|
||||
} catch {
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
function formatOrganizerContactLine(contact) {
|
||||
const c = contact || {};
|
||||
const bits = [];
|
||||
if (c.email) bits.push(c.email);
|
||||
if (c.phone) bits.push(c.phone);
|
||||
if (!bits.length) return '';
|
||||
const who = c.name ? `${c.name}` : 'the organizer';
|
||||
return `You can reach ${who} at ${bits.join(' or ')}.`;
|
||||
}
|
||||
|
||||
function missingOrganizerContactFallback() {
|
||||
return 'I don’t have a support email or phone on file for this event — please use your registration confirmation email or the event website to reach the organizer.';
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loadOrganizerContact,
|
||||
formatOrganizerContactLine,
|
||||
missingOrganizerContactFallback,
|
||||
};
|
||||
|
|
@ -50,16 +50,18 @@ async function loadTemplateSchema(template) {
|
|||
* @param {object} params.config
|
||||
* @param {string} [params.eventId]
|
||||
* @param {string} [params.subdomain]
|
||||
* @param {string} [params.assetsBaseUrl] Override template assets base (local preview)
|
||||
*/
|
||||
async function renderStaticMicrositeHtml({ template, config, eventId, subdomain }) {
|
||||
async function renderStaticMicrositeHtml({ template, config, eventId, subdomain, assetsBaseUrl }) {
|
||||
const tpl = template.slug ? template : await MicrositeV2Template.findById(template).lean();
|
||||
if (!tpl) throw new Error('Template not found');
|
||||
|
||||
const resolvedAssetsBaseUrl = assetsBaseUrl || tpl.assetsBaseUrl;
|
||||
const schema = await loadTemplateSchema(tpl);
|
||||
const mergedConfig = preparePublishedConfig(schema, config, tpl.assetsBaseUrl);
|
||||
const mergedConfig = preparePublishedConfig(schema, config, resolvedAssetsBaseUrl);
|
||||
const htmlSource = await loadTemplateHtml(tpl);
|
||||
const context = buildRenderContext({
|
||||
template: tpl,
|
||||
template: { ...tpl, assetsBaseUrl: resolvedAssetsBaseUrl },
|
||||
config: mergedConfig,
|
||||
eventId,
|
||||
subdomain,
|
||||
|
|
|
|||
Loading…
Reference in New Issue