250 lines
8.7 KiB
JavaScript
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,
|
|
};
|