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