EventBuzz_Backend/src/controllers/publicConcierge.controller.js

211 lines
7.1 KiB
JavaScript

const crypto = require('crypto');
const mongoose = require('mongoose');
const ConciergeBot = require('../models/ConciergeBot');
const Event = require('../models/Event');
const WebChatSession = require('../models/WebChatSession');
const { success, error } = require('../utils/response');
const { runConciergePipeline } = require('../services/conciergePipeline.service');
const { interpolateConciergeGreeting } = require('../utils/conciergeGreeting');
const MAX_MESSAGE_LENGTH = 800;
const MAX_HISTORY_MESSAGES = 40;
const SESSION_IDLE_MS = 30 * 60 * 1000;
// Basic in-memory rate limit (best-effort).
const ipBuckets = new Map();
const WINDOW_MS = 60_000;
const MAX_REQ_PER_WINDOW = 20;
function takeIpToken(ip) {
const now = Date.now();
const key = String(ip || 'unknown');
const row = ipBuckets.get(key) || { resetAt: now + WINDOW_MS, count: 0 };
if (now > row.resetAt) {
row.resetAt = now + WINDOW_MS;
row.count = 0;
}
row.count += 1;
ipBuckets.set(key, row);
return row.count <= MAX_REQ_PER_WINDOW;
}
async function loadBotOr404(botId) {
if (!mongoose.Types.ObjectId.isValid(String(botId))) return { err: error('VALIDATION_ERROR', 'Invalid bot') };
const bot = await ConciergeBot.findById(botId).lean();
if (!bot) return { err: error('NOT_FOUND', 'Chatbot not found') };
if (!bot.enabled || bot.status !== 'ACTIVE') return { err: error('FORBIDDEN', 'Chatbot is not enabled') };
const event = await Event.findById(bot.event).select('name').lean();
return { bot, event };
}
const config = async (request, h) => {
const { botId } = request.params;
const { bot, event, err } = await loadBotOr404(botId);
if (err) return h.response(err).code(err.error?.code === 'NOT_FOUND' ? 404 : 400);
const eventName = event?.name || undefined;
const greetingTemplate = bot.greeting?.message || '';
return h.response(
success({
botId: String(bot._id),
eventId: String(bot.event),
eventName: eventName || 'Event',
greeting: interpolateConciergeGreeting(greetingTemplate, {
profileName: undefined,
eventName,
}),
maxMessageLength: MAX_MESSAGE_LENGTH,
}),
);
};
const chat = async (request, h) => {
const ip = request.headers['x-forwarded-for'] || request.info.remoteAddress;
if (!takeIpToken(ip)) return h.response(error('RATE_LIMIT', 'Too many requests. Try again in a minute.')).code(429);
const { botId } = request.params;
const { bot, event, err } = await loadBotOr404(botId);
if (err) return h.response(err).code(err.error?.code === 'NOT_FOUND' ? 404 : 400);
const msg = String(request.payload?.message || '').trim();
if (!msg) return h.response(error('VALIDATION_ERROR', 'Message is required')).code(400);
if (msg.length > MAX_MESSAGE_LENGTH) {
return h
.response(error('VALIDATION_ERROR', `Message too long (max ${MAX_MESSAGE_LENGTH} chars)`))
.code(400);
}
let sessionId = String(request.payload?.sessionId || '').trim();
if (!sessionId) sessionId = crypto.randomBytes(16).toString('hex');
let session = await WebChatSession.findOne({ botId: bot._id, sessionId });
const sessionExpired = session && (
session.endedAt ||
(session.lastSeenAt && Date.now() - new Date(session.lastSeenAt).getTime() > SESSION_IDLE_MS)
);
if (sessionExpired) {
sessionId = crypto.randomBytes(16).toString('hex');
session = null;
}
if (!session) {
session = await WebChatSession.create({
botId: bot._id,
eventId: bot.event,
organization: bot.organization,
sessionId,
history: [],
lastSeenAt: new Date(),
});
}
session.history.push({
role: 'user',
text: msg,
ts: new Date(),
modelId: null,
inputTokens: 0,
outputTokens: 0,
});
if (session.history.length > MAX_HISTORY_MESSAGES) {
session.history = session.history.slice(-MAX_HISTORY_MESSAGES);
}
session.lastSeenAt = new Date();
const historyForPrompt = session.history.slice(-12).map((m) => ({ role: m.role, text: m.text }));
const includeEventDetails = bot.knowledge?.includeEventDetails !== false;
const includeFoodRecommendations = bot.knowledge?.includeFoodRecommendations !== false;
const { determineIntentCategory } = require('../utils/conciergeIntent');
const last6ForIntent = session.history.slice(-6).map((m) => ({
role: m.role,
text: m.text,
}));
const intentResult = session.awaitingPersonalEmail
? { category: 'Personal Information', databaseSearchNeeded: true }
: 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 })),
intentResult,
chatSession: session,
organizationId: String(bot.organization),
});
const answerText = pipelineResult?.text || "I don't have that in my knowledge base yet. Please check with event staff or try another question.";
const places = Array.isArray(pipelineResult?.places) ? pipelineResult.places.slice(0, 5) : [];
const foods = Array.isArray(pipelineResult?.foods) ? pipelineResult.foods.slice(0, 5) : [];
const reply = String(answerText || '').trim().slice(0, 1200);
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: 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, places, foods, usage: pipelineResult?.usage || null, intent: intentCategory }));
};
const end = async (request, h) => {
const { botId } = request.params;
const { bot, err } = await loadBotOr404(botId);
if (err) return h.response(err).code(err.error?.code === 'NOT_FOUND' ? 404 : 400);
const sessionId = String(request.payload?.sessionId || '').trim();
if (sessionId) {
await WebChatSession.updateOne(
{ botId: bot._id, sessionId },
{
$set: {
participant: null,
awaitingPersonalEmail: false,
identityVerifiedAt: null,
identityMethod: null,
endedAt: new Date(),
lastSeenAt: new Date(),
},
},
);
}
return h.response(success({ ended: true }));
};
module.exports = {
config,
chat,
end,
};