Add food recommendation features
This commit is contained in:
parent
2a5629c2ae
commit
8fdbe2bf89
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
14
package.json
14
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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
require('dotenv').config();
|
||||
(async () => {
|
||||
const id = process.argv[2];
|
||||
if (!id) {
|
||||
console.error('Usage: node run-ingest-single.js <sourceId>');
|
||||
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);
|
||||
}
|
||||
})();
|
||||
|
|
@ -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);
|
||||
}
|
||||
})();
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
||||
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: 'event',
|
||||
type,
|
||||
sourcePath: String(eventId),
|
||||
displayName: programmeDisplayName,
|
||||
displayName,
|
||||
status: 'PENDING',
|
||||
});
|
||||
} else {
|
||||
source.status = 'PENDING';
|
||||
source.errorMessage = null;
|
||||
source.displayName = programmeDisplayName;
|
||||
source.displayName = displayName;
|
||||
await source.save();
|
||||
}
|
||||
bot.knowledge = bot.knowledge || {};
|
||||
bot.knowledge.lastEventSnapshotAt = new Date();
|
||||
await bot.save();
|
||||
|
||||
await enqueueConciergeIngest({
|
||||
sourceId: String(source._id),
|
||||
botId: String(bot._id),
|
||||
eventId: String(eventId),
|
||||
type: 'event',
|
||||
type,
|
||||
});
|
||||
return h.response(success(source, 'Event programme sync queued'));
|
||||
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: 'food',
|
||||
sourcePath: String(eventId),
|
||||
displayName: 'Food Recommendations',
|
||||
status: 'PENDING',
|
||||
});
|
||||
} else {
|
||||
source.status = 'PENDING';
|
||||
source.errorMessage = null;
|
||||
source.displayName = 'Food Recommendations';
|
||||
await source.save();
|
||||
}
|
||||
console.log('[concierge] syncFoodKnowledge resolved source', { _id: source._id, type: source.type, displayName: source.displayName });
|
||||
|
||||
await enqueueConciergeIngest({
|
||||
sourceId: String(source._id),
|
||||
botId: String(bot._id),
|
||||
eventId: String(eventId),
|
||||
type: 'food',
|
||||
});
|
||||
|
||||
// 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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ const conciergeBotSchema = new mongoose.Schema(
|
|||
},
|
||||
knowledge: {
|
||||
includeEventDetails: { type: Boolean, default: true },
|
||||
includeFoodRecommendations: { type: Boolean, default: true},
|
||||
lastEventSnapshotAt: Date,
|
||||
},
|
||||
lastKnowledgeClearedAt: Date,
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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} }
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue