From 8fdbe2bf89d5f2924e8618e9f232d08bc2a5f2ab Mon Sep 17 00:00:00 2001 From: Tanuja Pansare Date: Wed, 10 Jun 2026 10:38:26 +0530 Subject: [PATCH 1/2] Add food recommendation features --- .env.example | 4 +- README.md | 2 +- package-lock.json | 2 +- package.json | 14 +- scripts/check-food-sources.js | 18 + scripts/create-food-sources.js | 39 ++ scripts/list-sources.js | 20 + scripts/run-ingest-single.js | 17 + scripts/show-food-places.js | 42 ++ src/controllers/conciergeBot.controller.js | 104 ++- src/controllers/publicConcierge.controller.js | 2 + src/models/ConciergeBot.js | 1 + src/models/KnowledgeSource.js | 2 +- src/routes/conciergeBot.routes.js | 2 + src/services/conciergeRag.service.js | 13 +- src/services/foodRecommendationService.js | 614 ++++++++++++++++++ src/utils/conciergeIngest.js | 27 + 17 files changed, 899 insertions(+), 24 deletions(-) create mode 100644 scripts/check-food-sources.js create mode 100644 scripts/create-food-sources.js create mode 100644 scripts/list-sources.js create mode 100644 scripts/run-ingest-single.js create mode 100644 scripts/show-food-places.js create mode 100644 src/services/foodRecommendationService.js diff --git a/.env.example b/.env.example index b1a6fe4..642e4cd 100644 --- a/.env.example +++ b/.env.example @@ -36,8 +36,8 @@ AWS_URL=https://did94hhhcgq1d.cloudfront.net # Amazon Bedrock (AI event generation). Model must be enabled in your AWS account for this region. # BEDROCK_MODEL_ID=anthropic.claude-3-haiku-20240307-v1:0 -# Public API base URL injected into published microsite HTML for contact forms (e.g. https://api.example.com/api or http://localhost:3001/api) -MICROSITE_API_BASE_URL=http://localhost:3001/api +# Public API base URL injected into published microsite HTML for contact forms (e.g. https://api.example.com/api or http://localhost:3002/api) +MICROSITE_API_BASE_URL=http://localhost:3002/api # Firebase Realtime Database (optional — quiz/poll sync). Paths relative to backend/ when starting the API from backend/ FIREBASE_DATABASE_URL=https://eventbuzz-live-default-rtdb.firebaseio.com diff --git a/README.md b/README.md index b7aecc3..e407fbe 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Node.js backend for EventBuzz built with **Hapi.js** and **MongoDB** (Mongoose), ```bash npm run dev ``` - Server runs at `http://localhost:3001` (or `PORT` from `.env`). + Server runs at `http://localhost:3002` (or `PORT` from `.env`). 4. **Multi-tenant backfill (after pulling schema that requires org on forms/speakers)** Run once against your database before restart if you have legacy rows: diff --git a/package-lock.json b/package-lock.json index 93aa9a3..0a3fb7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "bcrypt": "^5.1.1", "bullmq": "^5.34.5", "cheerio": "^1.0.0", - "dotenv": "^16.3.1", + "dotenv": "^16.6.1", "firebase-admin": "^13.0.0", "google-auth-library": "^9.14.0", "hapi-auth-jwt2": "^10.2.0", diff --git a/package.json b/package.json index 9857f82..6dd4041 100644 --- a/package.json +++ b/package.json @@ -18,30 +18,30 @@ "badge-batch-worker": "node src/workers/badgeBatchWorker.js" }, "dependencies": { - "@napi-rs/canvas": "^0.1.56", - "archiver": "^7.0.1", "@aws-sdk/client-bedrock-runtime": "^3.1030.0", "@aws-sdk/client-s3": "^3.1012.0", "@aws-sdk/lib-storage": "^3.1012.0", "@hapi/hapi": "^21.3.0", + "@napi-rs/canvas": "^0.1.56", + "archiver": "^7.0.1", "bcrypt": "^5.1.1", "bullmq": "^5.34.5", "cheerio": "^1.0.0", - "ioredis": "^5.4.2", - "mathjs": "^14.0.1", - "pdf-parse": "^1.1.1", - "dotenv": "^16.3.1", + "dotenv": "^16.6.1", "firebase-admin": "^13.0.0", "google-auth-library": "^9.14.0", "hapi-auth-jwt2": "^10.2.0", + "ioredis": "^5.4.2", "joi": "^17.13.0", "jsonwebtoken": "^9.0.2", + "mathjs": "^14.0.1", "mongoose": "^8.0.0", - "pdf-lib": "^1.17.1", "node-cron": "^3.0.3", "nodemailer": "^6.9.7", "officeparser": "^5.1.1", "otplib": "^12.0.1", + "pdf-lib": "^1.17.1", + "pdf-parse": "^1.1.1", "qrcode": "^1.5.4", "razorpay": "^2.9.6", "sharp": "^0.33.5", diff --git a/scripts/check-food-sources.js b/scripts/check-food-sources.js new file mode 100644 index 0000000..befce5d --- /dev/null +++ b/scripts/check-food-sources.js @@ -0,0 +1,18 @@ +require('dotenv').config(); +const mongoose = require('mongoose'); + +async function main() { + const mongoUrl = process.env.MONGODB_URI || process.env.MONGO_URL || process.env.MONGO_URI || 'mongodb://localhost:27017/eventbuzz'; + await mongoose.connect(mongoUrl, { useNewUrlParser: true, useUnifiedTopology: true }); + console.log('Connected to', mongoUrl); + const KnowledgeSource = require('../src/models/KnowledgeSource'); + const docs = await KnowledgeSource.find({ type: 'food' }).lean(); + console.log('Found', docs.length, 'food sources'); + console.log(JSON.stringify(docs, null, 2)); + await mongoose.disconnect(); +} + +main().catch((err) => { + console.error('Error', err); + process.exit(1); +}); diff --git a/scripts/create-food-sources.js b/scripts/create-food-sources.js new file mode 100644 index 0000000..6a5a558 --- /dev/null +++ b/scripts/create-food-sources.js @@ -0,0 +1,39 @@ +require('dotenv').config(); +const mongoose = require('mongoose'); + +async function main() { + const mongoUrl = process.env.MONGODB_URI || process.env.MONGO_URL || process.env.MONGO_URI || 'mongodb://localhost:27017/eventbuzz'; + await mongoose.connect(mongoUrl, { useNewUrlParser: true, useUnifiedTopology: true }); + console.log('Connected to', mongoUrl); + + const ConciergeBot = require('../src/models/ConciergeBot'); + const KnowledgeSource = require('../src/models/KnowledgeSource'); + + const bots = await ConciergeBot.find({}).lean(); + console.log('Found', bots.length, 'chatbots'); + for (const b of bots) { + const eventId = b.event && b.event._id ? b.event._id : b.event; + if (!eventId) continue; + const existing = await KnowledgeSource.findOne({ eventId, type: 'food' }).lean(); + if (existing) { + console.log('Bot', b._id, 'already has food source', existing._id); + continue; + } + const doc = { + eventId, + type: 'food', + sourcePath: String(eventId), + displayName: 'Food Recommendations', + status: 'PENDING', + }; + const created = await KnowledgeSource.create(doc); + console.log('Created food source for bot', b._id, 'sourceId', created._id); + } + + await mongoose.disconnect(); +} + +main().catch((err) => { + console.error('Error', err); + process.exit(1); +}); diff --git a/scripts/list-sources.js b/scripts/list-sources.js new file mode 100644 index 0000000..d8bd383 --- /dev/null +++ b/scripts/list-sources.js @@ -0,0 +1,20 @@ +require('dotenv').config(); +const mongoose = require('mongoose'); + +async function main() { + const mongoUrl = process.env.MONGODB_URI || process.env.MONGO_URL || process.env.MONGO_URI || 'mongodb://localhost:27017/eventbuzz'; + await mongoose.connect(mongoUrl, { useNewUrlParser: true, useUnifiedTopology: true }); + console.log('Connected to', mongoUrl); + const KnowledgeSource = require('../src/models/KnowledgeSource'); + const docs = await KnowledgeSource.find({}).sort({ eventId: 1, type: 1 }).lean(); + console.log('Total sources:', docs.length); + for (const d of docs) { + console.log(JSON.stringify({ _id: String(d._id), eventId: String(d.eventId), type: d.type, displayName: d.displayName, status: d.status }, null, 2)); + } + await mongoose.disconnect(); +} + +main().catch((err) => { + console.error('Error', err); + process.exit(1); +}); diff --git a/scripts/run-ingest-single.js b/scripts/run-ingest-single.js new file mode 100644 index 0000000..d6ebabb --- /dev/null +++ b/scripts/run-ingest-single.js @@ -0,0 +1,17 @@ +require('dotenv').config(); +(async () => { + const id = process.argv[2]; + if (!id) { + console.error('Usage: node run-ingest-single.js '); + process.exit(2); + } + try { + const { runConciergeIngest } = require('../src/utils/conciergeIngest'); + console.log('Running ingest for', id); + const res = await runConciergeIngest(id); + console.log('Ingest result:', res); + } catch (e) { + console.error('Ingest error:', e); + process.exit(1); + } +})(); diff --git a/scripts/show-food-places.js b/scripts/show-food-places.js new file mode 100644 index 0000000..0b83030 --- /dev/null +++ b/scripts/show-food-places.js @@ -0,0 +1,42 @@ +require('dotenv').config(); +const { MongoClient } = require('mongodb'); + +(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' }); + + if (!doc) { + console.log('No food document found'); + await client.close(); + 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'); + 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(` 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(''); + }); + + await client.close(); + } catch (e) { + console.error('Error:', e.message); + process.exit(1); + } +})(); diff --git a/src/controllers/conciergeBot.controller.js b/src/controllers/conciergeBot.controller.js index 0f86ee9..e43873e 100644 --- a/src/controllers/conciergeBot.controller.js +++ b/src/controllers/conciergeBot.controller.js @@ -288,6 +288,7 @@ const deleteSource = async (request, h) => { }; const syncSource = async (request, h) => { + console.log('[concierge] syncSource called', { params: request.params, payload: request.payload }); const { bot, err } = await getBotForUser(request.params.botId, request.auth.credentials.user); if (err) return h.response(error(err.code, err.message)).code(err.status); const eventId = bot.event._id || bot.event; @@ -295,6 +296,7 @@ const syncSource = async (request, h) => { _id: request.params.sourceId, eventId, }); + console.log('[concierge] syncSource resolved source', source && { _id: source._id, type: source.type, displayName: source.displayName }); if (!source) return h.response(error('NOT_FOUND', 'Source not found')).code(404); source.status = 'PENDING'; source.errorMessage = null; @@ -305,7 +307,16 @@ const syncSource = async (request, h) => { eventId: String(eventId), type: source.type, }); - return h.response(success(source, 'Sync queued')); + + try { + const { runConciergeIngest } = require('../utils/conciergeIngest'); + await runConciergeIngest(String(source._id)); + const refreshed = await KnowledgeSource.findById(source._id).lean(); + return h.response(success(refreshed, 'Sync completed')); + } catch (e) { + console.warn('[concierge] syncSource immediate ingest failed:', e.message || e); + return h.response(success(source, 'Sync queued')); + } }; const listChunks = async (request, h) => { @@ -378,6 +389,7 @@ const clearKnowledge = async (request, h) => { }; const syncEventKnowledge = async (request, h) => { + console.log('[concierge] syncEventKnowledge called', { params: request.params }); const { bot, err } = await getBotForUser(request.params.botId, request.auth.credentials.user); if (err) return h.response(error(err.code, err.message)).code(err.status); if (bot.knowledge?.includeEventDetails === false) { @@ -392,31 +404,102 @@ const syncEventKnowledge = async (request, h) => { } const eventId = bot.event._id || bot.event; const programmeDisplayName = 'Event programme & links'; - let source = await KnowledgeSource.findOne({ eventId, type: 'event' }); + + async function ensureSource(type, displayName) { + let source = await KnowledgeSource.findOne({ eventId, type }); + if (!source) { + source = await KnowledgeSource.create({ + eventId, + type, + sourcePath: String(eventId), + displayName, + status: 'PENDING', + }); + } else { + source.status = 'PENDING'; + source.errorMessage = null; + source.displayName = displayName; + await source.save(); + } + + await enqueueConciergeIngest({ + sourceId: String(source._id), + botId: String(bot._id), + eventId: String(eventId), + type, + }); + return source; + } + + const eventSource = await ensureSource('event', programmeDisplayName); + + bot.knowledge = bot.knowledge || {}; + bot.knowledge.lastEventSnapshotAt = new Date(); + + if (bot.knowledge?.includeFoodRecommendations !== false) { + await ensureSource('food', 'Food Recommendations'); + bot.knowledge.lastFoodSnapshotAt = new Date(); + } + + await bot.save(); + return h.response(success(eventSource, 'Event programme sync queued')); +}; + +const syncFoodKnowledge = async (request, h) => { + console.log('[concierge] syncFoodKnowledge called', { params: request.params }); + const { bot, err } = await getBotForUser( + request.params.botId, + request.auth.credentials.user + ); + if (err) { + return h.response(error(err.code, err.message)).code(err.status); + } + if (bot.knowledge?.includeFoodRecommendations === false) { + return h.response( + error('VALIDATION_ERROR', 'Food recommendations are disabled'), + ).code(400); + } + + const eventId = bot.event._id || bot.event; + + let source = await KnowledgeSource.findOne({ + eventId, + type: 'food', + }); + if (!source) { source = await KnowledgeSource.create({ eventId, - type: 'event', + type: 'food', sourcePath: String(eventId), - displayName: programmeDisplayName, + displayName: 'Food Recommendations', status: 'PENDING', }); } else { source.status = 'PENDING'; source.errorMessage = null; - source.displayName = programmeDisplayName; + source.displayName = 'Food Recommendations'; await source.save(); } - bot.knowledge = bot.knowledge || {}; - bot.knowledge.lastEventSnapshotAt = new Date(); - await bot.save(); + console.log('[concierge] syncFoodKnowledge resolved source', { _id: source._id, type: source.type, displayName: source.displayName }); + await enqueueConciergeIngest({ sourceId: String(source._id), botId: String(bot._id), eventId: String(eventId), - type: 'event', + type: 'food', }); - return h.response(success(source, 'Event programme sync queued')); + + // Also attempt immediate ingestion for food so the UI doesn't stay PENDING + try { + const { runConciergeIngest } = require('../utils/conciergeIngest'); + await runConciergeIngest(String(source._id)); + const refreshed = await KnowledgeSource.findById(source._id).lean(); + return h.response(success(refreshed, 'Food sync completed')); + } catch (e) { + // Ingest failed or worker not running; return queued response + return h.response(success(source, 'Food sync queued')); + } }; module.exports = { @@ -436,4 +519,5 @@ module.exports = { approveSource, clearKnowledge, syncEventKnowledge, + syncFoodKnowledge, }; diff --git a/src/controllers/publicConcierge.controller.js b/src/controllers/publicConcierge.controller.js index 06cdb3c..e84eeda 100644 --- a/src/controllers/publicConcierge.controller.js +++ b/src/controllers/publicConcierge.controller.js @@ -97,9 +97,11 @@ const chat = async (request, h) => { 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 ragResult = await executeConciergeRag(String(bot.event), msg, event?.name || 'Event', { includeEventDetails, + includeFoodRecommendations, history: historyForPrompt, }); const answerText = diff --git a/src/models/ConciergeBot.js b/src/models/ConciergeBot.js index e98d90c..1d30f45 100644 --- a/src/models/ConciergeBot.js +++ b/src/models/ConciergeBot.js @@ -35,6 +35,7 @@ const conciergeBotSchema = new mongoose.Schema( }, knowledge: { includeEventDetails: { type: Boolean, default: true }, + includeFoodRecommendations: { type: Boolean, default: true}, lastEventSnapshotAt: Date, }, lastKnowledgeClearedAt: Date, diff --git a/src/models/KnowledgeSource.js b/src/models/KnowledgeSource.js index b3017ec..7003ac2 100644 --- a/src/models/KnowledgeSource.js +++ b/src/models/KnowledgeSource.js @@ -10,7 +10,7 @@ const knowledgeSourceSchema = new mongoose.Schema( }, type: { type: String, - enum: ['url', 'pdf', 'event'], + enum: ['url', 'pdf', 'event','food'], required: true, }, sourcePath: { type: String, required: true }, diff --git a/src/routes/conciergeBot.routes.js b/src/routes/conciergeBot.routes.js index 2e7484a..43f8ce2 100644 --- a/src/routes/conciergeBot.routes.js +++ b/src/routes/conciergeBot.routes.js @@ -13,6 +13,7 @@ const registrationSchema = Joi.object({ const knowledgeSchema = Joi.object({ includeEventDetails: Joi.boolean(), + includeFoodRecommendations: Joi.boolean(), }); const createPayload = Joi.object({ @@ -65,4 +66,5 @@ module.exports = [ { method: 'POST', path: '/api/concierge-bots/{botId}/sources/{sourceId}/approve', options: { handler: controller.approveSource } }, { method: 'POST', path: '/api/concierge-bots/{botId}/knowledge/clear', options: { handler: controller.clearKnowledge } }, { method: 'POST', path: '/api/concierge-bots/{botId}/knowledge/sync-event', options: { handler: controller.syncEventKnowledge } }, + { method: "POST", path: "/api/concierge-bots/{botId}/knowledge/food-sync", options: { handler: controller.syncFoodKnowledge} } ]; diff --git a/src/services/conciergeRag.service.js b/src/services/conciergeRag.service.js index 1f6f96d..88d60f4 100644 --- a/src/services/conciergeRag.service.js +++ b/src/services/conciergeRag.service.js @@ -54,10 +54,11 @@ function extractUsage(response) { * @param {string} eventId * @param {string} userQuery * @param {string} eventName - * @param {{ includeEventDetails?: boolean, history?: { role: 'user'|'assistant', text: string }[] }} [options] + * @param {{ includeEventDetails?: boolean, includeFoodRecommendations?: boolean, history?: { role: 'user'|'assistant', text: string }[] }} [options] */ async function executeConciergeRag(eventId, userQuery, eventName, options = {}) { const includeEventDetails = options.includeEventDetails !== false; + const includeFoodRecommendations = options.includeFoodRecommendations !== false; const history = Array.isArray(options.history) ? options.history : []; const { embeddings, modelId: embedModelId } = await embedTexts([userQuery]); @@ -74,9 +75,17 @@ async function executeConciergeRag(eventId, userQuery, eventName, options = {}) ], }; + const excludedSourceIds = []; if (!includeEventDetails) { const eventSource = await KnowledgeSource.findOne({ eventId, type: 'event' }).select('_id').lean(); - if (eventSource) chunkFilter.sourceId = { $ne: eventSource._id }; + if (eventSource) excludedSourceIds.push(eventSource._id); + } + if (!includeFoodRecommendations) { + const foodSource = await KnowledgeSource.findOne({ eventId, type: 'food' }).select('_id').lean(); + if (foodSource) excludedSourceIds.push(foodSource._id); + } + if (excludedSourceIds.length) { + chunkFilter.sourceId = { $nin: excludedSourceIds }; } const activeChunks = await ContextChunk.find(chunkFilter).lean(); diff --git a/src/services/foodRecommendationService.js b/src/services/foodRecommendationService.js new file mode 100644 index 0000000..ea066d1 --- /dev/null +++ b/src/services/foodRecommendationService.js @@ -0,0 +1,614 @@ +const axios = require("axios"); +const readline = require("readline"); +const { spawn } = require("child_process"); +const { MongoClient } = require('mongodb'); + +const API_KEY_ENV = 'GOOGLE_PLACES_API_KEY'; +const GOOGLE_PLACES_API_KEY = process.env[API_KEY_ENV]; +const OLLAMA_MODEL_ENV = "OLLAMA_MODEL"; + +const DEFAULT_OLLAMA_MODEL = "phi3"; + +const BASE_GEOCODE_URL = + "https://maps.googleapis.com/maps/api/geocode/json"; +const BASE_PLACES_URL = + "https://maps.googleapis.com/maps/api/place/textsearch/json"; +const BASE_DETAILS_URL = + "https://maps.googleapis.com/maps/api/place/details/json"; + +const DEFAULT_RADIUS_METERS = 2000; +const DEFAULT_RESULT_COUNT = 20; + +const SEARCH_QUERY_TEMPLATES = [ + "best local food in {location}", + "traditional food in {location}", + "street food in {location}", + "top restaurants in {location}", + "famous dishes in {location}", + "best cafes in {location}", + "local cuisine near {location}", +]; + +const CUISINE_TYPES = new Set([ + "afghani","african","american","asian","asian_fusion","bbq","bakery","bar", + "belgian","brazilian","breakfast_brunch","british","bubble_tea","bulgarian", + "burgers","cafes","cambodian","caribbean","chinese","comfort_food", + "creperies","cuban","czech","danish","delis","diners","ethiopian", + "european","filipino","fish_and_chips","fondue","food_court","food_stands", + "french","gastropubs","german","greek","hawaiian","himalayan","hot_dogs", + "hot_pot","hungarian","indian","indonesian","irish","island_pub","italian", + "japanese","juice_bars","kebab","korean","kosher","laotian", + "latin_american","lebanese","malaysian","mediterranean","mexican", + "middle_eastern","modern_european","mongolian","moroccan","new_american", + "new_zealand","nicaraguan","northern_indian","pakistani","pasta_shops", + "peruvian","pizza","polish","portuguese","russian","salad","sandwiches", + "scandinavian","seafood","singaporean","slovak","somali","southern", + "spanish","sri_lankan","steakhouse","sushi","syrian","taiwanese", + "tapasmallplates","tex-mex","thai","tibetan","turkish","ukrainian", + "vietnamese","waffles" +]); + +function getApiKey() { + const apiKey = process.env[API_KEY_ENV]; + + if (!apiKey) { + console.error( + `Error: environment variable ${API_KEY_ENV} is not set.` + ); + process.exit(1); + } + + return apiKey; +} + +async function fetchJson(url, params) { + const response = await axios.get(url, { + params, + timeout: 30000, + }); + + return response.data; +} + +async function geocodeAddress(address, apiKey) { + const result = await fetchJson(BASE_GEOCODE_URL, { + address, + key: apiKey, + }); + + if ( + result.status !== "OK" || + !result.results || + !result.results.length + ) { + throw new Error( + `Geocoding failed: ${result.status}` + ); + } + + const location = result.results[0].geometry.location; + + return { + formattedAddress: + result.results[0].formatted_address || address, + lat: Number(location.lat), + lng: Number(location.lng), + }; +} + +function haversineDistance(lat1, lng1, lat2, lng2) { + const R = 6371000; + + const toRad = (deg) => deg * Math.PI / 180; + + const dLat = toRad(lat2 - lat1); + const dLng = toRad(lng2 - lng1); + + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(toRad(lat1)) * + Math.cos(toRad(lat2)) * + Math.sin(dLng / 2) ** 2; + + const c = 2 * Math.atan2( + Math.sqrt(a), + Math.sqrt(1 - a) + ); + + return R * c; +} + +function formatDistance(meters) { + if (meters >= 1000) { + return `${(meters / 1000).toFixed(1)} km`; + } + + return `${Math.round(meters)} m`; +} + +function parseCuisine(types = []) { + const cuisines = types + .filter((t) => CUISINE_TYPES.has(t)) + .map((t) => + t.replace(/_/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()) + ); + + return cuisines.length ? cuisines : ["N/A"]; +} + +async function getPlaceDetails(placeId, apiKey) { + const result = await fetchJson(BASE_DETAILS_URL, { + place_id: placeId, + key: apiKey, + fields: [ + "formatted_phone_number", + "international_phone_number", + "price_level", + "reviews", + "types", + "opening_hours", + "geometry", + ].join(","), + }); + + if ( + result.status !== "OK" || + !result.result + ) { + throw new Error( + `Place details failed: ${result.status}` + ); + } + + return result.result; +} + +async function searchFoodPlaces( + originLat, + originLng, + apiKey, + locationName = "", + radius = DEFAULT_RADIUS_METERS, + limit = DEFAULT_RESULT_COUNT +) { + const seen = new Set(); + const places = []; + + for (const template of SEARCH_QUERY_TEMPLATES) { + if (places.length >= limit) break; + + const queryText = template.replace( + "{location}", + locationName + ); + + let result; + + try { + result = await fetchJson(BASE_PLACES_URL, { + query: queryText, + location: `${originLat},${originLng}`, + radius, + key: apiKey, + }); + } catch { + continue; + } + + if ( + result.status !== "OK" && + result.status !== "ZERO_RESULTS" + ) { + continue; + } + + for (const place of result.results || []) { + if (places.length >= limit) break; + + const placeId = place.place_id; + + if (!placeId || seen.has(placeId)) { + continue; + } + + seen.add(placeId); + + let details = {}; + + try { + details = await getPlaceDetails( + placeId, + apiKey + ); + } catch {} + + const lat = + Number(place.geometry?.location?.lat || 0); + + const lng = + Number(place.geometry?.location?.lng || 0); + + const types = + details.types || place.types || []; + + places.push({ + name: place.name, + address: place.formatted_address, + rating: place.rating, + user_ratings_total: + place.user_ratings_total, + types, + place_id: placeId, + business_status: + place.business_status, + opening_hours: + details.opening_hours?.open_now, + price_level: + details.price_level, + phone_number: + details.formatted_phone_number || + details.international_phone_number, + reviews: + (details.reviews || []).map((r) => ({ + author_name: r.author_name, + rating: r.rating, + relative_time_description: + r.relative_time_description, + text: r.text, + })), + cuisines: parseCuisine(types), + delivery: + types.includes("meal_delivery"), + takeaway: + types.includes("meal_takeaway"), + distance_meters: + haversineDistance( + originLat, + originLng, + lat, + lng + ), + }); + } + } + + return places; +} + +function getOllamaModelName() { + return ( + process.env[OLLAMA_MODEL_ENV] || + DEFAULT_OLLAMA_MODEL + ); +} + +function runOllamaModel(prompt, modelName) { + return new Promise((resolve, reject) => { + const child = spawn("ollama", [ + "run", + modelName, + "--nowordwrap", + ]); + + let output = ""; + let errorOutput = ""; + + child.stdout.on("data", (data) => { + output += data.toString(); + }); + + child.stderr.on("data", (data) => { + errorOutput += data.toString(); + }); + + child.on("close", (code) => { + if (code !== 0) { + return reject( + new Error(errorOutput) + ); + } + + resolve(output.trim()); + }); + + child.stdin.write(prompt); + child.stdin.end(); + }); +} + +function buildRecommendationPrompt( + places, + location, + radius +) { + + const cuisineCounts = {}; + + places.forEach((place) => { + + place.cuisines.forEach((cuisine) => { + + if (cuisine === "N/A") return; + + cuisineCounts[cuisine] = + (cuisineCounts[cuisine] || 0) + 1; + }); + }); + + const dominantCuisines = + Object.entries(cuisineCounts) + .sort((a, b) => b[1] - a[1]) + .slice(0, 8) + .map(([name]) => name); + // Build a short restaurant summary to provide context to the LLM + const restaurantSummary = places + .slice(0, 10) + .map((p, index) => { + return `\n${index + 1}. ${p.name}\nCuisine: ${p.cuisines.join(", ")}\nRating: ${p.rating || 'N/A'}\nDistance: ${formatDistance(p.distance_meters)}\nAddress: ${p.address || 'N/A'}\n`; + }) + .join('\n'); + + return ` +You are a knowledgeable local food recommendation assistant. + +The user is looking for foods to try near: + +${location} + +Nearby restaurants and cuisines indicate the local food preferences in this area. + +Nearby restaurants: +${restaurantSummary} + +Cuisine types: +${dominantCuisines.join(", ")} + +Your task: + +1. Recommend 5 to 7 foods or dishes that a visitor should try in this area. +2. Prioritize local specialties, local street food, and popular dishes suggested by the available cuisine types and restaurant reviews. +3. For each food, include a brief explanation of why it is worth trying and list recommended places to order it. +4. Use a numbered list format with each food item followed by recommended restaurant names. +5. Do NOT invent restaurants. +6. Do NOT assume the location is in a specific country unless it is obvious from the provided information. +7. Start your response with a heading like "🍽 Must-Try Foods in ${location}". + +Return only the food recommendation text. +`; + +} + +async function recommendFoodWithLLM( + places, + location, + radius +) { + try { + const model = + getOllamaModelName(); + + const prompt = + buildRecommendationPrompt( + places, + location, + radius + ); + + const response = + await runOllamaModel( + prompt, + model + ); + + console.log( + "\nFood suggestions from Ollama:\n" + ); + + console.log(response); + } catch (err) { + console.log( + "\nUnable to generate recommendations:" + ); + + console.log(err.message); + } +} + +function displayPlaces(places) { + if (!places.length) { + console.log( + "No food-related places found." + ); + return; + } + + places.forEach((place, index) => { + console.log( + `\n${index + 1}. ${place.name}` + ); + + console.log( + `Address: ${place.address || "N/A"}` + ); + + console.log( + `Phone: ${place.phone_number || "N/A"}` + ); + + console.log( + `Rating: ${place.rating || "N/A"} (${ + place.user_ratings_total || 0 + } reviews)` + ); + + console.log( + `Cuisine: ${place.cuisines.join(", ")}` + ); + + console.log( + `Distance: ${formatDistance( + place.distance_meters + )}` + ); + + if ( + place.opening_hours !== undefined + ) { + console.log( + `Status: ${ + place.opening_hours + ? "Open now" + : "Closed now" + }` + ); + } + + if ( + place.reviews && + place.reviews.length + ) { + console.log("Reviews:"); + + place.reviews + .slice(0, 2) + .forEach((review) => { + console.log( + `- ${review.author_name} (${review.rating})` + ); + + console.log( + ` ${review.text}` + ); + }); + } + }); +} + +async function ask(question, rl) { + return new Promise((resolve) => + rl.question(question, resolve) + ); +} + +async function getFoodRecommendations(location, eventId = null) { + + const apiKey = process.env[API_KEY_ENV]; + + let geo = { formattedAddress: location, lat: 0, lng: 0 }; + let places = []; + + if (!apiKey) { + // Fallback when no API key available — return a helpful message so ingest can continue + console.warn('GOOGLE_PLACES_API_KEY not set; returning fallback recommendation text'); + 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 = { eventId: eventId ? String(eventId) : null, location, origin: geo, recommendations: fallbackText, places: [], note: 'no-api-key', createdAt: new Date() }; + if (doc.eventId) await coll.updateOne({ eventId: doc.eventId }, { $set: doc }, { upsert: true }); else await coll.insertOne(doc); + await client.close(); + } catch (dbErr) { + console.warn('Failed to save food places to external DB:', dbErr.message || dbErr); + } + return fallbackText + ' Set GOOGLE_PLACES_API_KEY to enable real recommendations.'; + } + + try { + console.log('[getFoodRecommendations] Geocoding location:', location); + geo = await geocodeAddress(location, apiKey); + console.log('[getFoodRecommendations] Geocoding success:', geo.formattedAddress); + } catch (e) { + console.warn('[getFoodRecommendations] Geocoding failed:', e.message); + // Continue with location name as fallback + geo.formattedAddress = location; + } + + try { + console.log('[getFoodRecommendations] Searching food places'); + places = await searchFoodPlaces(geo.lat, geo.lng, apiKey, geo.formattedAddress); + console.log('[getFoodRecommendations] Found', places.length, 'places'); + } catch (e) { + console.warn('[getFoodRecommendations] Food places search failed:', e.message); + // Continue with empty places array + } + + let recommendationText = ''; + + try { + console.log('[getFoodRecommendations] Generating LLM recommendation'); + const prompt = buildRecommendationPrompt( + places, + geo.formattedAddress, + DEFAULT_RADIUS_METERS + ); + + recommendationText = await runOllamaModel( + prompt, + getOllamaModelName() + ); + } catch (e) { + console.warn('[getFoodRecommendations] LLM recommendation failed:', e.message, '— generating fallback text'); + + if (places.length === 0) { + recommendationText = `No food places found near ${geo.formattedAddress}.`; + } else { + const topPlaces = places + .slice(0, 5) + .map( + (p) => + `${p.name} (${p.cuisines.join(', ')}, rating: ${p.rating || 'N/A'})` + ) + .join('\n'); + + recommendationText = `Food recommendations near ${geo.formattedAddress}\n\nNearby restaurants:\n\n${topPlaces}`; + } + } + + // 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 = { + eventId: eventId ? String(eventId) : null, + location: geo.formattedAddress, + origin: { lat: geo.lat, lng: geo.lng }, + recommendations: recommendationText, + restaurants: places, + createdAt: new Date(), + }; + + if (doc.eventId) { + await coll.updateOne({ eventId: doc.eventId }, { $set: doc }, { upsert: true }); + } else { + await coll.insertOne(doc); + } + + await client.close(); + console.log('[getFoodRecommendations] Saved to DB'); + } catch (dbErr) { + console.warn('Failed to save food places to external DB:', dbErr.message || dbErr); + } + + const restaurantSummary = places + .slice(0, 10) + .map((p, index) => { + return `\n${index + 1}. ${p.name}\nCuisine: ${p.cuisines.join(', ')}\nRating: ${p.rating || 'N/A'}\nDistance: ${formatDistance(p.distance_meters)}\nAddress: ${p.address || 'N/A'}\n`; + }) + .join('\n'); + + return `\nFOOD RECOMMENDATIONS FOR ${geo.formattedAddress}\n\n${recommendationText}\n\nPLACES TO TRY THESE FOODS\n\n${restaurantSummary}\n`; +} + +module.exports = { + getFoodRecommendations +}; diff --git a/src/utils/conciergeIngest.js b/src/utils/conciergeIngest.js index 1c072cd..12886e0 100644 --- a/src/utils/conciergeIngest.js +++ b/src/utils/conciergeIngest.js @@ -6,6 +6,7 @@ const { buildEventKnowledgeTextFromEventId } = require('./conciergeEventKnowledg const { getBufferFromS3Key } = require('./s3Media'); const { extractTextFromPdfBuffer } = require('./conciergeDocumentExtract'); const { crawlUrlSource, extractTextFromUrl } = require('./conciergeUrlCrawl'); +const { getFoodRecommendations } = require('../services/foodRecommendationService'); async function extractTextFromPdf(sourcePath) { const buffer = await getBufferFromS3Key(sourcePath); @@ -16,6 +17,29 @@ async function extractTextFromEvent(eventId) { return buildEventKnowledgeTextFromEventId(eventId); } +async function extractTextFromFood(eventId) { + + const Event = require('../models/Event'); + + const event = await Event.findById(eventId); + + if (!event) { + throw new Error('Event not found'); + } + + const location = + event.city || + event.venue || + event.location || + event.address; + + if (!location) { + throw new Error('No event location found'); + } + + return await getFoodRecommendations(location, eventId); +} + /** * @param {import('../models/KnowledgeSource')} source */ @@ -65,6 +89,8 @@ async function runConciergeIngest(sourceId) { text = await extractTextFromPdf(source.sourcePath); } else if (source.type === 'event') { text = await extractTextFromEvent(source.sourcePath || source.eventId); + } else if (source.type === 'food') { + text = await extractTextFromFood(source.sourcePath || source.eventId); } else { throw new Error(`Unknown source type: ${source.type}`); } @@ -108,4 +134,5 @@ module.exports = { extractTextFromUrlSource, extractTextFromPdf, extractTextFromEvent, + extractTextFromFood, }; From 337c166db88d6a68c60ee89190e2df69f3848064 Mon Sep 17 00:00:00 2001 From: Tanuja Pansare Date: Wed, 24 Jun 2026 17:54:05 +0530 Subject: [PATCH 2/2] 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, +}; +