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, };