Implement concierge food recommendation pipeline
This commit is contained in:
parent
8fdbe2bf89
commit
337c166db8
|
|
@ -124,6 +124,11 @@
|
|||
try { localStorage.setItem(storageKey, sessionId); } catch {}
|
||||
}
|
||||
addMessage('assistant', (res.json.data && res.json.data.reply) ? res.json.data.reply : '…');
|
||||
// Log detected intent to browser console when backend returns it
|
||||
if (res.json.data && res.json.data.intent) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.info('[EventBuzz Concierge] detected intent:', res.json.data.intent);
|
||||
}
|
||||
}).catch(function (e) {
|
||||
sendBtn.disabled = false;
|
||||
setError(e && e.message ? e.message : 'Network error');
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ const ConciergeBot = require('../models/ConciergeBot');
|
|||
const Event = require('../models/Event');
|
||||
const WebChatSession = require('../models/WebChatSession');
|
||||
const { success, error } = require('../utils/response');
|
||||
const { executeConciergeRag } = require('../services/conciergeRag.service');
|
||||
const { runConciergePipeline } = require('../services/conciergePipeline.service');
|
||||
|
||||
|
||||
const MAX_MESSAGE_LENGTH = 800;
|
||||
const MAX_HISTORY_MESSAGES = 40;
|
||||
|
|
@ -99,36 +100,63 @@ const chat = async (request, h) => {
|
|||
const includeEventDetails = bot.knowledge?.includeEventDetails !== false;
|
||||
const includeFoodRecommendations = bot.knowledge?.includeFoodRecommendations !== false;
|
||||
|
||||
const ragResult = await executeConciergeRag(String(bot.event), msg, event?.name || 'Event', {
|
||||
includeEventDetails,
|
||||
includeFoodRecommendations,
|
||||
history: historyForPrompt,
|
||||
});
|
||||
const answerText =
|
||||
ragResult?.text ||
|
||||
"I don't have that in my knowledge base yet. Please check with event staff or try another question.";
|
||||
const { determineIntentCategory } = require('../utils/conciergeIntent');
|
||||
|
||||
const last6ForIntent = session.history.slice(-6).map((m) => ({
|
||||
role: m.role,
|
||||
text: m.text,
|
||||
}));
|
||||
|
||||
const intentResult = await determineIntentCategory(last6ForIntent);
|
||||
const intentCategory = intentResult?.category || 'unrecognized';
|
||||
|
||||
if (intentResult?.databaseSearchNeeded) {
|
||||
console.log('[concierge] database search needed for intent:', intentCategory);
|
||||
}
|
||||
|
||||
// Web console / backend terminal debugging: show intent classification for each inbound chat message.
|
||||
console.log('[concierge][intent]', {
|
||||
botId: String(bot._id),
|
||||
sessionId,
|
||||
message: msg,
|
||||
intent: intentCategory,
|
||||
databaseSearchNeeded: Boolean(intentResult?.databaseSearchNeeded),
|
||||
});
|
||||
|
||||
|
||||
const pipelineResult = await runConciergePipeline({
|
||||
eventId: String(bot.event),
|
||||
eventName: event?.name || 'Event',
|
||||
bot,
|
||||
sessionHistory: session.history.map((m) => ({ role: m.role, text: m.text })),
|
||||
});
|
||||
|
||||
const answerText = pipelineResult?.text || "I don't have that in my knowledge base yet. Please check with event staff or try another question.";
|
||||
|
||||
|
||||
const reply = String(answerText || '').trim().slice(0, 1200);
|
||||
const inTok = ragResult?.usage?.inputTokens || 0;
|
||||
const outTok = ragResult?.usage?.outputTokens || 0;
|
||||
const inTok = pipelineResult?.usage?.inputTokens || 0;
|
||||
const outTok = pipelineResult?.usage?.outputTokens || 0;
|
||||
session.inputTokensTotal = Number(session.inputTokensTotal || 0) + Number(inTok || 0);
|
||||
session.outputTokensTotal = Number(session.outputTokensTotal || 0) + Number(outTok || 0);
|
||||
session.history.push({
|
||||
role: 'assistant',
|
||||
text: reply,
|
||||
ts: new Date(),
|
||||
modelId: ragResult?.modelId || null,
|
||||
modelId: pipelineResult?.modelId || null,
|
||||
inputTokens: inTok,
|
||||
outputTokens: outTok,
|
||||
});
|
||||
|
||||
if (session.history.length > MAX_HISTORY_MESSAGES) {
|
||||
session.history = session.history.slice(-MAX_HISTORY_MESSAGES);
|
||||
}
|
||||
await session.save();
|
||||
|
||||
return h.response(success({ sessionId, reply, usage: ragResult?.usage || null }));
|
||||
return h.response(success({ sessionId, reply, usage: pipelineResult?.usage || null, intent: intentCategory }));
|
||||
};
|
||||
|
||||
|
||||
module.exports = {
|
||||
config,
|
||||
chat,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
@ -88,9 +88,26 @@ async function executeConciergeRag(eventId, userQuery, eventName, options = {})
|
|||
chunkFilter.sourceId = { $nin: excludedSourceIds };
|
||||
}
|
||||
|
||||
// Debug: confirm why RAG can short-circuit before reaching the LLM.
|
||||
// (Keep this log lightweight; remove later if noisy.)
|
||||
try {
|
||||
const totalActiveForEvent = await ContextChunk.countDocuments({ eventId, isActive: true });
|
||||
const matchingChunksCount = await ContextChunk.countDocuments(chunkFilter);
|
||||
console.log('[conciergeRag][debug] RAG chunk gating', {
|
||||
eventId,
|
||||
isActive: true,
|
||||
embedModelId,
|
||||
totalActiveForEvent,
|
||||
matchingChunksCount,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[conciergeRag][debug] chunk gating log failed:', e?.message || e);
|
||||
}
|
||||
|
||||
const activeChunks = await ContextChunk.find(chunkFilter).lean();
|
||||
if (!activeChunks.length) return null;
|
||||
|
||||
|
||||
const ranked = activeChunks
|
||||
.map((chunk) => ({ text: chunk.rawText, score: cosineSimilarity(queryEmbedding, chunk.embedding) }))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
|
|
@ -99,7 +116,10 @@ async function executeConciergeRag(eventId, userQuery, eventName, options = {})
|
|||
const contextBlock = ranked.map((c) => c.text).join('\n\n');
|
||||
const { region, modelId: configuredId } = getBedrockConfig();
|
||||
const client = createBedrockClient(region);
|
||||
const system = buildConciergeSystemPrompt(eventName);
|
||||
// Pass intent/category so the concierge system prompt can be specialized.
|
||||
const system = buildConciergeSystemPrompt(eventName, options.promptIntent || 'general');
|
||||
|
||||
|
||||
const eventFacts = await buildEventFactsBlock(eventId);
|
||||
|
||||
const historyBlock = history
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
@ -249,14 +249,23 @@ async function searchFoodPlaces(
|
|||
phone_number:
|
||||
details.formatted_phone_number ||
|
||||
details.international_phone_number,
|
||||
reviews:
|
||||
(details.reviews || []).map((r) => ({
|
||||
author_name: r.author_name,
|
||||
rating: r.rating,
|
||||
relative_time_description:
|
||||
r.relative_time_description,
|
||||
text: r.text,
|
||||
})),
|
||||
reviewsSummary:
|
||||
(details.reviews || [])
|
||||
.map((r) => {
|
||||
const text = r.text;
|
||||
const summary = String(text || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.split(/[.!?]/)[0]
|
||||
.trim();
|
||||
|
||||
const s = summary || String(text || '').slice(0, 120);
|
||||
const rating = r.rating != null ? ` (${r.rating})` : '';
|
||||
return `${s}${rating}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.slice(0, 8)
|
||||
.join(' • '),
|
||||
cuisines: parseCuisine(types),
|
||||
delivery:
|
||||
types.includes("meal_delivery"),
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
const { BedrockRuntimeClient } = require('@aws-sdk/client-bedrock-runtime');
|
||||
|
||||
const DEFAULT_BEDROCK_MODEL_ID = 'meta.llama3-8b-instruct-v1:0';
|
||||
const DEFAULT_BEDROCK_MODEL_ID = 'amazon.nova-lite-v1:0';
|
||||
|
||||
|
||||
const DEFAULT_BEDROCK_EMBED_MODEL_ID = 'amazon.titan-embed-text-v2:0';
|
||||
|
||||
const REGION_FALLBACK_MODEL_IDS = [
|
||||
'meta.llama3-8b-instruct-v1:0',
|
||||
'meta.llama3-70b-instruct-v1:0',
|
||||
'amazon.nova-micro-v1:0',
|
||||
];
|
||||
|
||||
|
||||
const REGION_FALLBACK_EMBED_MODEL_IDS = [
|
||||
'amazon.titan-embed-text-v2:0',
|
||||
'amazon.titan-embed-text-v1:0',
|
||||
|
|
|
|||
|
|
@ -16,17 +16,38 @@ function responseText(response) {
|
|||
*/
|
||||
async function converse(client, modelId, opts) {
|
||||
const { system, messages, inferenceConfig } = opts;
|
||||
|
||||
// Allow tuning for different models/environments.
|
||||
// Defaults preserve existing behavior if env vars are not set.
|
||||
const temperatureEnv = process.env.BEDROCK_TEMPERATURE;
|
||||
const topPEnv = process.env.BEDROCK_TOP_P;
|
||||
|
||||
const temperature =
|
||||
temperatureEnv != null && temperatureEnv !== ''
|
||||
? Number(temperatureEnv)
|
||||
: 0.3;
|
||||
|
||||
const topP = topPEnv != null && topPEnv !== '' ? Number(topPEnv) : 0.9;
|
||||
|
||||
const mergedInferenceConfig = {
|
||||
maxTokens: 2048,
|
||||
temperature: Number.isFinite(temperature) ? temperature : 0.3,
|
||||
topP: Number.isFinite(topP) ? topP : 0.9,
|
||||
...inferenceConfig,
|
||||
};
|
||||
|
||||
console.log('[bedrock][converse] modelId=', modelId, {
|
||||
temperature: mergedInferenceConfig.temperature,
|
||||
topP: mergedInferenceConfig.topP,
|
||||
maxTokens: mergedInferenceConfig.maxTokens,
|
||||
});
|
||||
|
||||
return client.send(
|
||||
new ConverseCommand({
|
||||
modelId,
|
||||
...(system && system.length ? { system } : {}),
|
||||
messages,
|
||||
inferenceConfig: {
|
||||
maxTokens: 2048,
|
||||
temperature: 0.3,
|
||||
topP: 0.9,
|
||||
...inferenceConfig,
|
||||
},
|
||||
inferenceConfig: mergedInferenceConfig,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
function interpolateConciergeGreeting(template, values = {}) {
|
||||
const greeting = String(template || '').trim();
|
||||
if (!greeting) {
|
||||
const profileName = values.profileName || 'Guest';
|
||||
const eventName = values.eventName ? ` for ${values.eventName}` : '';
|
||||
return `Hello ${profileName}${eventName}! How can I help you today?`;
|
||||
}
|
||||
|
||||
return greeting.replace(/{{\s*(\w+)\s*}}/g, (_, key) => {
|
||||
if (Object.prototype.hasOwnProperty.call(values, key)) {
|
||||
return String(values[key] ?? '');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
interpolateConciergeGreeting,
|
||||
};
|
||||
|
|
@ -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