From 337c166db88d6a68c60ee89190e2df69f3848064 Mon Sep 17 00:00:00 2001 From: Tanuja Pansare Date: Wed, 24 Jun 2026 17:54:05 +0530 Subject: [PATCH] Implement concierge food recommendation pipeline --- public/embed/concierge-widget.js | 5 + src/controllers/publicConcierge.controller.js | 54 +++- src/services/conciergePipeline.service.js | 278 ++++++++++++++++++ src/services/conciergeRag.service.js | 22 +- src/services/foodDb.service.js | 55 ++++ src/services/foodRecommendationService.js | 25 +- src/utils/bedrockClient.js | 8 +- src/utils/bedrockConverse.js | 33 ++- src/utils/conciergeGreeting.js | 19 ++ src/utils/conciergeIntent.js | 207 +++++++++++++ 10 files changed, 675 insertions(+), 31 deletions(-) create mode 100644 src/services/conciergePipeline.service.js create mode 100644 src/services/foodDb.service.js create mode 100644 src/utils/conciergeGreeting.js create mode 100644 src/utils/conciergeIntent.js diff --git a/public/embed/concierge-widget.js b/public/embed/concierge-widget.js index dabfa38..31d2a0f 100644 --- a/public/embed/concierge-widget.js +++ b/public/embed/concierge-widget.js @@ -124,6 +124,11 @@ try { localStorage.setItem(storageKey, sessionId); } catch {} } addMessage('assistant', (res.json.data && res.json.data.reply) ? res.json.data.reply : '…'); + // Log detected intent to browser console when backend returns it + if (res.json.data && res.json.data.intent) { + // eslint-disable-next-line no-console + console.info('[EventBuzz Concierge] detected intent:', res.json.data.intent); + } }).catch(function (e) { sendBtn.disabled = false; setError(e && e.message ? e.message : 'Network error'); diff --git a/src/controllers/publicConcierge.controller.js b/src/controllers/publicConcierge.controller.js index e84eeda..fc62545 100644 --- a/src/controllers/publicConcierge.controller.js +++ b/src/controllers/publicConcierge.controller.js @@ -4,7 +4,8 @@ const ConciergeBot = require('../models/ConciergeBot'); const Event = require('../models/Event'); const WebChatSession = require('../models/WebChatSession'); const { success, error } = require('../utils/response'); -const { executeConciergeRag } = require('../services/conciergeRag.service'); +const { runConciergePipeline } = require('../services/conciergePipeline.service'); + const MAX_MESSAGE_LENGTH = 800; const MAX_HISTORY_MESSAGES = 40; @@ -99,36 +100,63 @@ const chat = async (request, h) => { const includeEventDetails = bot.knowledge?.includeEventDetails !== false; const includeFoodRecommendations = bot.knowledge?.includeFoodRecommendations !== false; - const ragResult = await executeConciergeRag(String(bot.event), msg, event?.name || 'Event', { - includeEventDetails, - includeFoodRecommendations, - history: historyForPrompt, - }); - const answerText = - ragResult?.text || - "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 last6ForIntent = session.history.slice(-6).map((m) => ({ + role: m.role, + text: m.text, + })); + + const intentResult = 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 })), + }); + + const answerText = pipelineResult?.text || "I don't have that in my knowledge base yet. Please check with event staff or try another question."; + const reply = String(answerText || '').trim().slice(0, 1200); - const inTok = ragResult?.usage?.inputTokens || 0; - const outTok = ragResult?.usage?.outputTokens || 0; + const inTok = pipelineResult?.usage?.inputTokens || 0; + const outTok = pipelineResult?.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: ragResult?.modelId || null, + modelId: pipelineResult?.modelId || null, inputTokens: inTok, outputTokens: outTok, }); + if (session.history.length > MAX_HISTORY_MESSAGES) { session.history = session.history.slice(-MAX_HISTORY_MESSAGES); } await session.save(); - return h.response(success({ sessionId, reply, usage: ragResult?.usage || null })); + return h.response(success({ sessionId, reply, usage: pipelineResult?.usage || null, intent: intentCategory })); }; + module.exports = { config, chat, diff --git a/src/services/conciergePipeline.service.js b/src/services/conciergePipeline.service.js new file mode 100644 index 0000000..e0829ce --- /dev/null +++ b/src/services/conciergePipeline.service.js @@ -0,0 +1,278 @@ +const { executeConciergeRag } = require('./conciergeRag.service'); +const { determineIntentCategory } = require('../utils/conciergeIntent'); +const { getFoodRecommendations } = require('./foodRecommendationService'); +const { fetchFoodRecordForEvent } = require('./foodDb.service'); + +/** + * Pipeline: classify intent -> fetch data -> generate response via LLM. + * + * Intent categories: + * - Event Information + * - Personal Information + * - Food + * - Places + * - unrecognized + */ + +async function runConciergePipeline({ eventId, eventName, bot, sessionHistory }) { + const historyForIntent = Array.isArray(sessionHistory) ? sessionHistory.slice(-10) : []; + + // IMPORTANT: Always use the latest user message in the current request. + const userQuestion = historyForIntent.slice(-1)?.[0]?.text || ''; + + const intentResult = await determineIntentCategory( + historyForIntent.map((m) => ({ role: m.role, text: m.text })), + ); + + const intentCategory = intentResult?.category || 'unrecognized'; + + const includeFoodRecommendations = bot?.knowledge?.includeFoodRecommendations !== false; + const historyForRag = Array.isArray(sessionHistory) + ? sessionHistory.slice(-12).map((m) => ({ role: m.role, text: m.text })) + : []; + + // 1) Fetch data for the intent (Food uses external DB, others are blank for now) + let dataBlock = ''; + + if (intentCategory === 'Food') { + const existing = await fetchFoodRecordForEvent(eventId); + + const isExpired = + existing?.generatedAt && + Date.now() - new Date(existing.generatedAt).getTime() > 24 * 60 * 60 * 1000; + + if (existing?.recommendations && !isExpired) { + dataBlock = `Food knowledge (ground truth / fetched):\n${existing.recommendations}`; + + // Add structured restaurant/place records (names + cuisines + review snippets) + // so the matching logic can reference persisted dataset entries directly. + if (Array.isArray(existing.restaurants) && existing.restaurants.length) { + const placesBlock = existing.restaurants + .map((p, i) => { + const cuisines = Array.isArray(p.cuisines) + ? p.cuisines.join(', ') + : p.cuisines || ''; + const topReviews = Array.isArray(p.reviews) ? p.reviews.slice(0, 2) : []; + + const reviewsText = topReviews + .map((r) => `- ${r.author_name} (${r.rating || 'N/A'}): ${r.summary || r.text || ''}`) + .join('\n'); + + return `\n${i + 1}) ${p.name}\n Cuisines: ${cuisines || 'N/A'}\n Rating: ${p.rating || 'N/A'} (${p.user_ratings_total || 0} reviews)\n Address: ${p.address || 'N/A'}\n Reviews (up to 2):\n${reviewsText || '- N/A'}\n`; + }) + .join(''); + + dataBlock += `\n\nFood places records (use ONLY these names when recommending):\n${placesBlock}`; + } + } else { + // Best-effort location string; foodRecommendationService will geocode & persist. + const location = bot?.eventLocation || bot?.location || ''; + const foodText = location + ? await getFoodRecommendations(location, eventId) + : await getFoodRecommendations('', eventId); + dataBlock = `Food knowledge (ground truth / fetched):\n${foodText}`; + } + } + + // 2) Generate response + if (intentCategory === 'Food') { + const questionLower = String(userQuestion || '').toLowerCase(); + + // Minimal dish-key extraction for deterministic matching. + // This is intentionally conservative: only match dishes we can reliably ground from DB context. + // For broad queries like "South Indian breakfast", we map to common dish keywords. + const dishKeys = []; + + const pushUnique = (k) => { + if (!k) return; + if (!dishKeys.includes(k)) dishKeys.push(k); + }; + + // Misal + if (questionLower.includes('misal')) pushUnique('misal'); + + // Pav bhaji / bhaji + if (questionLower.includes('pav bhaji') || questionLower.includes('bhaji')) pushUnique('pav bhaji'); + + // Biryani + if (questionLower.includes('biryani')) pushUnique('biryani'); + + // South Indian breakfast / common South Indian dishes + if (questionLower.includes('idli')) pushUnique('idli'); + if (questionLower.includes('dosa')) pushUnique('dosa'); + if (questionLower.includes('vada') || questionLower.includes('vada pav')) pushUnique('vada'); + if (questionLower.includes('vada pav')) pushUnique('vada pav'); + if (questionLower.includes('uttapam')) pushUnique('uttapam'); + if (questionLower.includes('sambar')) pushUnique('sambar'); + if (questionLower.includes('chutney')) pushUnique('chutney'); + if (questionLower.includes('upma')) pushUnique('upma'); + if (questionLower.includes('pongal')) pushUnique('pongal'); + + // If explicitly asked for South Indian breakfast but no specific dish keywords are present, + // still attempt a deterministic search using a small set of very common keywords. + if (dishKeys.length === 0 && /south\s*indian/.test(questionLower)) { + ['idli', 'dosa', 'vada', 'uttapam', 'sambar'].forEach(pushUnique); + } + + const dishKey = dishKeys.length === 1 ? dishKeys[0] : null; + + // Deterministic dish->place+review matching using the persisted DB-context text. + // IMPORTANT: Create human-like review bullets by removing the reviewer name. + // Example input stored format: + // "- Questraveler Questraveler (4): Perfect place ..." + // Output bullet should be: + // "- (4): Perfect place ..." + if (dishKey) { + const matches = []; + + const blockStart = String(dataBlock || '').indexOf('Food places records'); + if (blockStart !== -1) { + const placesSection = String(dataBlock || '').slice(blockStart); + const placeChunks = placesSection.split(/\n\s*\d+\)\s+/).slice(1); + + const dishRegex = new RegExp( + `\\b${dishKey.replace(/[-/\\^$*+?.()|[\\]{}]/g, '\\$&')}\\b`, + 'i', + ); + + for (const chunk of placeChunks) { + const chunkText = String(chunk || ''); + const nameLine = chunkText.split('\n')[0] || ''; + const placeName = nameLine.trim(); + if (!placeName) continue; + + if (dishRegex.test(chunkText)) { + const reviewLines = chunkText + .split('\n') + .filter((l) => dishRegex.test(l)) + .slice(0, 2); + + matches.push({ placeName, reviewSnippets: reviewLines }); + } + } + } + + if (matches.length) { + const parts = matches.map((m) => { + const reviews = !m.reviewSnippets.length + ? '- (review snippet not available)' + : m.reviewSnippets + .map((s) => String(s || '').trim()) + .filter(Boolean) + .map((line) => { + // strip leading "- (): " + // keep "- (): " or similar. + // Works for common patterns: "- Name (4): text" + const cleaned = line + .replace(/^[-\s]*[^:]+\(([^)]*)\):\s*/g, '($1): ') + .replace(/^[-\s]*[^:]+:\s*/g, ''); + + // Ensure bullet format + return `- ${cleaned.replace(/^[-\s]*/, '').trim()}`; + }) + .join('\n'); + + return `• ${m.placeName}\n${reviews}`; + }); + + return { + intent: intentCategory, + text: `🍽 Best match for ${userQuestion.trim()}\n\n${parts.join('\n\n')}`, + usage: null, + modelId: null, + }; + } + } + + // If dish-specific matching didn’t find anything, fall back to LLM using only context. + // (LLM fallback prompt should avoid refusal phrasing.) + const responseModelId = 'meta.llama3-8b-instruct-v1:0'; + + const { createBedrockClient, getBedrockConfig } = require('../utils/bedrockClient'); + const { converse, responseText } = require('../utils/bedrockConverse'); + + const { region } = getBedrockConfig(); + const client = createBedrockClient(region); + + const foodSystemPrompt = `You are a food concierge for an event assistant. + +You will be given a food recommendations context. +Your job is to answer the CURRENT USER QUESTION using ONLY information from the context. + +GROUNDING RULES (MUST FOLLOW): +1) Do NOT invent restaurants, dishes, or addresses. +2) Only recommend dishes/foods or restaurants that appear in the provided context. + +If the exact dish/restaurant requested is NOT clearly present in the context, do NOT say a refusal sentence. Instead: +- Suggest the closest dishes/foods that ARE present in the provided context. +- If you can't find any relevant dish from the context, say you don't have relevant recommendations from the provided list. + +Return ONLY plain text (no JSON).`; + + const userText = `CURRENT USER QUESTION:\n${userQuestion}\n\nFOOD RECOMMENDATIONS CONTEXT (do not invent beyond this):\n${dataBlock}`; + + try { + const response = await converse(client, responseModelId, { + system: [{ text: foodSystemPrompt }], + messages: [{ role: 'user', content: [{ text: userText }] }], + inferenceConfig: { + temperature: 0.4, + topP: 0.9, + maxTokens: 500, + }, + }); + + return { + intent: intentCategory, + text: responseText(response).trim(), + usage: null, + modelId: responseModelId, + }; + } catch (e) { + console.warn('[pipeline][food] Bedrock failed; falling back to deterministic formatting:', e?.message || e); + } + + return { + intent: intentCategory, + text: `🍽 Food suggestions are currently unavailable for this event.`, + usage: null, + modelId: null, + }; + } + + if ( + intentCategory === 'Event Information' || + intentCategory === 'Personal Information' || + intentCategory === 'Places' + ) { + const includeEventDetails = bot?.knowledge?.includeEventDetails !== false; + + const ragResult = await executeConciergeRag(eventId, userQuestion, eventName, { + includeEventDetails, + includeFoodRecommendations, + history: historyForRag, + promptIntent: intentCategory, + }); + + return { + intent: intentCategory, + text: + ragResult?.text || + "I don't have that in my knowledge base yet. Please check with event staff or try another question.", + usage: ragResult?.usage || null, + modelId: ragResult?.modelId || null, + }; + } + + return { + intent: intentCategory, + text: 'Hi! Tell me what you’re looking for (food, directions, schedule, or your ticket/check-in).', + usage: null, + modelId: null, + }; +} + +module.exports = { + runConciergePipeline, +}; + diff --git a/src/services/conciergeRag.service.js b/src/services/conciergeRag.service.js index 88d60f4..c6a58f5 100644 --- a/src/services/conciergeRag.service.js +++ b/src/services/conciergeRag.service.js @@ -88,9 +88,26 @@ async function executeConciergeRag(eventId, userQuery, eventName, options = {}) chunkFilter.sourceId = { $nin: excludedSourceIds }; } + // Debug: confirm why RAG can short-circuit before reaching the LLM. + // (Keep this log lightweight; remove later if noisy.) + try { + const totalActiveForEvent = await ContextChunk.countDocuments({ eventId, isActive: true }); + const matchingChunksCount = await ContextChunk.countDocuments(chunkFilter); + console.log('[conciergeRag][debug] RAG chunk gating', { + eventId, + isActive: true, + embedModelId, + totalActiveForEvent, + matchingChunksCount, + }); + } catch (e) { + console.warn('[conciergeRag][debug] chunk gating log failed:', e?.message || e); + } + const activeChunks = await ContextChunk.find(chunkFilter).lean(); if (!activeChunks.length) return null; + const ranked = activeChunks .map((chunk) => ({ text: chunk.rawText, score: cosineSimilarity(queryEmbedding, chunk.embedding) })) .sort((a, b) => b.score - a.score) @@ -99,7 +116,10 @@ async function executeConciergeRag(eventId, userQuery, eventName, options = {}) const contextBlock = ranked.map((c) => c.text).join('\n\n'); const { region, modelId: configuredId } = getBedrockConfig(); const client = createBedrockClient(region); - const system = buildConciergeSystemPrompt(eventName); + // Pass intent/category so the concierge system prompt can be specialized. + const system = buildConciergeSystemPrompt(eventName, options.promptIntent || 'general'); + + const eventFacts = await buildEventFactsBlock(eventId); const historyBlock = history diff --git a/src/services/foodDb.service.js b/src/services/foodDb.service.js new file mode 100644 index 0000000..3b984ab --- /dev/null +++ b/src/services/foodDb.service.js @@ -0,0 +1,55 @@ +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: } + const doc = await coll.findOne({ eventId: eventIdStr }); + if (!doc) return null; + + return { + location: doc.location, + recommendations: doc.recommendations, + restaurants: doc.restaurants, + raw: doc, + }; + } finally { + await client.close(); + } +} + +module.exports = { + fetchFoodRecordForEvent, +}; + diff --git a/src/services/foodRecommendationService.js b/src/services/foodRecommendationService.js index ea066d1..a5345d8 100644 --- a/src/services/foodRecommendationService.js +++ b/src/services/foodRecommendationService.js @@ -249,14 +249,23 @@ async function searchFoodPlaces( phone_number: details.formatted_phone_number || details.international_phone_number, - reviews: - (details.reviews || []).map((r) => ({ - author_name: r.author_name, - rating: r.rating, - relative_time_description: - r.relative_time_description, - text: r.text, - })), + reviewsSummary: + (details.reviews || []) + .map((r) => { + const text = r.text; + const summary = String(text || '') + .replace(/\s+/g, ' ') + .trim() + .split(/[.!?]/)[0] + .trim(); + + const s = summary || String(text || '').slice(0, 120); + const rating = r.rating != null ? ` (${r.rating})` : ''; + return `${s}${rating}`; + }) + .filter(Boolean) + .slice(0, 8) + .join(' • '), cuisines: parseCuisine(types), delivery: types.includes("meal_delivery"), diff --git a/src/utils/bedrockClient.js b/src/utils/bedrockClient.js index c1a9e61..aa1fe58 100644 --- a/src/utils/bedrockClient.js +++ b/src/utils/bedrockClient.js @@ -1,13 +1,15 @@ const { BedrockRuntimeClient } = require('@aws-sdk/client-bedrock-runtime'); -const DEFAULT_BEDROCK_MODEL_ID = 'meta.llama3-8b-instruct-v1:0'; +const DEFAULT_BEDROCK_MODEL_ID = 'amazon.nova-lite-v1:0'; + + const DEFAULT_BEDROCK_EMBED_MODEL_ID = 'amazon.titan-embed-text-v2:0'; const REGION_FALLBACK_MODEL_IDS = [ - 'meta.llama3-8b-instruct-v1:0', - 'meta.llama3-70b-instruct-v1:0', + 'amazon.nova-micro-v1:0', ]; + const REGION_FALLBACK_EMBED_MODEL_IDS = [ 'amazon.titan-embed-text-v2:0', 'amazon.titan-embed-text-v1:0', diff --git a/src/utils/bedrockConverse.js b/src/utils/bedrockConverse.js index babaaa6..dddd313 100644 --- a/src/utils/bedrockConverse.js +++ b/src/utils/bedrockConverse.js @@ -16,17 +16,38 @@ function responseText(response) { */ async function converse(client, modelId, opts) { const { system, messages, inferenceConfig } = opts; + + // Allow tuning for different models/environments. + // Defaults preserve existing behavior if env vars are not set. + const temperatureEnv = process.env.BEDROCK_TEMPERATURE; + const topPEnv = process.env.BEDROCK_TOP_P; + + const temperature = + temperatureEnv != null && temperatureEnv !== '' + ? Number(temperatureEnv) + : 0.3; + + const topP = topPEnv != null && topPEnv !== '' ? Number(topPEnv) : 0.9; + + const mergedInferenceConfig = { + maxTokens: 2048, + temperature: Number.isFinite(temperature) ? temperature : 0.3, + topP: Number.isFinite(topP) ? topP : 0.9, + ...inferenceConfig, + }; + + console.log('[bedrock][converse] modelId=', modelId, { + temperature: mergedInferenceConfig.temperature, + topP: mergedInferenceConfig.topP, + maxTokens: mergedInferenceConfig.maxTokens, + }); + return client.send( new ConverseCommand({ modelId, ...(system && system.length ? { system } : {}), messages, - inferenceConfig: { - maxTokens: 2048, - temperature: 0.3, - topP: 0.9, - ...inferenceConfig, - }, + inferenceConfig: mergedInferenceConfig, }), ); } diff --git a/src/utils/conciergeGreeting.js b/src/utils/conciergeGreeting.js new file mode 100644 index 0000000..1725d8c --- /dev/null +++ b/src/utils/conciergeGreeting.js @@ -0,0 +1,19 @@ +function interpolateConciergeGreeting(template, values = {}) { + const greeting = String(template || '').trim(); + if (!greeting) { + const profileName = values.profileName || 'Guest'; + const eventName = values.eventName ? ` for ${values.eventName}` : ''; + return `Hello ${profileName}${eventName}! How can I help you today?`; + } + + return greeting.replace(/{{\s*(\w+)\s*}}/g, (_, key) => { + if (Object.prototype.hasOwnProperty.call(values, key)) { + return String(values[key] ?? ''); + } + return ''; + }); +} + +module.exports = { + interpolateConciergeGreeting, +}; diff --git a/src/utils/conciergeIntent.js b/src/utils/conciergeIntent.js new file mode 100644 index 0000000..d2689b3 --- /dev/null +++ b/src/utils/conciergeIntent.js @@ -0,0 +1,207 @@ +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(); + + // PERSONAL + if ( + /\b(my|am i|me)\b/.test(text) && + /\b(ticket|badge|registration|registered|qr|check.?in|seat|profile)\b/.test(text) + ) { + return { category: 'Personal Information', databaseSearchNeeded: true }; + } + + // EVENT + if ( + /\b(session|sessions|schedule|agenda|speaker|speakers|workshop|workshops|keynote|program)\b/.test( + text, + ) + ) { + return { category: 'Event Information', databaseSearchNeeded: true }; + } + + // FOOD + if ( + /\b(food|eat|restaurant|cafe|vendor|menu|breakfast|lunch|dinner|coffee|tea|vegetarian|vegan)\b/.test( + text, + ) + ) { + return { category: 'Food', databaseSearchNeeded: true }; + } + + // PLACES + if ( + /\b(hall|room|booth|parking|restroom|bathroom|gate|entrance|exit|directions|map|stage)\b/.test( + text, + ) + ) { + return { category: 'Places', 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, my ticket, badge, QR code, check-in, attendee profile +- Food: food, meals, restaurants, cafes, vendors, dietary preferences +- Places: halls, rooms, parking, restrooms, directions, booths +- unrecognized: greetings, compliments, or unrelated requests + +Rules: +- If the message is about the user themselves (ticket/badge/QR/check-in/profile), use Personal Information. +- If the message is about what/when/where inside the event (schedule, speakers, sessions, workshops, agenda, program), use Event Information. +- If the message asks what to eat or where to eat, use Food. +- If the message asks for navigation inside the venue (directions/maps/where is X room/stage/booth), use Places. + +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, +}; +