Merge branch 'master' of bitbucket.org and resolved conflicts
This commit is contained in:
commit
347005322a
|
|
@ -27,7 +27,7 @@ Node.js backend for EventBuzz built with **Hapi.js** and **MongoDB** (Mongoose),
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
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)**
|
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:
|
Run once against your database before restart if you have legacy rows:
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"bullmq": "^5.34.5",
|
"bullmq": "^5.34.5",
|
||||||
"cheerio": "^1.0.0",
|
"cheerio": "^1.0.0",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.6.1",
|
||||||
"firebase-admin": "^13.0.0",
|
"firebase-admin": "^13.0.0",
|
||||||
"google-auth-library": "^9.14.0",
|
"google-auth-library": "^9.14.0",
|
||||||
"hapi-auth-jwt2": "^10.2.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"
|
"badge-batch-worker": "node src/workers/badgeBatchWorker.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@napi-rs/canvas": "^0.1.56",
|
|
||||||
"archiver": "^7.0.1",
|
|
||||||
"@aws-sdk/client-bedrock-runtime": "^3.1030.0",
|
"@aws-sdk/client-bedrock-runtime": "^3.1030.0",
|
||||||
"@aws-sdk/client-s3": "^3.1012.0",
|
"@aws-sdk/client-s3": "^3.1012.0",
|
||||||
"@aws-sdk/lib-storage": "^3.1012.0",
|
"@aws-sdk/lib-storage": "^3.1012.0",
|
||||||
"@hapi/hapi": "^21.3.0",
|
"@hapi/hapi": "^21.3.0",
|
||||||
|
"@napi-rs/canvas": "^0.1.56",
|
||||||
|
"archiver": "^7.0.1",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"bullmq": "^5.34.5",
|
"bullmq": "^5.34.5",
|
||||||
"cheerio": "^1.0.0",
|
"cheerio": "^1.0.0",
|
||||||
"ioredis": "^5.4.2",
|
"dotenv": "^16.6.1",
|
||||||
"mathjs": "^14.0.1",
|
|
||||||
"pdf-parse": "^1.1.1",
|
|
||||||
"dotenv": "^16.3.1",
|
|
||||||
"firebase-admin": "^13.0.0",
|
"firebase-admin": "^13.0.0",
|
||||||
"google-auth-library": "^9.14.0",
|
"google-auth-library": "^9.14.0",
|
||||||
"hapi-auth-jwt2": "^10.2.0",
|
"hapi-auth-jwt2": "^10.2.0",
|
||||||
|
"ioredis": "^5.4.2",
|
||||||
"joi": "^17.13.0",
|
"joi": "^17.13.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"mathjs": "^14.0.1",
|
||||||
"mongoose": "^8.0.0",
|
"mongoose": "^8.0.0",
|
||||||
"pdf-lib": "^1.17.1",
|
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
"nodemailer": "^6.9.7",
|
"nodemailer": "^6.9.7",
|
||||||
"officeparser": "^5.1.1",
|
"officeparser": "^5.1.1",
|
||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
|
"pdf-parse": "^1.1.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"razorpay": "^2.9.6",
|
"razorpay": "^2.9.6",
|
||||||
"sharp": "^0.33.5",
|
"sharp": "^0.33.5",
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,11 @@
|
||||||
try { localStorage.setItem(storageKey, sessionId); } catch {}
|
try { localStorage.setItem(storageKey, sessionId); } catch {}
|
||||||
}
|
}
|
||||||
addMessage('assistant', (res.json.data && res.json.data.reply) ? res.json.data.reply : '…');
|
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) {
|
}).catch(function (e) {
|
||||||
sendBtn.disabled = false;
|
sendBtn.disabled = false;
|
||||||
setError(e && e.message ? e.message : 'Network error');
|
setError(e && e.message ? e.message : 'Network error');
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
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);
|
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 (err) return h.response(error(err.code, err.message)).code(err.status);
|
||||||
const eventId = bot.event._id || bot.event;
|
const eventId = bot.event._id || bot.event;
|
||||||
|
|
@ -295,6 +296,7 @@ const syncSource = async (request, h) => {
|
||||||
_id: request.params.sourceId,
|
_id: request.params.sourceId,
|
||||||
eventId,
|
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);
|
if (!source) return h.response(error('NOT_FOUND', 'Source not found')).code(404);
|
||||||
source.status = 'PENDING';
|
source.status = 'PENDING';
|
||||||
source.errorMessage = null;
|
source.errorMessage = null;
|
||||||
|
|
@ -305,7 +307,16 @@ const syncSource = async (request, h) => {
|
||||||
eventId: String(eventId),
|
eventId: String(eventId),
|
||||||
type: source.type,
|
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) => {
|
const listChunks = async (request, h) => {
|
||||||
|
|
@ -378,6 +389,7 @@ const clearKnowledge = async (request, h) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const syncEventKnowledge = 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);
|
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 (err) return h.response(error(err.code, err.message)).code(err.status);
|
||||||
if (bot.knowledge?.includeEventDetails === false) {
|
if (bot.knowledge?.includeEventDetails === false) {
|
||||||
|
|
@ -392,31 +404,102 @@ const syncEventKnowledge = async (request, h) => {
|
||||||
}
|
}
|
||||||
const eventId = bot.event._id || bot.event;
|
const eventId = bot.event._id || bot.event;
|
||||||
const programmeDisplayName = 'Event programme & links';
|
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) {
|
if (!source) {
|
||||||
source = await KnowledgeSource.create({
|
source = await KnowledgeSource.create({
|
||||||
eventId,
|
eventId,
|
||||||
type: 'event',
|
type: 'food',
|
||||||
sourcePath: String(eventId),
|
sourcePath: String(eventId),
|
||||||
displayName: programmeDisplayName,
|
displayName: 'Food Recommendations',
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
source.status = 'PENDING';
|
source.status = 'PENDING';
|
||||||
source.errorMessage = null;
|
source.errorMessage = null;
|
||||||
source.displayName = programmeDisplayName;
|
source.displayName = 'Food Recommendations';
|
||||||
await source.save();
|
await source.save();
|
||||||
}
|
}
|
||||||
bot.knowledge = bot.knowledge || {};
|
console.log('[concierge] syncFoodKnowledge resolved source', { _id: source._id, type: source.type, displayName: source.displayName });
|
||||||
bot.knowledge.lastEventSnapshotAt = new Date();
|
|
||||||
await bot.save();
|
|
||||||
await enqueueConciergeIngest({
|
await enqueueConciergeIngest({
|
||||||
sourceId: String(source._id),
|
sourceId: String(source._id),
|
||||||
botId: String(bot._id),
|
botId: String(bot._id),
|
||||||
eventId: String(eventId),
|
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 = {
|
module.exports = {
|
||||||
|
|
@ -436,4 +519,5 @@ module.exports = {
|
||||||
approveSource,
|
approveSource,
|
||||||
clearKnowledge,
|
clearKnowledge,
|
||||||
syncEventKnowledge,
|
syncEventKnowledge,
|
||||||
|
syncFoodKnowledge,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ const ConciergeBot = require('../models/ConciergeBot');
|
||||||
const Event = require('../models/Event');
|
const Event = require('../models/Event');
|
||||||
const WebChatSession = require('../models/WebChatSession');
|
const WebChatSession = require('../models/WebChatSession');
|
||||||
const { success, error } = require('../utils/response');
|
const { success, error } = require('../utils/response');
|
||||||
const { executeConciergeRag } = require('../services/conciergeRag.service');
|
const { runConciergePipeline } = require('../services/conciergePipeline.service');
|
||||||
const { interpolateConciergeGreeting } = require('../utils/conciergeGreeting');
|
const { interpolateConciergeGreeting } = require('../utils/conciergeGreeting');
|
||||||
|
|
||||||
const MAX_MESSAGE_LENGTH = 800;
|
const MAX_MESSAGE_LENGTH = 800;
|
||||||
|
|
@ -103,36 +103,65 @@ const chat = async (request, h) => {
|
||||||
|
|
||||||
const historyForPrompt = session.history.slice(-12).map((m) => ({ role: m.role, text: m.text }));
|
const historyForPrompt = session.history.slice(-12).map((m) => ({ role: m.role, text: m.text }));
|
||||||
const includeEventDetails = bot.knowledge?.includeEventDetails !== false;
|
const includeEventDetails = bot.knowledge?.includeEventDetails !== false;
|
||||||
|
const includeFoodRecommendations = bot.knowledge?.includeFoodRecommendations !== false;
|
||||||
|
|
||||||
|
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 ragResult = await executeConciergeRag(String(bot.event), msg, event?.name || 'Event', {
|
|
||||||
includeEventDetails,
|
|
||||||
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 reply = String(answerText || '').trim().slice(0, 1200);
|
const reply = String(answerText || '').trim().slice(0, 1200);
|
||||||
const inTok = ragResult?.usage?.inputTokens || 0;
|
const inTok = pipelineResult?.usage?.inputTokens || 0;
|
||||||
const outTok = ragResult?.usage?.outputTokens || 0;
|
const outTok = pipelineResult?.usage?.outputTokens || 0;
|
||||||
session.inputTokensTotal = Number(session.inputTokensTotal || 0) + Number(inTok || 0);
|
session.inputTokensTotal = Number(session.inputTokensTotal || 0) + Number(inTok || 0);
|
||||||
session.outputTokensTotal = Number(session.outputTokensTotal || 0) + Number(outTok || 0);
|
session.outputTokensTotal = Number(session.outputTokensTotal || 0) + Number(outTok || 0);
|
||||||
session.history.push({
|
session.history.push({
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
text: reply,
|
text: reply,
|
||||||
ts: new Date(),
|
ts: new Date(),
|
||||||
modelId: ragResult?.modelId || null,
|
modelId: pipelineResult?.modelId || null,
|
||||||
inputTokens: inTok,
|
inputTokens: inTok,
|
||||||
outputTokens: outTok,
|
outputTokens: outTok,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (session.history.length > MAX_HISTORY_MESSAGES) {
|
if (session.history.length > MAX_HISTORY_MESSAGES) {
|
||||||
session.history = session.history.slice(-MAX_HISTORY_MESSAGES);
|
session.history = session.history.slice(-MAX_HISTORY_MESSAGES);
|
||||||
}
|
}
|
||||||
await session.save();
|
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 = {
|
module.exports = {
|
||||||
config,
|
config,
|
||||||
chat,
|
chat,
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ const conciergeBotSchema = new mongoose.Schema(
|
||||||
},
|
},
|
||||||
knowledge: {
|
knowledge: {
|
||||||
includeEventDetails: { type: Boolean, default: true },
|
includeEventDetails: { type: Boolean, default: true },
|
||||||
|
includeFoodRecommendations: { type: Boolean, default: true},
|
||||||
lastEventSnapshotAt: Date,
|
lastEventSnapshotAt: Date,
|
||||||
},
|
},
|
||||||
lastKnowledgeClearedAt: Date,
|
lastKnowledgeClearedAt: Date,
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ const knowledgeSourceSchema = new mongoose.Schema(
|
||||||
},
|
},
|
||||||
type: {
|
type: {
|
||||||
type: String,
|
type: String,
|
||||||
enum: ['url', 'pdf', 'event'],
|
enum: ['url', 'pdf', 'event','food'],
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
sourcePath: { type: String, required: true },
|
sourcePath: { type: String, required: true },
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ const registrationSchema = Joi.object({
|
||||||
|
|
||||||
const knowledgeSchema = Joi.object({
|
const knowledgeSchema = Joi.object({
|
||||||
includeEventDetails: Joi.boolean(),
|
includeEventDetails: Joi.boolean(),
|
||||||
|
includeFoodRecommendations: Joi.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const createPayload = Joi.object({
|
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}/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/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/sync-event', options: { handler: controller.syncEventKnowledge } },
|
||||||
|
{ method: "POST", path: "/api/concierge-bots/{botId}/knowledge/food-sync", options: { handler: controller.syncFoodKnowledge} }
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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 "- <author> (<rating>): "
|
||||||
|
// keep "- (<rating>): <text>" 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,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -54,10 +54,11 @@ function extractUsage(response) {
|
||||||
* @param {string} eventId
|
* @param {string} eventId
|
||||||
* @param {string} userQuery
|
* @param {string} userQuery
|
||||||
* @param {string} eventName
|
* @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 = {}) {
|
async function executeConciergeRag(eventId, userQuery, eventName, options = {}) {
|
||||||
const includeEventDetails = options.includeEventDetails !== false;
|
const includeEventDetails = options.includeEventDetails !== false;
|
||||||
|
const includeFoodRecommendations = options.includeFoodRecommendations !== false;
|
||||||
const history = Array.isArray(options.history) ? options.history : [];
|
const history = Array.isArray(options.history) ? options.history : [];
|
||||||
|
|
||||||
const { embeddings, modelId: embedModelId } = await embedTexts([userQuery]);
|
const { embeddings, modelId: embedModelId } = await embedTexts([userQuery]);
|
||||||
|
|
@ -74,14 +75,39 @@ async function executeConciergeRag(eventId, userQuery, eventName, options = {})
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const excludedSourceIds = [];
|
||||||
if (!includeEventDetails) {
|
if (!includeEventDetails) {
|
||||||
const eventSource = await KnowledgeSource.findOne({ eventId, type: 'event' }).select('_id').lean();
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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();
|
const activeChunks = await ContextChunk.find(chunkFilter).lean();
|
||||||
if (!activeChunks.length) return null;
|
if (!activeChunks.length) return null;
|
||||||
|
|
||||||
|
|
||||||
const ranked = activeChunks
|
const ranked = activeChunks
|
||||||
.map((chunk) => ({ text: chunk.rawText, score: cosineSimilarity(queryEmbedding, chunk.embedding) }))
|
.map((chunk) => ({ text: chunk.rawText, score: cosineSimilarity(queryEmbedding, chunk.embedding) }))
|
||||||
.sort((a, b) => b.score - a.score)
|
.sort((a, b) => b.score - a.score)
|
||||||
|
|
@ -90,7 +116,10 @@ async function executeConciergeRag(eventId, userQuery, eventName, options = {})
|
||||||
const contextBlock = ranked.map((c) => c.text).join('\n\n');
|
const contextBlock = ranked.map((c) => c.text).join('\n\n');
|
||||||
const { region, modelId: configuredId } = getBedrockConfig();
|
const { region, modelId: configuredId } = getBedrockConfig();
|
||||||
const client = createBedrockClient(region);
|
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 eventFacts = await buildEventFactsBlock(eventId);
|
||||||
|
|
||||||
const historyBlock = history
|
const historyBlock = history
|
||||||
|
|
|
||||||
|
|
@ -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: <string> }
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,623 @@
|
||||||
|
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,
|
||||||
|
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"),
|
||||||
|
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
|
||||||
|
};
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
const { BedrockRuntimeClient } = require('@aws-sdk/client-bedrock-runtime');
|
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 DEFAULT_BEDROCK_EMBED_MODEL_ID = 'amazon.titan-embed-text-v2:0';
|
||||||
|
|
||||||
const REGION_FALLBACK_MODEL_IDS = [
|
const REGION_FALLBACK_MODEL_IDS = [
|
||||||
'meta.llama3-8b-instruct-v1:0',
|
'amazon.nova-micro-v1:0',
|
||||||
'meta.llama3-70b-instruct-v1:0',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
const REGION_FALLBACK_EMBED_MODEL_IDS = [
|
const REGION_FALLBACK_EMBED_MODEL_IDS = [
|
||||||
'amazon.titan-embed-text-v2:0',
|
'amazon.titan-embed-text-v2:0',
|
||||||
'amazon.titan-embed-text-v1:0',
|
'amazon.titan-embed-text-v1:0',
|
||||||
|
|
|
||||||
|
|
@ -16,17 +16,38 @@ function responseText(response) {
|
||||||
*/
|
*/
|
||||||
async function converse(client, modelId, opts) {
|
async function converse(client, modelId, opts) {
|
||||||
const { system, messages, inferenceConfig } = 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(
|
return client.send(
|
||||||
new ConverseCommand({
|
new ConverseCommand({
|
||||||
modelId,
|
modelId,
|
||||||
...(system && system.length ? { system } : {}),
|
...(system && system.length ? { system } : {}),
|
||||||
messages,
|
messages,
|
||||||
inferenceConfig: {
|
inferenceConfig: mergedInferenceConfig,
|
||||||
maxTokens: 2048,
|
|
||||||
temperature: 0.3,
|
|
||||||
topP: 0.9,
|
|
||||||
...inferenceConfig,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,17 +7,30 @@ const CONCIERGE_GREETING_DEFAULTS = {
|
||||||
/**
|
/**
|
||||||
* Replace {profileName}, {eventName}, and other known placeholders in a concierge greeting.
|
* Replace {profileName}, {eventName}, and other known placeholders in a concierge greeting.
|
||||||
* Empty or missing values fall back to CONCIERGE_GREETING_DEFAULTS.
|
* Empty or missing values fall back to CONCIERGE_GREETING_DEFAULTS.
|
||||||
|
* When the template is empty, returns a sensible default greeting.
|
||||||
*
|
*
|
||||||
* @param {string} template
|
* @param {string} template
|
||||||
* @param {{ profileName?: string, eventName?: string }} [values]
|
* @param {{ profileName?: string, eventName?: string }} [values]
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
function interpolateConciergeGreeting(template, values = {}) {
|
function interpolateConciergeGreeting(template, values = {}) {
|
||||||
let out = String(template || '');
|
const greeting = String(template || '').trim();
|
||||||
|
if (!greeting) {
|
||||||
|
const profileName =
|
||||||
|
values.profileName != null && String(values.profileName).trim() !== ''
|
||||||
|
? String(values.profileName).trim()
|
||||||
|
: CONCIERGE_GREETING_DEFAULTS.profileName;
|
||||||
|
const eventName =
|
||||||
|
values.eventName != null && String(values.eventName).trim() !== ''
|
||||||
|
? ` for ${String(values.eventName).trim()}`
|
||||||
|
: '';
|
||||||
|
return `Hello ${profileName}${eventName}! How can I help you today?`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let out = greeting;
|
||||||
for (const [key, fallback] of Object.entries(CONCIERGE_GREETING_DEFAULTS)) {
|
for (const [key, fallback] of Object.entries(CONCIERGE_GREETING_DEFAULTS)) {
|
||||||
const raw = values[key];
|
const raw = values[key];
|
||||||
const val =
|
const val = raw != null && String(raw).trim() !== '' ? String(raw).trim() : fallback;
|
||||||
raw != null && String(raw).trim() !== '' ? String(raw).trim() : fallback;
|
|
||||||
out = out.replace(new RegExp(`\\{${key}\\}`, 'gi'), val);
|
out = out.replace(new RegExp(`\\{${key}\\}`, 'gi'), val);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ const { buildEventKnowledgeTextFromEventId } = require('./conciergeEventKnowledg
|
||||||
const { getBufferFromS3Key } = require('./s3Media');
|
const { getBufferFromS3Key } = require('./s3Media');
|
||||||
const { extractTextFromPdfBuffer } = require('./conciergeDocumentExtract');
|
const { extractTextFromPdfBuffer } = require('./conciergeDocumentExtract');
|
||||||
const { crawlUrlSource, extractTextFromUrl } = require('./conciergeUrlCrawl');
|
const { crawlUrlSource, extractTextFromUrl } = require('./conciergeUrlCrawl');
|
||||||
|
const { getFoodRecommendations } = require('../services/foodRecommendationService');
|
||||||
|
|
||||||
async function extractTextFromPdf(sourcePath) {
|
async function extractTextFromPdf(sourcePath) {
|
||||||
const buffer = await getBufferFromS3Key(sourcePath);
|
const buffer = await getBufferFromS3Key(sourcePath);
|
||||||
|
|
@ -16,6 +17,29 @@ async function extractTextFromEvent(eventId) {
|
||||||
return buildEventKnowledgeTextFromEventId(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
|
* @param {import('../models/KnowledgeSource')} source
|
||||||
*/
|
*/
|
||||||
|
|
@ -65,6 +89,8 @@ async function runConciergeIngest(sourceId) {
|
||||||
text = await extractTextFromPdf(source.sourcePath);
|
text = await extractTextFromPdf(source.sourcePath);
|
||||||
} else if (source.type === 'event') {
|
} else if (source.type === 'event') {
|
||||||
text = await extractTextFromEvent(source.sourcePath || source.eventId);
|
text = await extractTextFromEvent(source.sourcePath || source.eventId);
|
||||||
|
} else if (source.type === 'food') {
|
||||||
|
text = await extractTextFromFood(source.sourcePath || source.eventId);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Unknown source type: ${source.type}`);
|
throw new Error(`Unknown source type: ${source.type}`);
|
||||||
}
|
}
|
||||||
|
|
@ -108,4 +134,5 @@ module.exports = {
|
||||||
extractTextFromUrlSource,
|
extractTextFromUrlSource,
|
||||||
extractTextFromPdf,
|
extractTextFromPdf,
|
||||||
extractTextFromEvent,
|
extractTextFromEvent,
|
||||||
|
extractTextFromFood,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
||||||
Loading…
Reference in New Issue