Eventbuzz_Concierge/src/services/localData.service.js

250 lines
8.7 KiB
JavaScript

const mongoose = require('mongoose');
const Event = require('../models/Event');
const { filterPlacesForQuestion } = require('../utils/placeCategoryFilter');
const { faqAnswerText } = require('../utils/faqAnswer');
const LOCAL_INTENTS = new Set(['tourist_places', 'accommodation']);
const MAX_CACHE_LOAD = 40;
const MAX_ITEMS = 8;
const TEXT_MAX = 200;
const HELPDESK = 'Please ask at the venue help desk if you need more local tips.';
const FAQ_ANSWER_MAX = 600;
function accommodationFaqsFromBot(bot) {
const rows = Array.isArray(bot?.faqs) ? bot.faqs : [];
return rows
.filter((f) => {
const key = String(f?.key || '').trim();
const cat = String(f?.category || '').trim();
return (key === 'accommodation' || cat === 'accommodation') && faqAnswerText(f);
})
.slice(0, 2)
.map((f) => {
const q = String(f?.question || '').trim();
const a = faqAnswerText(f).trim();
const cat = String(f?.category || '').trim();
const key = String(f?.key || cat || 'accommodation').trim() || 'accommodation';
return {
q: q.length > 200 ? `${q.slice(0, 200)}…` : q,
a: a.length > FAQ_ANSWER_MAX ? `${a.slice(0, FAQ_ANSWER_MAX)}…` : a,
key,
category: cat || 'accommodation',
};
})
.filter((f) => f.a);
}
function accommodationProvidedFromFaq(text) {
const t = String(text || '').toLowerCase();
if (!t) return null;
if (/\bno, book your own\b|\bbook your own stay\b|\bnot (provided|arranged)\b/.test(t)) {
return false;
}
if (/\bpartner hotel\b/.test(t)) return false;
if (
/\byes, arranged\b|\barranged by the organizer\b|\bselected attendees\b|\bprovided\b/.test(t)
) {
return true;
}
return null;
}
function isLocalIntent(intent) {
return LOCAL_INTENTS.has(String(intent || ''));
}
function trimText(value, max = TEXT_MAX) {
const s = String(value || '').trim();
if (!s) return null;
return s.length > max ? `${s.slice(0, max)}…` : s;
}
function mapsUrlFor(placeId, raw) {
const fromRaw =
trimText(raw?.google_maps_uri || raw?.googleMapsUri || raw?.mapsUrl || raw?.url, 400);
if (fromRaw) return fromRaw;
const id = String(placeId || '');
if (id.startsWith('ChIJ')) {
return `https://www.google.com/maps/place/?q=place_id:${encodeURIComponent(id)}`;
}
return null;
}
function normalizeTags(raw) {
const fromTags = Array.isArray(raw?.tags) ? raw.tags : [];
const fromTypes = Array.isArray(raw?.types) ? raw.types : [];
return [...fromTags, ...fromTypes]
.map((t) => String(t || '').replace(/_/g, ' ').trim())
.filter(Boolean)
.slice(0, 8);
}
function normalizePlace(raw, i) {
const id = String(raw?.place_id || raw?.id || raw?.placeId || `place-${i}`).trim();
if (!id) return null;
return {
id,
name: trimText(raw?.name, 120) || 'Place',
address: trimText(raw?.address || raw?.formatted_address || raw?.vicinity, TEXT_MAX),
rating: raw?.rating != null ? Number(raw.rating) : null,
distance:
raw?.distance_meters != null
? Number(raw.distance_meters)
: raw?.distance != null
? Number(raw.distance)
: null,
mapsUrl: mapsUrlFor(id, raw),
category: trimText(raw?.category || raw?.primaryType || raw?.type, 80),
tags: normalizeTags(raw),
};
}
function normalizeHotel(raw, i) {
const id = String(raw?.place_id || raw?.placeId || raw?.id || `hotel-${i}`).trim();
if (!id) return null;
return {
id,
name: trimText(raw?.name, 120) || 'Stay',
address: trimText(raw?.address || raw?.formatted_address || raw?.vicinity, TEXT_MAX),
rating: raw?.rating != null ? Number(raw.rating) : null,
distance:
raw?.distance_meters != null
? Number(raw.distance_meters)
: raw?.distance != null
? Number(raw.distance)
: null,
mapsUrl: mapsUrlFor(id, raw),
website: trimText(raw?.website, 400),
priceLevel: raw?.price_level != null ? Number(raw.price_level) : null,
tags: normalizeTags(raw),
};
}
async function loadTouristPlaces(eventId) {
try {
const coll = mongoose.connection.collection('concierge_places');
const doc = await coll.findOne({ eventId: String(eventId) });
if (!doc) return [];
const list = Array.isArray(doc.places)
? doc.places
: Array.isArray(doc.recommendations)
? doc.recommendations
: [];
return list.map(normalizePlace).filter(Boolean).slice(0, MAX_CACHE_LOAD);
} catch (err) {
console.warn('[localData] places cache read failed:', err?.message || err);
return [];
}
}
async function loadHotels(eventId) {
try {
const coll = mongoose.connection.collection('concierge_accommodation');
const doc = await coll.findOne({ eventId: String(eventId) });
if (!doc) return [];
const hotels = Array.isArray(doc.hotels) ? doc.hotels : [];
return hotels.map(normalizeHotel).filter(Boolean).slice(0, MAX_ITEMS);
} catch (err) {
console.warn('[localData] accommodation cache read failed:', err?.message || err);
return [];
}
}
/**
* @param {{ intent: string, eventId: object|string, message?: string, bot?: object }} opts
*/
async function fetchLocalData({ intent, eventId, message = '', bot = null } = {}) {
const intentKey = String(intent || '');
const event = await Event.findById(eventId)
.select('name location participantLogistics')
.lean();
const eventName = String(event?.name || '').trim() || null;
const location = String(event?.location || '').trim() || null;
const faqs = intentKey === 'accommodation' ? accommodationFaqsFromBot(bot) : [];
const faqProvided = faqs.length ? accommodationProvidedFromFaq(faqs.map((f) => f.a).join('\n')) : null;
const hostManaged =
faqProvided != null
? faqProvided
: Boolean(event?.participantLogistics?.hostManagedAccommodation);
const base = {
intent: intentKey,
eventName,
location,
hostManagedAccommodation: hostManaged,
faqs,
places: [],
hotels: [],
requestedCategory: null,
categoryNoMatch: null,
note: null,
};
if (intentKey === 'tourist_places') {
const allPlaces = await loadTouristPlaces(eventId);
const { requestedCategory, places: filtered } = filterPlacesForQuestion(allPlaces, message);
const places = filtered.slice(0, MAX_ITEMS);
base.places = places;
base.requestedCategory = requestedCategory
? { label: requestedCategory.label, broad: Boolean(requestedCategory.broad) }
: null;
if (!allPlaces.length) {
base.note = `No tourist places are cached for this event yet. Suggest the help desk or exploring near ${location || 'the venue'}. Do not invent attractions. ${HELPDESK}`;
return base;
}
if (requestedCategory && !places.length) {
base.categoryNoMatch =
requestedCategory.noMatch ||
`I couldn't find any ${requestedCategory.label} in the available recommendations for this event.`;
base.note = `The attendee asked for ${requestedCategory.label}, but none match in DATA.places (empty). Say so honestly. place_ids []. Do not invent or substitute other place types (e.g. churches for temples). ${HELPDESK}`;
return base;
}
if (requestedCategory && !requestedCategory.broad) {
base.note = `DATA.places are already filtered to ${requestedCategory.label}. Suggest only these. Set place_ids (up to 6). Do not substitute other categories.`;
} else {
base.note =
'Suggest sightseeing from DATA.places only. Set place_ids for useful cards (up to 6). Mention DATA.location when helpful.';
}
return base;
}
if (intentKey === 'accommodation') {
if (faqs.length) {
base.note =
'Answer from DATA.faqs only. Do not invent hotels or say accommodation is provided unless the FAQ says so.';
if (hostManaged) return base;
const hotels = await loadHotels(eventId);
base.hotels = hotels;
if (hotels.length) {
base.note +=
' You may also set hotel_ids from DATA.hotels if they asked for nearby stays.';
}
return base;
}
if (hostManaged) {
base.note =
'The organizer arranges accommodation. Tell the attendee to contact the event team for hotel, check-in dates, and room confirmation. Do not invent hotels. hotel_ids [].';
return base;
}
const hotels = await loadHotels(eventId);
base.hotels = hotels;
base.note = hotels.length
? 'Suggest stays from DATA.hotels only. Set hotel_ids for useful cards (up to 6). Remind them to verify rates/availability themselves.'
: `Accommodation is not arranged by the organizer, and no hotel cache is available. Suggest booking a stay near ${location || 'the venue'} and asking the help desk. Do not invent hotels. ${HELPDESK}`;
return base;
}
return base;
}
module.exports = {
LOCAL_INTENTS,
isLocalIntent,
fetchLocalData,
loadTouristPlaces,
loadHotels,
};