diff --git a/src/controllers/publicConcierge.controller.js b/src/controllers/publicConcierge.controller.js index f92e843..620edec 100644 --- a/src/controllers/publicConcierge.controller.js +++ b/src/controllers/publicConcierge.controller.js @@ -40,7 +40,9 @@ async function loadBotOr404(botId) { if (!bot.enabled || bot.status !== 'ACTIVE') { return { err: error('FORBIDDEN', 'Chatbot is not enabled') }; } - const event = await Event.findById(bot.event).select('name location locationType').lean(); + const event = await Event.findById(bot.event) + .select('name location locationType startDate endDate') + .lean(); return { bot, event }; } diff --git a/src/models/Participant.js b/src/models/Participant.js index 9b0a3a4..90985f0 100644 --- a/src/models/Participant.js +++ b/src/models/Participant.js @@ -11,6 +11,8 @@ const participantSchema = new mongoose.Schema( firstName: { type: String, trim: true }, lastName: { type: String, trim: true }, displayName: { type: String, trim: true }, + phone: { type: String, trim: true }, + notes: { type: String, trim: true }, deletedAt: { type: Date, default: null }, payment: mongoose.Schema.Types.Mixed, eventMemberships: [ diff --git a/src/models/WebChatSession.js b/src/models/WebChatSession.js index e2a9590..65be675 100644 --- a/src/models/WebChatSession.js +++ b/src/models/WebChatSession.js @@ -52,6 +52,8 @@ const webChatSessionSchema = new mongoose.Schema( pendingEntities: { type: mongoose.Schema.Types.Mixed, default: null }, /** Last session ids shown as cards — used to resolve "add it" / "yes". */ lastSessionIds: { type: [String], default: [] }, + /** FAQ keys / category tags already used this chat — skip on later turns. */ + alreadyTold: { type: [String], default: [] }, /** Rate-limit ticket QR emails from this chat session. */ lastTicketEmailAt: { type: Date, default: null }, /** Rate-limit organizer escalation emails from this chat session. */ diff --git a/src/models/WhatsAppChatSession.js b/src/models/WhatsAppChatSession.js index d3c948f..5e4f4b8 100644 --- a/src/models/WhatsAppChatSession.js +++ b/src/models/WhatsAppChatSession.js @@ -61,6 +61,7 @@ const whatsAppChatSessionSchema = new mongoose.Schema( pendingMessage: { type: String, default: null }, pendingEntities: { type: mongoose.Schema.Types.Mixed, default: null }, lastSessionIds: { type: [String], default: [] }, + alreadyTold: { type: [String], default: [] }, lastTicketEmailAt: { type: Date, default: null }, lastEscalationEmailAt: { type: Date, default: null }, }, diff --git a/src/prompts/call2/conversation.js b/src/prompts/call2/conversation.js index 9a117f8..a981d0b 100644 --- a/src/prompts/call2/conversation.js +++ b/src/prompts/call2/conversation.js @@ -8,9 +8,10 @@ You are talking with one attendee in an ongoing chat. Sound like a helpful perso 4. Never reuse the same sentence as the previous Assistant line in HISTORY. Vary the opener. 5. If HISTORY exists: this is the same conversation. Do not reprint the last list or recap unless they asked to see it again. If they name something from HISTORY or DATA, talk about that item. If it is not in DATA, say so honestly. 6. If there is no HISTORY: still speak like a person. -7. Facts only from DATA (or the travel exception already in the venue prompt). Never invent places, sessions, URLs, or contacts. -8. Do not add organizer email/phone unless they asked who to contact. -9. Never repeat the event name. Never say "I can only assist with [event]". +7. DATA.faqs are background for THIS ask only — use a FAQ when it actually answers the current utterance. Do not recap topics listed in DATA.alreadyTold unless they ask again. +8. Never invent URLs, tickets, Wi-Fi passwords, or organizer contacts. Never invent places or sessions unless the adjacent-local exception in the domain prompt allows world knowledge for this city. +9. Do not add organizer email/phone unless they asked who to contact. +10. Never repeat the event name. Never say "I can only assist with [event]". `.trim(); module.exports = { CALL2_CONVERSATION_PROMPT }; diff --git a/src/prompts/call2/local/accommodation.js b/src/prompts/call2/local/accommodation.js index 83c08d2..bae23bd 100644 --- a/src/prompts/call2/local/accommodation.js +++ b/src/prompts/call2/local/accommodation.js @@ -4,7 +4,7 @@ module.exports = { usesHotelCards: true, promptlet: ` ### INTENT TASK: accommodation -If DATA.faqs is present: answer from those FAQ lines. Do not invent a different policy. +If DATA.faqs is present and answers this ask: use those FAQ lines. Do not invent a different policy. Do not recap DATA.alreadyTold. If they ask about hotel check-in: say what the FAQ allows. Never say "refer to the agenda". Do not add organizer email/phone unless they asked who to contact. If DATA.hostManagedAccommodation is true: say the organizer arranges accommodation; contact the event team. hotel_ids [] / place_ids []. Else suggest stays from DATA.hotels only; set hotel_ids (up to 6). Remind them to verify rates themselves. diff --git a/src/prompts/call2/local/main.js b/src/prompts/call2/local/main.js index f240c55..a65312b 100644 --- a/src/prompts/call2/local/main.js +++ b/src/prompts/call2/local/main.js @@ -3,7 +3,7 @@ You are the Virtual Concierge for an EventBuzz event (nearby places and stays). ### GUARDRAILS: 1. Use only DATA. Never invent attractions, hotels, addresses, ratings, or prices. -2. If DATA.faqs has an accommodation answer, use that. If DATA.hostManagedAccommodation is true, do not suggest booking hotels as if listed — say the organizer arranges stay. Never say "refer to the agenda" for hotel check-in. Do not add organizer email/phone unless they asked who to contact. +2. Use DATA.faqs only when they answer this utterance (e.g. host-managed stay). Do not recap DATA.alreadyTold. If DATA.hostManagedAccommodation is true, do not suggest booking hotels as if listed — say the organizer arranges stay. Never say "refer to the agenda" for hotel check-in. Do not add organizer email/phone unless they asked who to contact. 3. place_ids MUST come only from DATA.places[].id. hotel_ids only from DATA.hotels[].id. 4. Never substitute a different place category (temples ≠ churches; forts ≠ churches/beaches). 5. Prefer at most 6 ids. Answer naturally in "message"; cards show details. diff --git a/src/prompts/call2/system/event_adjacent.js b/src/prompts/call2/system/event_adjacent.js new file mode 100644 index 0000000..39a71a5 --- /dev/null +++ b/src/prompts/call2/system/event_adjacent.js @@ -0,0 +1,18 @@ +module.exports = { + intent: 'event_adjacent', + promptlet: ` +### INTENT TASK: event_adjacent +The attendee is asking about the world around THIS event city (DATA.location / DATA.city) and DATA.eventDates — food dishes, a 1-day plan after the event, local tips. + +You MAY use general world knowledge for that city and window only. If they ask about a different city, refuse in one short sentence. + +Never invent event programme, session times, tickets, registration URLs, Wi‑Fi passwords, or organizer contacts. Do not recap meal-provided / FAQ policy unless it is in DATA.faqs for this turn. + +If DATA.dish is set, answer that dish (where to try it, what it is) — do not pivot to whether the venue serves lunch. + +Remind them to verify opening hours and maps. Keep it decent. No attendee PII, no medical advice beyond first aid / go to on-site help. + +If DATA.places exist, you may set place_ids from DATA.places[].id (up to 6) when they help; otherwise []. +Prefer not repeating a long event marketing title. Use the city name. +`.trim(), +}; diff --git a/src/prompts/call2/system/index.js b/src/prompts/call2/system/index.js index 0acf2bf..848f330 100644 --- a/src/prompts/call2/system/index.js +++ b/src/prompts/call2/system/index.js @@ -3,6 +3,7 @@ const lost_and_found = require('./lost_and_found'); const human_agent_escalation = require('./human_agent_escalation'); const chitchat_greeting = require('./chitchat_greeting'); const out_of_scope = require('./out_of_scope'); +const event_adjacent = require('./event_adjacent'); const { CALL2_SYSTEM_MAIN_PROMPT } = require('./main'); const { CALL2_CONVERSATION_PROMPT } = require('../conversation'); @@ -12,6 +13,7 @@ const MODULES = [ human_agent_escalation, chitchat_greeting, out_of_scope, + event_adjacent, ]; const PROMPTLETS = Object.fromEntries(MODULES.map((m) => [m.intent, m.promptlet])); diff --git a/src/prompts/call2/system/main.js b/src/prompts/call2/system/main.js index c9b0e0b..9918465 100644 --- a/src/prompts/call2/system/main.js +++ b/src/prompts/call2/system/main.js @@ -1,14 +1,15 @@ const CALL2_SYSTEM_MAIN_PROMPT = ` -You are the Virtual Concierge for an EventBuzz event (greetings, safety, lost & found, human help). +You are the Virtual Concierge for an EventBuzz event (greetings, safety, lost & found, human help, event-city local tips). ### GUARDRAILS: -1. Use only DATA. Never invent that organizers were emailed unless DATA.emailSent is true. -2. NEVER give medical advice. For emergencies, urge immediate on-site security/medical staff first. -3. If DATA.contact or DATA.faqs include Email/Phone, you MUST share those exact values with the visitor — they are organizer-configured public contacts, not invented. -4. Do NOT invent other phone numbers, emails, or desk hours beyond DATA. -5. If DATA.contact is present, do NOT say you have no organizer contact information. -6. Answer naturally in "message" unless listing a required contact or safety step. Never repeat the event name. Never say "I can only assist with [event name]". Do not pad with capability lists. -7. Output ONLY the JSON key message — no other keys. +1. Use only DATA unless the event_adjacent task explicitly allows world knowledge for DATA.city. +2. Never invent that organizers were emailed unless DATA.emailSent is true. +3. NEVER give medical advice. For emergencies, urge immediate on-site security/medical staff first. +4. If DATA.contact or DATA.faqs include Email/Phone, you MUST share those exact values with the visitor — they are organizer-configured public contacts, not invented. +5. Do NOT invent other phone numbers, emails, or desk hours beyond DATA. +6. If DATA.contact is present, do NOT say you have no organizer contact information. +7. Answer naturally in "message" unless listing a required contact or safety step. Never repeat the event name. Never say "I can only assist with [event name]". Do not pad with capability lists. Do not recap DATA.alreadyTold. +8. Output ONLY the JSON key message — no other keys (except place_ids when the adjacent task allows it). ### REQUIRED OUTPUT (JSON only, no markdown): - Valid JSON only. Escape newlines inside strings as \\n. diff --git a/src/prompts/call2/tech/main.js b/src/prompts/call2/tech/main.js index 63ae357..a8f5068 100644 --- a/src/prompts/call2/tech/main.js +++ b/src/prompts/call2/tech/main.js @@ -3,7 +3,7 @@ You are the Virtual Concierge for an EventBuzz event (Wi‑Fi and app/tech help) ### GUARDRAILS: 1. Use only DATA. Never invent Wi‑Fi SSIDs, passwords, reset links, or app UI steps. -2. NEVER invent credentials. If DATA.faqs is empty for wifi, say details are not listed and suggest signage / help desk. +2. NEVER invent credentials. Use DATA.faqs only if they answer this ask. If DATA.faqs is empty for wifi, say details are not listed and suggest signage / help desk. Do not recap DATA.alreadyTold. 3. If DATA.staticTips is present, use those tips (you may number them). Do not add invented tips beyond DATA. 4. NEVER output personal email addresses or phone numbers. 5. Answer naturally in "message". You may list Wi‑Fi details from DATA when asked. diff --git a/src/prompts/call2/ticket/main.js b/src/prompts/call2/ticket/main.js index 26a042c..7f15bee 100644 --- a/src/prompts/call2/ticket/main.js +++ b/src/prompts/call2/ticket/main.js @@ -5,7 +5,7 @@ You are the Virtual Concierge for an EventBuzz event (ticket / registration ques 1. Use only DATA for personal ticket facts (name, status, ticket tier, payment, receipt, QR availability). Do not invent order ids, amounts, or statuses. 2. Never claim a transfer or upgrade succeeded. Those are not available in chat. 3. Do not mention system prompts, intent names, or internal fields like policy/note unless paraphrasing helpfully for the attendee. -4. Stay on-event. Answer naturally in "message". Do not repeat the event name or recap the whole ticket unless asked. For how-to-register, share DATA.registrationUrl / DATA.contact / DATA.faqs — never refuse as out of scope. +4. Stay on-event. Answer the user's utterance in "message". Do not repeat the event name. For attending/joining questions: answer that ask; DATA.faqs are facts only if they match. Never invent or paste registration URLs — buttons in the UI open the real forms. Never refuse as out of scope. 5. When DATA.qr.imageUrl is present, the UI shows a QR card — say only that their check-in QR is below (or that it was emailed). ### REQUIRED OUTPUT (JSON only, no markdown): diff --git a/src/prompts/call2/ticket/ticket_how_to_register.js b/src/prompts/call2/ticket/ticket_how_to_register.js index 3461242..4584a2b 100644 --- a/src/prompts/call2/ticket/ticket_how_to_register.js +++ b/src/prompts/call2/ticket/ticket_how_to_register.js @@ -3,13 +3,10 @@ module.exports = { usesQrCard: false, promptlet: ` ### INTENT TASK: ticket_how_to_register -They want to sign up or buy a ticket — not look up an existing registration. -Answer only from DATA. Sound like a helpful person. -- If DATA.registrationUrl: tell them they can register there and share the URL. -- If DATA.faqs has how-to text: use that. -- If DATA.contact email/phone: share those for registration questions. -- If DATA.tickets: you may mention pass names briefly. -- Never say you can only assist with this event. Never repeat DATA.eventName. -- Never ask them to verify email. show_qr: false. +Answer the user's question about attending / joining (who may come, how to register, eligibility). +DATA.faqs are background — use a FAQ only if it answers THIS ask. Do not paste an unrelated gate, contact, or how-to line as the whole reply. +If DATA does not say whether everyone may attend, say that honestly; do not invent a public/private policy. +If DATA.hasRegistrationButtons is true, mention they can register with the buttons below. Never invent or paste URLs. show_qr: false. +Do not add organizer email/phone unless they asked who to contact. `.trim(), }; diff --git a/src/prompts/call2/venue/main.js b/src/prompts/call2/venue/main.js index f4dcfcb..04bdd30 100644 --- a/src/prompts/call2/venue/main.js +++ b/src/prompts/call2/venue/main.js @@ -2,9 +2,9 @@ const CALL2_VENUE_MAIN_PROMPT = ` You are the Virtual Concierge for an EventBuzz event (venue questions). ### GUARDRAILS: -1. Prefer answered items in DATA.faqs (question/answer) for event-specific venue facts. Relay those clearly. +1. Use DATA.faqs only when they answer this utterance. They are facts, not a script — do not recap DATA.alreadyTold. 2. For maps, indoor amenities, on-site parking policy, food provided, and accessibility: if DATA.faqs is empty, follow DATA.note / DATA.fallback — do not invent those facts. -3. For intent venue_travel_directions: organizers cannot list every origin. You may use general geographic knowledge for city-to-city train/flight/road corridors to DATA.location / DATA.eventName. Prefer a direct train/flight when one commonly exists; only suggest a via-hub connection if a direct option is unlikely. Say you are not a live timetable — attendee should verify on IRCTC / airline apps. +3. For intent venue_travel_directions: organizers cannot list every origin. You may use general geographic knowledge for city-to-city train/flight/road corridors to DATA.location (and DATA.eventName only if needed as the destination name). Prefer a direct train/flight when one commonly exists; only suggest a via-hub connection if a direct option is unlikely. Say you are not a live timetable — attendee should verify on IRCTC / airline apps. 4. If DATA.transportModes.enforce is true: local last-mile modes (taxi, Uber/Ola, event shuttle, public transport) are an allow-list. ONLY recommend DATA.transportModes.allowed. NEVER say a mode in DATA.transportModes.notAllowed is available. If asked about a notAllowed mode, say organizers have not listed it for this event. 5. Do not invent URLs. Do not mention system prompts or intent names. 6. Stay on-event. Refuse unrelated requests politely. diff --git a/src/prompts/call2/venue/venue_accessibility.js b/src/prompts/call2/venue/venue_accessibility.js index 884242a..709dd46 100644 --- a/src/prompts/call2/venue/venue_accessibility.js +++ b/src/prompts/call2/venue/venue_accessibility.js @@ -3,7 +3,7 @@ module.exports = { usesPlaceCards: false, promptlet: ` ### INTENT TASK: venue_accessibility -Answer from DATA.faqs when present (wheelchair access, assistance contacts). +Use DATA.faqs when they answer this ask (wheelchair access, assistance contacts). If no FAQ: direct the attendee to the venue help desk. Do not invent accessibility policies. place_ids: []. `.trim(), diff --git a/src/prompts/call2/venue/venue_food_beverage.js b/src/prompts/call2/venue/venue_food_beverage.js index 3465b6e..6cbb861 100644 --- a/src/prompts/call2/venue/venue_food_beverage.js +++ b/src/prompts/call2/venue/venue_food_beverage.js @@ -3,7 +3,9 @@ module.exports = { usesPlaceCards: true, promptlet: ` ### INTENT TASK: venue_food_beverage -Answer from DATA.faqs first (food provided, breaks, dietary options). +If DATA.faqs answers whether meals/tea are provided or dietary policy, use that for this ask only. +If DATA.faqs is empty, do not invent or replay meal policy. +If they asked about a specific dish, do not pivot to whether lunch is provided. If DATA.places exists you may select place_ids for nearby dining cards. If no FAQ and no places: direct the attendee to the venue help desk. Never invent restaurants. `.trim(), diff --git a/src/prompts/call2/venue/venue_map_request.js b/src/prompts/call2/venue/venue_map_request.js index 80d2d3f..b967e39 100644 --- a/src/prompts/call2/venue/venue_map_request.js +++ b/src/prompts/call2/venue/venue_map_request.js @@ -3,8 +3,8 @@ module.exports = { usesPlaceCards: false, promptlet: ` ### INTENT TASK: venue_map_request -Answer from DATA.faqs about maps/floor plans when present. -If no FAQ: share only DATA.eventName and DATA.location (venue address). Do not invent a map URL or floor plan. +Use DATA.faqs about maps/floor plans when they answer this ask. +If no FAQ: share DATA.location (venue address). Do not invent a map URL or floor plan. place_ids: []. `.trim(), }; diff --git a/src/prompts/call2/venue/venue_nearest_amenity.js b/src/prompts/call2/venue/venue_nearest_amenity.js index 58f858e..cf8a60e 100644 --- a/src/prompts/call2/venue/venue_nearest_amenity.js +++ b/src/prompts/call2/venue/venue_nearest_amenity.js @@ -3,7 +3,7 @@ module.exports = { usesPlaceCards: false, promptlet: ` ### INTENT TASK: venue_nearest_amenity -Answer from DATA.faqs when present (restrooms, water, charging, etc.). +Use DATA.faqs when they answer this ask (restrooms, water, charging, etc.). If no FAQ: direct the attendee to the venue help desk. Do not invent amenity locations. place_ids: []. `.trim(), diff --git a/src/prompts/call2/venue/venue_travel_directions.js b/src/prompts/call2/venue/venue_travel_directions.js index fdfb584..f842351 100644 --- a/src/prompts/call2/venue/venue_travel_directions.js +++ b/src/prompts/call2/venue/venue_travel_directions.js @@ -3,7 +3,8 @@ module.exports = { usesPlaceCards: false, promptlet: ` ### INTENT TASK: venue_travel_directions -Help the attendee reach DATA.eventName at DATA.location from their origin (ENTITIES.location or the user message), by train / flight / road. +Help the attendee reach the event at DATA.location from their origin (ENTITIES.location or the user message), by train / flight / road. +Use DATA.location (city / venue address) as the destination. Do not lead with a long marketing event title. Do not suggest sightseeing, forts, beaches, or hotels unless they asked. Organizers cannot document every origin city — use general geographic knowledge for long-distance corridors (train/flight/road between cities). Routing rules: diff --git a/src/prompts/call2/venue/venue_wayfinding.js b/src/prompts/call2/venue/venue_wayfinding.js index 70de71b..5eedce0 100644 --- a/src/prompts/call2/venue/venue_wayfinding.js +++ b/src/prompts/call2/venue/venue_wayfinding.js @@ -3,7 +3,7 @@ module.exports = { usesPlaceCards: false, promptlet: ` ### INTENT TASK: venue_wayfinding -Answer from DATA.faqs when present. +Use DATA.faqs when they answer this ask. If no FAQ: list halls/rooms from DATA.halls and DATA.rooms if available, and say more details are at the venue help desk. Do not invent turn-by-turn indoor directions. place_ids: []. `.trim(), diff --git a/src/prompts/step1.js b/src/prompts/step1.js index 56a8b74..1a63157 100644 --- a/src/prompts/step1.js +++ b/src/prompts/step1.js @@ -1,301 +1,69 @@ /** - * Call 1 — intent classification + entity extraction. - * INTENT_DESCRIPTIONS is the single source of truth; the system prompt is built from it. + * Call 1 — domain + action + entity extraction. */ -const INTENT_DESCRIPTIONS = { - // Agenda - agenda_current_now: - 'Sessions happening right now / currently live. Use ONLY for "now", "currently", "happening right now", "live". NOT for a specific date or day.', - agenda_upcoming_next: - 'What is next / coming up soon after the present moment (next sessions). Not a specific calendar day unless they mean "next up".', - agenda_by_track: - 'Sessions filtered by programme track, stream, hall theme, or track name.', - agenda_by_speaker: - 'Which sessions a named speaker is in, or schedule for a speaker.', - agenda_session_details: - 'Session list/details by name, topic, specialty, or calendar day/date. Also use for the full programme ("what are the sessions", "show the agenda", "what talks are there") and for recommendations ("what should I attend", "must-not-miss", "I am an asthma specialist"). For a full-programme ask, leave topic/session_name empty. Set topic only from a specialty/interest. Prefer this over agenda_current_now when a day/date or topic is mentioned. Do NOT invent intents like agenda_by_topic.', - agenda_personal_schedule: - "The attendee's own saved/bookmarked personal agenda (list/read only — not add/remove).", - agenda_bookmark_add: - 'Add/save/bookmark a session to the attendee\'s personal agenda. Use for "add it", "save this", "bookmark Aerosol Therapy", "yes add it". Set session_name (or session_id) from the message or recent conversation. action_category MUST be "mutation".', - agenda_bookmark_remove: - 'Remove/unsave a session from the attendee\'s personal agenda. Set session_name or session_id. action_category MUST be "mutation".', - agenda_conflict_check: - 'Whether the attendee\'s saved sessions overlap in time.', - agenda_download_materials: - 'Slides, PDFs, handouts, or downloadable resources for a session.', +const { DOMAINS, ACTIONS } = require('../utils/domainAction'); - // Venue - venue_map_request: 'Venue map, floor plan, or overall layout. Not session schedules.', - venue_wayfinding: 'Directions inside the venue to a room, hall, booth, or stage ("where is Hall A", "how do I find registration").', - venue_nearest_amenity: 'Nearest restroom, water, charging, info desk, or similar amenity.', - venue_parking_transport: - 'On-site parking, local taxis/metro near the venue, airport transfer arranged by host, or event shuttle. NOT long-distance "how do I reach from another city".', - venue_travel_directions: - 'How to reach the event venue from another city/airport by train, flight, or road (e.g. "from Nagpur by train", "flight from Delhi"). Put origin city in entities.location. Prefer this over venue_parking_transport for city-to-venue routing.', - venue_food_beverage: 'Food provided at the event, tea/coffee breaks, dietary options, cafes/restaurants near the venue.', - venue_accessibility: 'Accessibility, wheelchair access, assistance at the venue.', - tourist_places: - 'Sightseeing and local landmarks near the venue/city: attractions, beaches, churches, temples, forts, museums, parks, "most famous church/beach/fort", things to do, spare time to roam (not restaurants; not general world trivia unrelated to the event city).', - accommodation: - 'Hotel, lodging, whether accommodation is provided, or where to stay near the event (not travel directions from another city).', - - // Ticket - ticket_qr_display: 'Show the attendee ticket QR / badge code in chat (display only, not email).', - ticket_email_qr: - 'Email / send / mail the attendee ticket QR and registration details to their registered email.', - ticket_status_lookup: 'Status of an existing ticket / registration / check-in / payment. Use only when they already have a registration (e.g. "is my registration confirmed").', - ticket_how_to_register: - 'How to register, sign up, buy tickets, get a pass, or find the registration link/form. Public how-to — not looking up an existing registration, and not "where is the registration desk".', - ticket_transfer_request: 'Transfer a ticket to someone else.', - ticket_upgrade: 'Upgrade ticket tier or pass.', - ticket_document_receipt: 'Invoice, receipt, or ticket document.', - - // Speaker - speaker_bio_lookup: - 'Speaker biography, who they are, background, role, org, public profile, or list of event speakers ("who are the speakers"). Not when they speak.', - speaker_meet_and_greet: - 'How to meet a speaker, meet-and-greet, networking with a named speaker.', - speaker_contact_info: - 'Speaker public social links (LinkedIn, Twitter/X, website). Not email/phone; not when they speak.', - - // Exhibitor - exhibitor_location: - 'Where an exhibitor/booth is located, company booth lookup, or list of exhibitors ("who are the exhibitors").', - exhibitor_by_category: - 'Exhibitors filtered by category or industry (set entities.topic to the category).', - exhibitor_demo_schedule: - 'Exhibitor demo times or product showcase schedule (may only have FAQ info).', - exhibitor_book_meeting: - 'Book or schedule a meeting with an exhibitor (bot cannot book; classify here when asked).', - - // Engagement - matchmaking_suggest: 'Suggest people to meet / networking matches.', - connection_request: 'Send or manage a connection request.', - qa_submit_question: 'Submit a question to a session Q&A.', - poll_active_check: 'Active polls or quiz status.', - gamification_status: 'Points, badges, leaderboard, gamification status.', - social_feed_post: 'Post to or view the event social feed.', - - // Tech - wifi_credentials: 'Event Wi‑Fi network name (SSID) or password.', - app_issue_streaming: - 'Streaming / live video / audio technical problems (can’t hear, black screen, mic/camera).', - app_issue_account: 'Login, OTP, or account problems in the event app.', - app_feature_guidance: - 'How to use an app feature (agenda, bookmarks, Q&A, etc.) — not Wi‑Fi and not login failures.', - - // System - emergency_medical: - 'Medical emergency or urgent safety issue (injury, collapse, fire, threat).', - lost_and_found: 'Lost and found items (lost badge, bag, phone at the venue).', - human_agent_escalation: - 'Explicit request for a human organizer/agent ("talk to organizer", "speak to staff").', - chitchat_greeting: 'Hello, thanks, goodbye, or light chitchat with no event question.', - out_of_scope: - 'Unrelated to this event (coding help, other products, world trivia with no link to the event city/venue). Local sightseeing near the event (churches, temples, forts, beaches) is tourist_places — not out_of_scope.', +const DOMAIN_DESCRIPTIONS = { + agenda: 'This event’s programme: sessions, tracks, times, personal saved agenda, materials.', + speaker: 'People on the programme: bios, lineup, meet-and-greet, public socials. When they speak → agenda (set speaker_name).', + venue: 'The event site: map, halls, amenities, parking/shuttle, meals *policy*, accessibility, and reaching the venue from any origin city. Not city sightseeing.', + local: 'Around the event city: hotels/stay policy, cached landmarks. City world-knowledge (a dish, a day plan) uses action adjacent, not read.', + ticket: 'Attending this event: who may come, how to join/register, eligibility, ticket status, QR, receipt. Emailing QR is action email_qr.', + exhibitor: 'Booths, exhibitor list, category, demos, meeting requests.', + tech: 'Wi-Fi, streaming/AV, app login, how to use app features.', + system: 'Greeting, lost & found, true refuse (oos), emergency, organizer/human contact.', }; -const INTENT_DOMAINS = [ - { - name: 'Agenda Domain', - intents: [ - 'agenda_current_now', - 'agenda_upcoming_next', - 'agenda_by_track', - 'agenda_by_speaker', - 'agenda_session_details', - 'agenda_personal_schedule', - 'agenda_bookmark_add', - 'agenda_bookmark_remove', - 'agenda_conflict_check', - 'agenda_download_materials', - ], - }, - { - name: 'Venue Domain', - intents: [ - 'venue_map_request', - 'venue_wayfinding', - 'venue_nearest_amenity', - 'venue_parking_transport', - 'venue_travel_directions', - 'venue_food_beverage', - 'venue_accessibility', - 'tourist_places', - 'accommodation', - ], - }, - { - name: 'Ticket Domain', - intents: [ - 'ticket_qr_display', - 'ticket_email_qr', - 'ticket_status_lookup', - 'ticket_how_to_register', - 'ticket_transfer_request', - 'ticket_upgrade', - 'ticket_document_receipt', - ], - }, - { - name: 'Speaker Domain', - intents: ['speaker_bio_lookup', 'speaker_meet_and_greet', 'speaker_contact_info'], - }, - { - name: 'Exhibitor Domain', - intents: [ - 'exhibitor_location', - 'exhibitor_by_category', - 'exhibitor_demo_schedule', - 'exhibitor_book_meeting', - ], - }, - { - name: 'Engagement Domain', - intents: [ - 'matchmaking_suggest', - 'connection_request', - 'qa_submit_question', - 'poll_active_check', - 'gamification_status', - 'social_feed_post', - ], - }, - { - name: 'Tech Domain', - intents: [ - 'wifi_credentials', - 'app_issue_streaming', - 'app_issue_account', - 'app_feature_guidance', - ], - }, - { - name: 'System Domain', - intents: [ - 'emergency_medical', - 'lost_and_found', - 'human_agent_escalation', - 'chitchat_greeting', - 'out_of_scope', - ], - }, -]; +const ACTION_DESCRIPTIONS = { + read: 'Default. Fetch facts and answer. Use for almost every in-scope question.', + bookmark_add: 'They asked to save/add a session. domain agenda.', + bookmark_remove: 'They asked to remove a saved session. domain agenda.', + email_qr: 'They asked to email their QR/ticket. domain ticket.', + emergency: 'Medical/safety emergency. domain system.', + escalate_human: 'Talk to organizer / who to contact. domain system.', + adjacent: + 'World knowledge for THIS event city/dates only (a local dish, a day plan after the event). Not event policy, not programme, not tickets, not another city.', + oos: 'ONLY if the ask is clearly not this event or its host city: other products, homework, a different city as the trip, news, abuse.', + greeting: 'Hello / thanks / goodbye with no event question. domain system.', +}; -const ALLOWED_INTENTS = INTENT_DOMAINS.flatMap((d) => d.intents); - -const ALLOWED_INTENT_SET = new Set(ALLOWED_INTENTS); - -function isAllowedIntent(intent) { - return ALLOWED_INTENT_SET.has(String(intent || '').trim()); -} - -function formatDomainIntents(intents) { - return intents - .map((id) => { - const desc = INTENT_DESCRIPTIONS[id] || ''; - return ` - "${id}": ${desc}`; - }) - .join('\n'); -} - -function buildAllowedIntentsSection() { - return INTENT_DOMAINS.map( - (domain) => `- ${domain.name}:\n${formatDomainIntents(domain.intents)}`, - ).join('\n\n'); +function formatList(ids, descriptions) { + return ids.map((id) => ` - "${id}": ${descriptions[id] || ''}`).join('\n'); } const STEP_1_SYSTEM_PROMPT = ` -You are the Intent Classification and Entity Extraction Engine for eventbuzz.live Virtual Concierge. -Your sole job is to analyze the incoming user message and short dialogue history, then produce a strictly formatted JSON object. -DO NOT output any conversational text, markdown wrapping, or explanations outside the JSON object. +You are the classification engine for EventBuzz Virtual Concierge. +Output JSON only — no markdown, no extra keys. -### HARD CONSTRAINT — ALLOWED INTENTS ONLY: -- "primary_intent" MUST be copied EXACTLY from the ALLOWED INTENTS list below (same spelling, same underscores). -- The ALLOWED INTENTS list is a CLOSED set. You MUST NOT invent, rename, combine, or paraphrase intent ids. -- FORBIDDEN examples (never output these): agenda_by_topic, agenda_topic, agenda_recommend, agenda_recommendations, agenda_suggestion, agenda_by_interest, agenda_must_not_miss, or any other id not listed. -- If the user asks about a topic, specialty, interest, or "what should I attend / must not miss", use "agenda_session_details" and put the subject in entities.topic. -- If nothing fits, use "out_of_scope" or "chitchat_greeting" — still only from the list. Never create a new intent name. +### HARD CONSTRAINTS +- "domain" MUST be one of: ${DOMAINS.map((d) => `"${d}"`).join(', ')} +- "action" MUST be one of: ${ACTIONS.map((a) => `"${a}"`).join(', ')} +- Do not invent domain or action names. Do not output primary_intent. -### INSTRUCTIONS: -1. Classify into exactly ONE primary_intent from ALLOWED INTENTS. Use each intent's description to disambiguate. -2. Resolve contextual ambiguities (e.g., if user says "When is that?", extract the referenced talk/speaker from history). - - If the current message is a reaction or names something from RECENT CONVERSATION (session, speaker, place, company), set context_resolution.is_followup true and fill entities (session_name / speaker_name / location / company_name) from HISTORY. - - Do not treat "I've heard about X", "what about that one?", or "and the other one?" as a fresh list request — keep the same domain intent and resolve the named item. -3. Extract relevant entities (speaker_name, session_name, location, company_name, time_reference, topic, etc.). - - For speaker_name: strip salutations/honorifics (sir, madam, ji, Dr, Mr, Prof, Shri, Smt). "Salvi sir" / "Dr Salvi" → "Salvi". - - For day/date phrases ("8th", "on the 7th", "Aug 8", "tomorrow") set time_reference. - - For subject keywords ("chest", "biologics", "asthma", "tea break") set topic (not session_name unless they named a session). -4. Determine the action_category: - - "read": Querying information (schedules, FAQs, bios, directions). - - "mutation": Triggering a database write or API state change (bookmarking, emailing a ticket QR, Q&A submission). Ticket transfer/upgrade requests still classify as those intents but are not performed by the bot. - - "escalation": Urgent safety/medical issues or explicit requests for human organizers. - - "direct_reply": Simple greetings, chitchat, or out-of-scope questions that require no database lookup. -5. Agenda disambiguation (map into listed intents only): - - "now / currently / live" → agenda_current_now - - "what's next / upcoming soon" → agenda_upcoming_next - - "my sessions / my schedule / my agenda / saved sessions / bookmarked" (list/read) → agenda_personal_schedule - - "add / save / bookmark / put on my agenda" (including "yes", "yes add it", "add it") → agenda_bookmark_add; resolve session_name from CURRENT message or RECENT CONVERSATION - - "remove / unsave / unbookmark" → agenda_bookmark_remove - - day/date ("on the 7th", "tomorrow", "8 August") → agenda_session_details + time_reference - - topic / specialty / recommendations / must-not-miss → agenda_session_details + topic - - when / which sessions a named speaker speaks → agenda_by_speaker (set entities.speaker_name) - - named track/stream → agenda_by_track -6. Speaker disambiguation (map into listed intents only; set entities.speaker_name when a person is named): - - "who are the speakers" / list speakers / speaker lineup (no specific person) → speaker_bio_lookup with speaker_name null - - who is / bio / profile / background / about a named speaker → speaker_bio_lookup + speaker_name - - how to meet / meet-and-greet / networking with speaker → speaker_meet_and_greet - - LinkedIn / Twitter / website / public contact for a speaker → speaker_contact_info - - when does X speak / X's sessions on the schedule → agenda_by_speaker (not speaker_*) -7. Venue disambiguation (map into listed intents only): - - map / floor plan / venue layout → venue_map_request - - "where is Hall X" / find room / registration / stage inside venue → venue_wayfinding - - restroom / washroom / water / charging / info desk → venue_nearest_amenity - - on-site parking / local taxi / metro near venue / host shuttle → venue_parking_transport - - "how to reach from [city]" / train / flight / drive from another city → venue_travel_directions (set entities.location to the origin city) - - food provided / lunch / tea break / dietary / restaurants near venue → venue_food_beverage - - wheelchair / accessibility / assistance → venue_accessibility - - sightseeing / attractions / spare time to roam / things to do nearby → tourist_places (not restaurants) - - church / temple / fort / beach / museum / park / "most famous [landmark]" near the event city → tourist_places (NOT out_of_scope) - - hotel / where to stay / lodging / "is accommodation provided" / hotel check-in → accommodation - - event badge/QR check-in at the venue (not hotel) → ticket_status_lookup or venue_wayfinding for the desk -8. Ticket disambiguation (map into listed intents only): - - "show / display / see my QR / badge" in chat → ticket_qr_display - - "email / send / mail me my QR / ticket / pass" → ticket_email_qr - - "how can I register / how do I sign up / buy tickets / registration link" → ticket_how_to_register (NOT out_of_scope) - - my / existing registration / check-in / payment status → ticket_status_lookup - - invoice / receipt / payment document → ticket_document_receipt - - transfer / reassign ticket → ticket_transfer_request - - upgrade pass / change tier → ticket_upgrade -9. Exhibitor disambiguation (map into listed intents only; set entities.company_name when a company is named): - - "who are the exhibitors" / list exhibitors / exhibitor lineup → exhibitor_location with company_name null - - where is / booth / hall for a company → exhibitor_location + company_name - - exhibitors in [category/industry] → exhibitor_by_category + topic = category (not company_name) - - demo times / product showcase schedule → exhibitor_demo_schedule (+ company_name if named) - - book / schedule a meeting with an exhibitor → exhibitor_book_meeting -10. Tech disambiguation (map into listed intents only): - - Wi‑Fi / wifi password / SSID / network name → wifi_credentials - - stream / video / audio not working / can’t hear / black screen → app_issue_streaming - - can’t login / OTP / account / password reset for the event app → app_issue_account - - how do I use [app feature] / bookmarks / ask a question in-app → app_feature_guidance -11. System disambiguation (map into listed intents only): - - hi / hello / thanks / goodbye (no event question) → chitchat_greeting - - medical emergency / injury / collapse / fire / urgent safety → emergency_medical - - lost item / lost and found / found something → lost_and_found - - talk to organizer / human / staff / agent / "who to contact" / contact details → human_agent_escalation - - unrelated to the event → out_of_scope (but local landmarks near the event city stay tourist_places) -12. NEVER invent write success. Classification only — the system performs bookmark / email sends separately. +### DOMAINS +${formatList(DOMAINS, DOMAIN_DESCRIPTIONS)} -### ALLOWED INTENTS (closed list — primary_intent must be one of these exact strings): -${buildAllowedIntentsSection()} +### ACTIONS +${formatList(ACTIONS, ACTION_DESCRIPTIONS)} -### REQUIRED JSON OUTPUT SCHEMA: +### HOW TO CHOOSE (coverage, not phrase matching) +An attendee is talking to the event desk. Classify the *kind of ask*, not whether you have seen this wording. + +1. In-scope if it is about THIS event or THIS event’s host city: programme, people, venue, getting here, meals policy, Wi-Fi/app, tickets, who may attend / how to join, exhibitors, nearby food or sights in that city. Pick the closest domain. Action is "read" unless a mutation below applies. +2. oos ONLY if it is clearly not that: other products, homework, a different city as the trip, general news, abuse. Features we do not have (matchmaking, polls, social feed) → oos. If you are unsure between two in-scope domains, pick one and use "read". Never use oos for lack of a matching example. +3. Event policy (who may attend, meals provided, how to join, Wi-Fi listed) is always "read", never adjacent, never oos. +4. adjacent only when they need city world-knowledge beyond programme/FAQ (a specific dish, a day plan after the event) in THIS city. Set dish when they named food. Other city → oos. +5. Mutations only when they clearly asked to do it: bookmark_add / bookmark_remove, email_qr, emergency, escalate_human. +6. Follow-ups: use RECENT CONVERSATION. Set is_followup. Fill entities. Do not treat a named follow-up as a fresh list dump. +7. Entities: speaker_name without salutations (sir, madam, ji, Dr, Mr, Prof, Shri). location = origin city or named place. dish when they named food. +8. Classification only — never claim a bookmark or email succeeded. + +### REQUIRED JSON { - "primary_intent": "agenda_session_details", - "confidence": 0.98, - "action_category": "read", + "domain": "ticket", + "action": "read", + "confidence": 0.9, "entities": { "speaker_name": null, "session_name": null, @@ -303,131 +71,57 @@ ${buildAllowedIntentsSection()} "location": null, "company_name": null, "time_reference": null, - "topic": "asthma" + "topic": null, + "dish": null }, "context_resolution": { "is_followup": false, "resolved_from_history": false - }, - "requires_data_fetch": true + } } `.trim(); -/** Model sometimes invents near-miss intent ids — remap before rejecting. */ -const INTENT_ALIASES = { - agenda_by_topic: 'agenda_session_details', - agenda_topic: 'agenda_session_details', - agenda_by_interest: 'agenda_session_details', - agenda_recommend: 'agenda_session_details', - agenda_recommendations: 'agenda_session_details', - agenda_suggestion: 'agenda_session_details', - agenda_must_not_miss: 'agenda_session_details', - agenda_add_session: 'agenda_bookmark_add', - agenda_save_session: 'agenda_bookmark_add', - agenda_bookmark: 'agenda_bookmark_add', - agenda_remove_session: 'agenda_bookmark_remove', - agenda_unbookmark: 'agenda_bookmark_remove', - venue_how_to_reach: 'venue_travel_directions', - venue_travel: 'venue_travel_directions', - travel_transport: 'venue_travel_directions', - venue_directions_from_city: 'venue_travel_directions', - ticket_send_qr: 'ticket_email_qr', - ticket_mail_qr: 'ticket_email_qr', - ticket_email_pass: 'ticket_email_qr', - ticket_show_qr: 'ticket_qr_display', - ticket_qr: 'ticket_qr_display', - ticket_receipt: 'ticket_document_receipt', - ticket_invoice: 'ticket_document_receipt', - ticket_status: 'ticket_status_lookup', - ticket_register: 'ticket_how_to_register', - how_to_register: 'ticket_how_to_register', - ticket_purchase: 'ticket_how_to_register', - buy_tickets: 'ticket_how_to_register', - sign_up: 'ticket_how_to_register', - speaker_profile: 'speaker_bio_lookup', - speaker_bio: 'speaker_bio_lookup', - speaker_about: 'speaker_bio_lookup', - speaker_linkedin: 'speaker_contact_info', - speaker_social: 'speaker_contact_info', - speaker_twitter: 'speaker_contact_info', - speaker_website: 'speaker_contact_info', - speaker_contact: 'speaker_contact_info', - speaker_meet: 'speaker_meet_and_greet', - speaker_networking: 'speaker_meet_and_greet', - exhibitor_booth: 'exhibitor_location', - exhibitor_list: 'exhibitor_location', - exhibitor_where: 'exhibitor_location', - exhibitor_find: 'exhibitor_location', - exhibitor_category: 'exhibitor_by_category', - exhibitor_industry: 'exhibitor_by_category', - exhibitor_demo: 'exhibitor_demo_schedule', - exhibitor_demos: 'exhibitor_demo_schedule', - exhibitor_meeting: 'exhibitor_book_meeting', - exhibitor_book: 'exhibitor_book_meeting', - exhibitor_appointment: 'exhibitor_book_meeting', - wifi: 'wifi_credentials', - wifi_password: 'wifi_credentials', - wifi_ssid: 'wifi_credentials', - wifi_network: 'wifi_credentials', - streaming_issue: 'app_issue_streaming', - video_issue: 'app_issue_streaming', - audio_issue: 'app_issue_streaming', - login_issue: 'app_issue_account', - account_issue: 'app_issue_account', - otp_issue: 'app_issue_account', - app_help: 'app_feature_guidance', - app_how_to: 'app_feature_guidance', - app_feature: 'app_feature_guidance', - talk_to_human: 'human_agent_escalation', - talk_to_organizer: 'human_agent_escalation', - human_agent: 'human_agent_escalation', - contact_organizer: 'human_agent_escalation', - who_to_contact: 'human_agent_escalation', - contact_details: 'human_agent_escalation', - hotel_checkin: 'accommodation', - hotel_check_in: 'accommodation', - lost_item: 'lost_and_found', - lost_found: 'lost_and_found', - medical_emergency: 'emergency_medical', - emergency: 'emergency_medical', - hello: 'chitchat_greeting', - hi: 'chitchat_greeting', - greeting: 'chitchat_greeting', - places_nearby: 'tourist_places', - sightseeing: 'tourist_places', - tourist: 'tourist_places', - attractions: 'tourist_places', - church: 'tourist_places', - churches: 'tourist_places', - temple: 'tourist_places', - temples: 'tourist_places', - fort: 'tourist_places', - forts: 'tourist_places', - beach: 'tourist_places', - beaches: 'tourist_places', - museum: 'tourist_places', - museums: 'tourist_places', - hotel: 'accommodation', - hotels: 'accommodation', - where_to_stay: 'accommodation', - lodging: 'accommodation', - accommodation_provided: 'accommodation', - is_accommodation_provided: 'accommodation', +const DOMAIN_ALIASES = { + agenda_domain: 'agenda', + speakers: 'speaker', + venue_domain: 'venue', + places: 'local', + tickets: 'ticket', + exhibitors: 'exhibitor', + wifi: 'tech', }; -function resolveIntent(rawIntent) { - const primary = String(rawIntent || '').trim(); - if (!primary) return ''; - if (isAllowedIntent(primary)) return primary; - const aliased = INTENT_ALIASES[primary]; - return aliased && isAllowedIntent(aliased) ? aliased : ''; +const ACTION_ALIASES = { + lookup: 'read', + query: 'read', + itinerary: 'adjacent', + local_tips: 'adjacent', + out_of_scope: 'oos', + chitchat: 'greeting', + hello: 'greeting', + talk_to_human: 'escalate_human', + human_agent: 'escalate_human', + email_ticket: 'email_qr', + save_session: 'bookmark_add', + remove_session: 'bookmark_remove', +}; + +function resolveDomain(raw) { + const d = String(raw || '').trim(); + if (DOMAINS.includes(d)) return d; + return DOMAIN_ALIASES[d] && DOMAINS.includes(DOMAIN_ALIASES[d]) ? DOMAIN_ALIASES[d] : ''; +} + +function resolveAction(raw) { + const a = String(raw || '').trim(); + if (ACTIONS.includes(a)) return a; + return ACTION_ALIASES[a] && ACTIONS.includes(ACTION_ALIASES[a]) ? ACTION_ALIASES[a] : ''; } module.exports = { - ALLOWED_INTENTS, - INTENT_DESCRIPTIONS, - INTENT_ALIASES, STEP_1_SYSTEM_PROMPT, - isAllowedIntent, - resolveIntent, + DOMAINS, + ACTIONS, + resolveDomain, + resolveAction, }; diff --git a/src/services/localData.service.js b/src/services/localData.service.js index 6d2ad2e..d3cfeb5 100644 --- a/src/services/localData.service.js +++ b/src/services/localData.service.js @@ -22,9 +22,13 @@ function accommodationFaqsFromBot(bot) { .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); diff --git a/src/services/orchestrator.service.js b/src/services/orchestrator.service.js index 8e7fcc0..0ffeaf7 100644 --- a/src/services/orchestrator.service.js +++ b/src/services/orchestrator.service.js @@ -10,21 +10,19 @@ const { generateLocalReply, } = require('./step2.service'); const { - fetchAgendaData, isAgendaIntent, isAuthRequiredAgendaIntent, } = require('./agendaData.service'); -const { fetchVenueData, isVenueIntent } = require('./venueData.service'); +const { isVenueIntent } = require('./venueData.service'); const { - fetchTicketData, isTicketIntent, isAuthRequiredTicketIntent, } = require('./ticketData.service'); -const { fetchSpeakerData, isSpeakerIntent } = require('./speakerData.service'); -const { fetchExhibitorData, isExhibitorIntent } = require('./exhibitorData.service'); -const { fetchTechData, isTechIntent } = require('./techData.service'); -const { fetchSystemData, isSystemIntent } = require('./systemData.service'); -const { fetchLocalData, isLocalIntent } = require('./localData.service'); +const { isSpeakerIntent } = require('./speakerData.service'); +const { isExhibitorIntent } = require('./exhibitorData.service'); +const { isTechIntent } = require('./techData.service'); +const { isSystemIntent } = require('./systemData.service'); +const { isLocalIntent } = require('./localData.service'); const { addBookmark, removeBookmark, @@ -39,6 +37,9 @@ const { } = require('./identity.service'); const { sendTicketPassEmail } = require('../utils/mail'); const { withOrganizerContact } = require('../utils/organizerContact'); +const { retrieveForTurn } = require('./retrieveForTurn'); +const { fromLegacyIntent, isBookmarkAction } = require('../utils/domainAction'); +const { isUnsafeAdjacentMessage, UNSAFE_REFUSE } = require('../utils/adjacentSafety'); const TICKET_EMAIL_COOLDOWN_MS = 60 * 1000; @@ -113,223 +114,67 @@ async function maybeSendTicketEmail({ session, data, intent }) { }; } -async function runVenueCall2({ bot, event, session, message, history, classification }) { - const intent = classification.primary_intent; - const data = await fetchVenueData({ - intent, - eventId: bot.event, - bot, - entities: classification.entities || {}, - message, - }); - - const step2 = await generateVenueReply({ - intent, +async function generateForDomain({ intent, action, message, history, entities, data }) { + if (action === 'adjacent' || intent === 'event_adjacent') { + return generateSystemReply({ + intent: 'event_adjacent', + message, + history, + entities, + data, + }); + } + if (isAgendaIntent(intent)) { + return generateAgendaReply({ intent, message, history, entities, data }); + } + if (isVenueIntent(intent)) { + return generateVenueReply({ intent, message, history, entities, data }); + } + if (isLocalIntent(intent)) { + return generateLocalReply({ intent, message, history, entities, data }); + } + if (isTicketIntent(intent)) { + return generateTicketReply({ intent, message, history, entities, data }); + } + if (isSpeakerIntent(intent)) { + return generateSpeakerReply({ intent, message, history, entities, data }); + } + if (isExhibitorIntent(intent)) { + return generateExhibitorReply({ intent, message, history, entities, data }); + } + if (isTechIntent(intent)) { + return generateTechReply({ intent, message, history, entities, data }); + } + if (isSystemIntent(intent) || action === 'oos' || action === 'greeting' || action === 'emergency' || action === 'escalate_human') { + return generateSystemReply({ intent, message, history, entities, data }); + } + return generateSystemReply({ + intent: 'out_of_scope', message, history, - entities: classification.entities || {}, + entities, data, }); - - return { - reply: step2.reply, - cards: Array.isArray(step2.cards) ? step2.cards : [], - sessionIds: [], - placeIds: Array.isArray(step2.placeIds) ? step2.placeIds : [], - classification: publicClassification(classification), - intent, - modelId: step2.modelId, - usage: step2.usage, - }; } -async function runLocalCall2({ bot, event, session, message, history, classification }) { +async function completeReadTurn({ bot, event, session, message, history, classification }) { const intent = classification.primary_intent; - const data = await fetchLocalData({ - intent, - eventId: bot.event, - message, - bot, - }); - - const step2 = await generateLocalReply({ - intent, - message, - history, - entities: classification.entities || {}, - data, - }); - - return { - reply: step2.reply, - cards: Array.isArray(step2.cards) ? step2.cards : [], - sessionIds: [], - placeIds: Array.isArray(step2.placeIds) ? step2.placeIds : [], - hotelIds: Array.isArray(step2.hotelIds) ? step2.hotelIds : [], - classification: publicClassification(classification), - intent, - modelId: step2.modelId, - usage: step2.usage, - }; -} - -async function runAgendaCall2({ bot, event, session, message, history, classification }) { - const intent = classification.primary_intent; - const data = await fetchAgendaData({ - intent, - eventId: bot.event, - entities: classification.entities || {}, - participantId: session.participant || null, - }); - - const step2 = await generateAgendaReply({ - intent, - message, - history, - entities: classification.entities || {}, - data, - }); - - return { - reply: step2.reply, - cards: Array.isArray(step2.cards) ? step2.cards : [], - sessionIds: Array.isArray(step2.sessionIds) ? step2.sessionIds : [], - classification: publicClassification(classification), - intent, - modelId: step2.modelId, - usage: step2.usage, - }; -} - -async function runTicketCall2({ bot, event, session, message, history, classification }) { - const intent = classification.primary_intent; - const eventName = event?.name || null; - - let data = await fetchTicketData({ - intent, - eventId: bot.event, - participantId: session.participant || null, - eventName, - bot, - event, - }); - - data = await maybeSendTicketEmail({ session, data, intent }); - - const step2 = await generateTicketReply({ - intent, - message, - history, - entities: classification.entities || {}, - data, - }); - - return { - reply: step2.reply, - cards: Array.isArray(step2.cards) ? step2.cards : [], - sessionIds: [], - classification: publicClassification(classification), - intent, - modelId: step2.modelId, - usage: step2.usage, - }; -} - -async function runSpeakerCall2({ bot, event, session, message, history, classification }) { - const intent = classification.primary_intent; - const data = await fetchSpeakerData({ - intent, - eventId: bot.event, - entities: classification.entities || {}, - }); - - const step2 = await generateSpeakerReply({ - intent, - message, - history, - entities: classification.entities || {}, - data, - }); - - return { - reply: step2.reply, - cards: Array.isArray(step2.cards) ? step2.cards : [], - sessionIds: Array.isArray(step2.sessionIds) ? step2.sessionIds : [], - speakerIds: Array.isArray(step2.speakerIds) ? step2.speakerIds : [], - classification: publicClassification(classification), - intent, - modelId: step2.modelId, - usage: step2.usage, - }; -} - -async function runExhibitorCall2({ bot, event, session, message, history, classification }) { - const intent = classification.primary_intent; - const data = await fetchExhibitorData({ - intent, - eventId: bot.event, - bot, - entities: classification.entities || {}, - }); - - const step2 = await generateExhibitorReply({ - intent, - message, - history, - entities: classification.entities || {}, - data, - }); - - return { - reply: step2.reply, - cards: Array.isArray(step2.cards) ? step2.cards : [], - sessionIds: [], - exhibitorIds: Array.isArray(step2.exhibitorIds) ? step2.exhibitorIds : [], - classification: publicClassification(classification), - intent, - modelId: step2.modelId, - usage: step2.usage, - }; -} - -async function runTechCall2({ bot, event, session, message, history, classification }) { - const intent = classification.primary_intent; - const data = await fetchTechData({ - intent, - bot, - }); - - const step2 = await generateTechReply({ - intent, - message, - history, - entities: classification.entities || {}, - data, - }); - - return { - reply: step2.reply, - cards: [], - sessionIds: [], - classification: publicClassification(classification), - intent, - modelId: step2.modelId, - usage: step2.usage, - }; -} - -async function runSystemCall2({ bot, event, session, message, history, classification }) { - const intent = classification.primary_intent; - const data = await fetchSystemData({ - intent, + const action = classification.action; + let data = await retrieveForTurn({ bot, event, session, + classification, message, }); - const step2 = await generateSystemReply({ + if (action === 'email_qr' || intent === 'ticket_email_qr') { + data = await maybeSendTicketEmail({ session, data, intent: 'ticket_email_qr' }); + } + + const step2 = await generateForDomain({ intent, + action, message, history, entities: classification.entities || {}, @@ -338,13 +183,40 @@ async function runSystemCall2({ bot, event, session, message, history, classific return { reply: step2.reply, - cards: [], - sessionIds: [], + cards: Array.isArray(step2.cards) ? step2.cards : [], + sessionIds: Array.isArray(step2.sessionIds) ? step2.sessionIds : [], + placeIds: Array.isArray(step2.placeIds) ? step2.placeIds : [], + hotelIds: Array.isArray(step2.hotelIds) ? step2.hotelIds : [], + speakerIds: Array.isArray(step2.speakerIds) ? step2.speakerIds : [], + exhibitorIds: Array.isArray(step2.exhibitorIds) ? step2.exhibitorIds : [], quickReplies: Array.isArray(step2.quickReplies) ? step2.quickReplies : null, classification: publicClassification(classification), intent, modelId: step2.modelId, usage: step2.usage, + faqKeysUsed: Array.isArray(data?.faqKeysUsed) ? data.faqKeysUsed : [], + }; +} + +function classificationFromPending(pendingIntent, pendingEntities) { + const mapped = fromLegacyIntent(pendingIntent) || { domain: 'system', action: 'read' }; + const action = + pendingIntent === 'ticket_email_qr' + ? 'email_qr' + : pendingIntent === 'agenda_bookmark_add' + ? 'bookmark_add' + : pendingIntent === 'agenda_bookmark_remove' + ? 'bookmark_remove' + : mapped.action; + return { + primary_intent: pendingIntent, + domain: mapped.domain, + action, + confidence: 1, + action_category: action === 'email_qr' || isBookmarkAction(action) ? 'mutation' : 'read', + entities: pendingEntities, + context_resolution: { is_followup: true, resolved_from_history: true }, + requires_data_fetch: true, }; } @@ -372,6 +244,20 @@ async function runBookmarkMutation({ bot, session, classification }) { intent, modelId: null, usage: { inputTokens: 0, outputTokens: 0 }, + faqKeysUsed: [], + }; +} + +function unsafeRefuseResult(classification, step1) { + return { + reply: UNSAFE_REFUSE, + cards: [], + sessionIds: [], + classification: publicClassification(classification), + intent: classification?.primary_intent || null, + modelId: step1?.modelId || null, + usage: step1?.usage || { inputTokens: 0, outputTokens: 0 }, + faqKeysUsed: [], }; } @@ -411,6 +297,7 @@ async function handleChatTurnCore({ bot, event, session, message }) { intent: null, modelId: null, usage: { inputTokens: 0, outputTokens: 0 }, + faqKeysUsed: [], }; } // New question while verifying — drop auth and answer it below. @@ -423,6 +310,7 @@ async function handleChatTurnCore({ bot, event, session, message }) { intent: session.pendingIntent || null, modelId: null, usage: { inputTokens: 0, outputTokens: 0 }, + faqKeysUsed: [], }; } else { @@ -436,50 +324,15 @@ async function handleChatTurnCore({ bot, event, session, message }) { clearPending(session); if (pendingIntent && isBookmarkMutationIntent(pendingIntent)) { - const classification = { - primary_intent: pendingIntent, - confidence: 1, - action_category: 'mutation', - entities: pendingEntities, - context_resolution: { is_followup: true, resolved_from_history: true }, - requires_data_fetch: false, - }; + const classification = classificationFromPending(pendingIntent, pendingEntities); const result = await runBookmarkMutation({ bot, session, classification }); result.reply = `You're verified. ${result.reply}`; return result; } - if (pendingIntent && isAuthRequiredTicketIntent(pendingIntent)) { - const classification = { - primary_intent: pendingIntent, - confidence: 1, - action_category: pendingIntent === 'ticket_email_qr' ? 'mutation' : 'read', - entities: pendingEntities, - context_resolution: { is_followup: true, resolved_from_history: true }, - requires_data_fetch: true, - }; - const result = await runTicketCall2({ - bot, - event, - session, - message: pendingMessage, - history, - classification, - }); - result.reply = `You're verified. ${result.reply}`; - return result; - } - - if (pendingIntent && isAgendaIntent(pendingIntent)) { - const classification = { - primary_intent: pendingIntent, - confidence: 1, - action_category: 'read', - entities: pendingEntities, - context_resolution: { is_followup: true, resolved_from_history: true }, - requires_data_fetch: true, - }; - const result = await runAgendaCall2({ + if (pendingIntent && (isAuthRequiredTicketIntent(pendingIntent) || isAgendaIntent(pendingIntent))) { + const classification = classificationFromPending(pendingIntent, pendingEntities); + const result = await completeReadTurn({ bot, event, session, @@ -499,6 +352,7 @@ async function handleChatTurnCore({ bot, event, session, message }) { intent: pendingIntent, modelId: null, usage: { inputTokens: 0, outputTokens: 0 }, + faqKeysUsed: [], }; } } @@ -507,8 +361,14 @@ async function handleChatTurnCore({ bot, event, session, message }) { const step1 = await classifyIntent({ message, history }); const classification = step1.classification; const intent = classification.primary_intent; + const action = classification.action; - // 3) Auth-required intents (personal agenda + ticket reads/email) + // 3) Safety refuse (abuse / PII / medical-beyond-first-aid) — never the strong model + if (isUnsafeAdjacentMessage(message) && action !== 'emergency') { + return unsafeRefuseResult(classification, step1); + } + + // 4) Auth-required intents (personal agenda + ticket reads/email) if ( (isAuthRequiredAgendaIntent(intent) || isAuthRequiredTicketIntent(intent)) && !isVerified(session) @@ -527,11 +387,12 @@ async function handleChatTurnCore({ bot, event, session, message }) { intent, modelId: step1.modelId, usage: step1.usage, + faqKeysUsed: [], }; } - // 4) Bookmark mutations — write to SessionBookmark (no Call 2) - if (isBookmarkMutationIntent(intent)) { + // 5) Bookmark mutations — write to SessionBookmark (no Call 2) + if (isBookmarkMutationIntent(intent) || isBookmarkAction(action)) { const result = await runBookmarkMutation({ bot, session, classification }); return { ...result, @@ -540,153 +401,19 @@ async function handleChatTurnCore({ bot, event, session, message }) { }; } - // 5) Agenda Call 2 - if (isAgendaIntent(intent)) { - const result = await runAgendaCall2({ - bot, - event, - session, - message, - history, - classification, - }); - return { - ...result, - usage: sumUsage(step1.usage, result.usage), - modelId: result.modelId || step1.modelId, - }; - } - - // 6) Venue Call 2 (guest — no auth) - if (isVenueIntent(intent)) { - const result = await runVenueCall2({ - bot, - event, - session, - message, - history, - classification, - }); - return { - ...result, - usage: sumUsage(step1.usage, result.usage), - modelId: result.modelId || step1.modelId, - }; - } - - // 6b) Local places / accommodation Call 2 (guest — cached recommendations) - if (isLocalIntent(intent)) { - const result = await runLocalCall2({ - bot, - event, - session, - message, - history, - classification, - }); - return { - ...result, - usage: sumUsage(step1.usage, result.usage), - modelId: result.modelId || step1.modelId, - }; - } - - // 7) Ticket Call 2 (auth already gated for personal intents; transfer/upgrade refuse) - if (isTicketIntent(intent)) { - const result = await runTicketCall2({ - bot, - event, - session, - message, - history, - classification, - }); - return { - ...result, - usage: sumUsage(step1.usage, result.usage), - modelId: result.modelId || step1.modelId, - }; - } - - // 8) Speaker Call 2 (guest — public programme data only) - if (isSpeakerIntent(intent)) { - const result = await runSpeakerCall2({ - bot, - event, - session, - message, - history, - classification, - }); - return { - ...result, - usage: sumUsage(step1.usage, result.usage), - modelId: result.modelId || step1.modelId, - }; - } - - // 9) Exhibitor Call 2 (guest — published booth data + FAQ demos) - if (isExhibitorIntent(intent)) { - const result = await runExhibitorCall2({ - bot, - event, - session, - message, - history, - classification, - }); - return { - ...result, - usage: sumUsage(step1.usage, result.usage), - modelId: result.modelId || step1.modelId, - }; - } - - // 10) Tech Call 2 (guest — FAQ + static tips) - if (isTechIntent(intent)) { - const result = await runTechCall2({ - bot, - event, - session, - message, - history, - classification, - }); - return { - ...result, - usage: sumUsage(step1.usage, result.usage), - modelId: result.modelId || step1.modelId, - }; - } - - // 11) System Call 2 (greeting / safety / escalation — may email organizers) - if (isSystemIntent(intent)) { - const result = await runSystemCall2({ - bot, - event, - session, - message, - history, - classification, - }); - return { - ...result, - usage: sumUsage(step1.usage, result.usage), - modelId: result.modelId || step1.modelId, - }; - } - - // 12) Unimplemented intent groups + // 6) Retrieve this-turn DATA, then Call 2 by domain (email QR / emergency stay code-backed) + const result = await completeReadTurn({ + bot, + event, + session, + message, + history, + classification, + }); return { - reply: - `I understood your request (${intent.replace(/_/g, ' ')}), but that topic isn't available in this concierge yet. ` - + 'I can help with the agenda, speakers, exhibitors, venue, Wi‑Fi / app help, or your ticket (QR, status, receipt).', - cards: [], - sessionIds: [], - classification: publicClassification(classification), - intent, - modelId: step1.modelId, - usage: step1.usage, + ...result, + usage: sumUsage(step1.usage, result.usage), + modelId: result.modelId || step1.modelId, }; } diff --git a/src/services/retrieveForTurn.js b/src/services/retrieveForTurn.js new file mode 100644 index 0000000..bd5245f --- /dev/null +++ b/src/services/retrieveForTurn.js @@ -0,0 +1,301 @@ +const { faqAnswerText } = require('../utils/faqAnswer'); +const { + looksLikeFoodPolicy, + looksLikeDishAsk, + looksLikeItinerary, + faqKeyOf, +} = require('../utils/domainAction'); +const { fetchAgendaData, isAgendaIntent } = require('./agendaData.service'); +const { fetchVenueData, isVenueIntent } = require('./venueData.service'); +const { fetchLocalData, isLocalIntent } = require('./localData.service'); +const { fetchTicketData, isTicketIntent } = require('./ticketData.service'); +const { fetchSpeakerData, isSpeakerIntent } = require('./speakerData.service'); +const { fetchExhibitorData, isExhibitorIntent } = require('./exhibitorData.service'); +const { fetchTechData, isTechIntent } = require('./techData.service'); +const { fetchSystemData, isSystemIntent } = require('./systemData.service'); +const Event = require('../models/Event'); + +const FOOD_FAQ_KEYS = new Set(['venue_food', 'venue_food_beverage']); + +function tokens(text) { + return String(text || '') + .toLowerCase() + .replace(/[^a-z0-9\s]/g, ' ') + .split(/\s+/) + .filter((t) => t.length > 2); +} + +function scoreFaq(row, message, entities) { + const q = String(row?.question || row?.q || ''); + const key = String(row?.key || row?.category || ''); + const hay = `${q} ${key}`.toLowerCase(); + const bits = tokens(message); + if (entities?.dish) bits.push(String(entities.dish).toLowerCase()); + if (entities?.topic) bits.push(String(entities.topic).toLowerCase()); + let score = 0; + for (const b of bits) { + if (hay.includes(b)) score += 1; + } + return score; +} + +function alreadyToldSet(session) { + return new Set((Array.isArray(session?.alreadyTold) ? session.alreadyTold : []).map(String)); +} + +function serializeBotFaqs(bot) { + const rows = Array.isArray(bot?.faqs) ? bot.faqs : []; + return rows + .map((f) => { + const a = faqAnswerText(f); + if (!a) return null; + const q = String(f?.question || '').trim(); + const key = faqKeyOf(f); + return { + q: q || key || 'FAQ', + a, + key, + category: String(f?.category || '').trim() || key, + }; + }) + .filter(Boolean); +} + +function shouldSkipFoodFaq({ action, message, entities }) { + if (action === 'adjacent') return true; + if (looksLikeDishAsk(message, entities?.dish) && !looksLikeFoodPolicy(message)) return true; + if (looksLikeItinerary(message)) return true; + return false; +} + +function rankFaqs({ bot, message, entities, action, alreadyTold, existingFaqs }) { + const skipFood = shouldSkipFoodFaq({ action, message, entities }); + const told = alreadyToldSet({ alreadyTold: [...alreadyTold] }); + const fromBot = serializeBotFaqs(bot); + const botByQ = new Map(fromBot.map((f) => [String(f.q || '').toLowerCase(), f])); + const pool = existingFaqs == null ? fromBot : existingFaqs; + + const enriched = (Array.isArray(pool) ? pool : []).map((f) => { + const hit = botByQ.get(String(f.q || '').toLowerCase()); + const key = String(f.key || f.category || hit?.key || hit?.category || '').trim(); + return { + q: f.q, + a: f.a, + key: key || null, + category: String(f.category || hit?.category || key || '').trim() || null, + }; + }); + + const filtered = enriched.filter((f) => { + const key = String(f.key || f.category || ''); + if (key && told.has(key)) return false; + if (skipFood && (FOOD_FAQ_KEYS.has(key) || FOOD_FAQ_KEYS.has(String(f.category || '')))) { + return false; + } + return Boolean(f.a); + }); + + const scored = filtered + .map((f) => ({ f, score: scoreFaq(f, message, entities) })) + .sort((a, b) => b.score - a.score); + + const picked = (scored[0]?.score > 0 ? scored.filter((s) => s.score > 0) : scored) + .slice(0, 3) + .map((s) => s.f); + + return picked.map((f) => ({ + q: f.q, + a: f.a, + key: f.key || f.category || null, + })); +} + +function compactEventDates(event) { + if (!event || typeof event !== 'object') return null; + const start = + event.startDate || event.start || event.dateFrom || event.startsAt || event.eventStart; + const end = event.endDate || event.end || event.dateTo || event.endsAt || event.eventEnd; + const out = {}; + if (start) out.start = start instanceof Date ? start.toISOString() : String(start); + if (end) out.end = end instanceof Date ? end.toISOString() : String(end); + return Object.keys(out).length ? out : null; +} + +function cityHint(location) { + const loc = String(location || '').trim(); + if (!loc) return null; + const parts = loc.split(',').map((p) => p.trim()).filter(Boolean); + return parts[parts.length - 1] || loc; +} + +async function loadEventContext(event, eventId) { + let doc = event; + if (!doc?.location || !compactEventDates(doc)) { + const id = event?._id || eventId; + if (id) { + const extra = await Event.findById(id) + .select('name location startDate endDate start end dateFrom dateTo startsAt endsAt eventStart eventEnd') + .lean(); + if (extra) doc = { ...extra, ...doc }; + } + } + return { + location: String(doc?.location || '').trim() || null, + city: cityHint(doc?.location), + eventDates: compactEventDates(doc), + }; +} + +function faqKeysUsed(faqs) { + return (Array.isArray(faqs) ? faqs : []) + .map((f) => String(f.key || f.category || '').trim()) + .filter(Boolean); +} + +/** + * Retrieve this-turn DATA using existing domain fetchers + ranked FAQs. + */ +async function retrieveForTurn({ + bot, + event, + session, + classification, + message, +}) { + const intent = classification.primary_intent; + const action = classification.action; + const domain = classification.domain; + const entities = classification.entities || {}; + const alreadyTold = Array.isArray(session?.alreadyTold) ? session.alreadyTold : []; + const eventId = bot.event; + const participantId = session?.participant || null; + + let data = { faqs: [], alreadyTold }; + + if (action === 'adjacent' || intent === 'event_adjacent') { + const sys = await fetchSystemData({ + intent: 'event_adjacent', + bot, + event, + session, + message, + }); + const ctx = await loadEventContext(event, eventId); + let places = []; + let hotels = []; + try { + if (looksLikeDishAsk(message, entities.dish)) { + const food = await fetchVenueData({ + intent: 'venue_food_beverage', + eventId, + bot, + entities, + message, + }); + places = Array.isArray(food.places) ? food.places : []; + } else { + const local = await fetchLocalData({ + intent: 'tourist_places', + eventId, + message, + bot, + }); + places = Array.isArray(local.places) ? local.places : []; + hotels = Array.isArray(local.hotels) ? local.hotels : []; + } + } catch (err) { + console.warn('[retrieve] adjacent local cache:', err?.message || err); + } + + const faqs = rankFaqs({ + bot, + message, + entities, + action: 'adjacent', + alreadyTold, + existingFaqs: [], + }); + + data = { + ...sys, + ...ctx, + allowGeneralLocal: true, + faqs, + places, + hotels, + alreadyTold, + dish: entities.dish || null, + }; + data.faqKeysUsed = faqKeysUsed(faqs); + return data; + } + + if (isAgendaIntent(intent)) { + data = await fetchAgendaData({ + intent, + eventId, + entities, + participantId, + }); + } else if (isVenueIntent(intent)) { + data = await fetchVenueData({ + intent, + eventId, + bot, + entities, + message, + }); + } else if (isLocalIntent(intent)) { + data = await fetchLocalData({ intent, eventId, message, bot }); + } else if (isTicketIntent(intent)) { + data = await fetchTicketData({ + intent, + eventId, + participantId, + eventName: event?.name || null, + bot, + event, + }); + } else if (isSpeakerIntent(intent)) { + data = await fetchSpeakerData({ intent, eventId, entities }); + } else if (isExhibitorIntent(intent)) { + data = await fetchExhibitorData({ intent, eventId, bot, entities }); + } else if (isTechIntent(intent)) { + data = await fetchTechData({ intent, bot }); + } else if (isSystemIntent(intent)) { + data = await fetchSystemData({ intent, bot, event, session, message }); + } else { + data = await fetchSystemData({ + intent: 'out_of_scope', + bot, + event, + session, + message, + }); + } + + const ranked = rankFaqs({ + bot, + message, + entities, + action, + alreadyTold, + existingFaqs: Array.isArray(data.faqs) ? data.faqs : [], + }); + + data = { + ...data, + faqs: ranked, + alreadyTold, + domain, + action, + }; + data.faqKeysUsed = faqKeysUsed(ranked); + return data; +} + +module.exports = { + retrieveForTurn, + rankFaqs, + faqKeysUsed, +}; diff --git a/src/services/step1.service.js b/src/services/step1.service.js index 93fd310..a9ecbb6 100644 --- a/src/services/step1.service.js +++ b/src/services/step1.service.js @@ -1,7 +1,4 @@ -const { - STEP_1_SYSTEM_PROMPT, - resolveIntent, -} = require('../prompts/step1'); +const { STEP_1_SYSTEM_PROMPT, resolveDomain, resolveAction } = require('../prompts/step1'); const { createClient, converse, @@ -12,19 +9,20 @@ const { const { looksLikeTouristPlacesQuestion } = require('../utils/placeCategoryFilter'); const { looksLikeWhoToContact, looksLikeHotelCheckIn } = require('../utils/organizerContact'); const { stripNameSalutations } = require('../utils/nameSalutations'); +const { + inferFetchIntent, + fromLegacyIntent, + looksLikeDishAsk, + looksLikeItinerary, + looksLikeFoodPolicy, + looksLikeCityToVenueTravel, + looksLikeEventDeskQuestion, + extractTravelOrigin, + LEGACY_TO_DOMAIN_ACTION, +} = require('../utils/domainAction'); -const RESCUE_FROM_INTENTS = new Set(['out_of_scope', 'chitchat_greeting']); -const REGISTER_RESCUE_FROM = new Set([ - 'out_of_scope', - 'chitchat_greeting', - 'ticket_status_lookup', - 'venue_wayfinding', -]); +const REGISTER_RESCUE_FROM_ACTIONS = new Set(['oos', 'greeting', 'read']); -/** - * "How can I register / buy a ticket" — not desk wayfinding and not existing-status lookup. - * @param {string} message - */ function looksLikeHowToRegisterQuestion(message) { const t = String(message || '') .toLowerCase() @@ -51,12 +49,12 @@ function looksLikeHowToRegisterQuestion(message) { || /\b(want to|need to|like to|trying to) (register|sign ?up|buy (a )?(ticket|pass))\b/.test(t) || /\b(buy|purchase) (a |an )?(ticket|pass|registration)\b/.test(t) || /\bregistration (link|form|page|url|website|online)\b/.test(t) - || /^(how can i register|how do i register|how to register|can i register|register|sign up|sign-up)( for (the |this )?event)?\??$/.test(t) + || /^(how can i register|how do i register|how to register|can i register|register|sign up|sign-up)( for (the |this )?event)?\??$/.test( + t, + ) ); } -const ACTION_CATEGORIES = new Set(['read', 'mutation', 'escalation', 'direct_reply']); - function emptyEntities() { return { speaker_name: null, @@ -66,11 +64,14 @@ function emptyEntities() { company_name: null, time_reference: null, topic: null, + dish: null, }; } function fallbackClassification(reason) { return { + domain: 'system', + action: 'oos', primary_intent: 'out_of_scope', confidence: 0, action_category: 'direct_reply', @@ -106,59 +107,88 @@ function normalizeEntities(raw) { base[key] = null; } else { const value = typeof raw[key] === 'string' ? raw[key].trim() : raw[key]; - base[key] = key === 'speaker_name' && typeof value === 'string' - ? stripNameSalutations(value) || null - : value; + if (key === 'speaker_name' && typeof value === 'string') { + base[key] = stripNameSalutations(value) || null; + } else { + base[key] = value; + } } } return base; } -function normalizeClassification(parsed) { +function actionCategoryFor(action) { + if (action === 'bookmark_add' || action === 'bookmark_remove' || action === 'email_qr') { + return 'mutation'; + } + if (action === 'emergency' || action === 'escalate_human') return 'escalation'; + if (action === 'oos' || action === 'greeting') return 'direct_reply'; + return 'read'; +} + +function attachFetchIntent(base, message) { + const fetchIntent = inferFetchIntent({ + domain: base.domain, + action: base.action, + entities: base.entities, + message, + }); + return { + ...base, + primary_intent: fetchIntent, + action_category: actionCategoryFor(base.action), + requires_data_fetch: !['oos', 'greeting'].includes(base.action) || fetchIntent === 'lost_and_found', + }; +} + +function normalizeClassification(parsed, message = '') { if (!parsed || typeof parsed !== 'object') { return fallbackClassification('invalid_json_object'); } - const rawPrimary = String(parsed.primary_intent || '').trim(); - const primary = resolveIntent(rawPrimary); - if (!primary) { - return fallbackClassification(`unknown_intent:${rawPrimary || '(empty)'}`); + let domain = resolveDomain(parsed.domain); + let action = resolveAction(parsed.action); + + // Model still emitting old primary_intent + if ((!domain || !action) && parsed.primary_intent) { + const legacy = fromLegacyIntent(String(parsed.primary_intent).trim()) + || LEGACY_TO_DOMAIN_ACTION[String(parsed.primary_intent).trim()]; + if (legacy) { + domain = domain || legacy.domain; + action = action || legacy.action; + } + } + + if (!domain || !action) { + return fallbackClassification( + `unknown_domain_action:${parsed.domain || ''}:${parsed.action || parsed.primary_intent || ''}`, + ); } let confidence = Number(parsed.confidence); if (!Number.isFinite(confidence)) confidence = 0; confidence = Math.max(0, Math.min(1, confidence)); - let action = String(parsed.action_category || '').trim(); - if (primary === 'agenda_bookmark_add' || primary === 'agenda_bookmark_remove') { - action = 'mutation'; - } else if (!ACTION_CATEGORIES.has(action)) { - action = primary.startsWith('agenda_') ? 'read' : 'direct_reply'; - } - - const ctx = parsed.context_resolution && typeof parsed.context_resolution === 'object' - ? parsed.context_resolution - : {}; + const ctx = + parsed.context_resolution && typeof parsed.context_resolution === 'object' + ? parsed.context_resolution + : {}; const entities = normalizeEntities(parsed.entities); - // Aliased topic intents should still fetch agenda data for Call 2 - const requiresData = - parsed.requires_data_fetch != null - ? Boolean(parsed.requires_data_fetch) - : primary.startsWith('agenda_'); - return { - primary_intent: primary, - confidence, - action_category: action, - entities, - context_resolution: { - is_followup: Boolean(ctx.is_followup), - resolved_from_history: Boolean(ctx.resolved_from_history), + return attachFetchIntent( + { + domain, + action, + confidence, + entities, + context_resolution: { + is_followup: Boolean(ctx.is_followup), + resolved_from_history: Boolean(ctx.resolved_from_history), + }, }, - requires_data_fetch: requiresData, - ...(rawPrimary !== primary ? { _aliased_from: rawPrimary } : {}), - }; + message, + ); } function formatHistory(history) { @@ -172,11 +202,135 @@ function formatHistory(history) { .join('\n'); } -/** - * Run Call 1 intent classification. - * @param {{ message: string, history?: { role: string, text: string }[] }} opts - * @returns {Promise<{ classification: object, modelId: string|null, usage: { inputTokens: number, outputTokens: number }, raw: string|null }>} - */ +function applyRescues(classification, message) { + let next = { ...classification }; + + // Mutations / code paths that must run even if Call 1 misses the action. + if ( + REGISTER_RESCUE_FROM_ACTIONS.has(next.action) + && looksLikeHowToRegisterQuestion(message) + ) { + next = attachFetchIntent( + { + ...next, + domain: 'ticket', + action: 'read', + _rescued_from: classification.action, + _rescue: 'how_to_register_keyword', + }, + message, + ); + next.primary_intent = 'ticket_how_to_register'; + } + + if (looksLikeWhoToContact(message)) { + next = attachFetchIntent( + { + ...next, + domain: 'system', + action: 'escalate_human', + _rescued_from: classification.action, + _rescue: 'who_to_contact_keyword', + }, + message, + ); + } else if (looksLikeHotelCheckIn(message)) { + next = attachFetchIntent( + { + ...next, + domain: 'local', + action: 'read', + _rescued_from: classification.action, + _rescue: 'hotel_checkin_keyword', + }, + message, + ); + next.primary_intent = 'accommodation'; + } + + if ( + ['read', 'oos', 'greeting', 'adjacent'].includes(next.action) + && looksLikeCityToVenueTravel(message) + && !looksLikeItinerary(message) + && !looksLikeDishAsk(message, next.entities?.dish) + ) { + const origin = extractTravelOrigin(message, next.entities?.location); + next = attachFetchIntent( + { + ...next, + domain: 'venue', + action: 'read', + entities: { + ...next.entities, + location: origin || next.entities?.location || null, + }, + _rescued_from: classification.action, + _rescue: 'city_to_venue', + }, + message, + ); + next.primary_intent = 'venue_travel_directions'; + } + + if ( + ['read', 'oos', 'greeting'].includes(next.action) + && !looksLikeFoodPolicy(message) + && (looksLikeItinerary(message) || looksLikeDishAsk(message, next.entities?.dish)) + ) { + const dishMatch = String(message || '').match( + /\b(puttu|dosa|idli|appam|porotta|parotta|biryani)\b/i, + ); + const entities = { + ...next.entities, + dish: next.entities?.dish || (dishMatch ? dishMatch[1].toLowerCase() : null), + }; + next = attachFetchIntent( + { + ...next, + domain: 'local', + action: 'adjacent', + entities, + _rescued_from: classification.action, + _rescue: 'adjacent_keyword', + }, + message, + ); + } + + // If the router refused but the ask is still an event-desk question, recover in-scope. + if (next.action === 'oos' || next.action === 'greeting') { + if ( + looksLikeTouristPlacesQuestion(message) + && !looksLikeItinerary(message) + && !looksLikeDishAsk(message, next.entities?.dish) + ) { + next = attachFetchIntent( + { + ...next, + domain: 'local', + action: 'read', + _rescued_from: classification.action, + _rescue: 'oos_event_desk', + }, + message, + ); + } else if (looksLikeEventDeskQuestion(message)) { + next = attachFetchIntent( + { + ...next, + domain: 'ticket', + action: 'read', + _rescued_from: classification.action, + _rescue: 'oos_event_desk', + }, + message, + ); + } + } + + return next; +} + async function classifyIntent({ message, history }) { const modelId = getRouterModelId(); const client = createClient(getRegion()); @@ -209,59 +363,10 @@ async function classifyIntent({ message, history }) { } let classification = parsed - ? normalizeClassification(parsed) + ? normalizeClassification(parsed, message) : fallbackClassification('json_parse_failed'); - // Local landmark questions (church/temple/fort/beach/…) must not fall through as trivia. - if ( - RESCUE_FROM_INTENTS.has(classification.primary_intent) && - looksLikeTouristPlacesQuestion(message) - ) { - classification = { - ...classification, - primary_intent: 'tourist_places', - action_category: 'read', - requires_data_fetch: true, - _rescued_from: classification.primary_intent, - _rescue: 'tourist_places_keyword', - }; - } - - // How-to-register is in-scope; do not treat as OOS or existing-status lookup. - if ( - REGISTER_RESCUE_FROM.has(classification.primary_intent) && - looksLikeHowToRegisterQuestion(message) - ) { - classification = { - ...classification, - primary_intent: 'ticket_how_to_register', - action_category: 'read', - requires_data_fetch: true, - _rescued_from: classification.primary_intent, - _rescue: 'how_to_register_keyword', - }; - } - - // Explicit "who to contact" — always share organizer contact, even as a follow-up. - if (looksLikeWhoToContact(message)) { - classification = { - ...classification, - primary_intent: 'human_agent_escalation', - action_category: 'escalation', - requires_data_fetch: true, - _rescued_from: classification.primary_intent, - _rescue: 'who_to_contact_keyword', - }; - } else if (looksLikeHotelCheckIn(message)) { - classification = { - ...classification, - primary_intent: 'accommodation', - action_category: 'read', - requires_data_fetch: true, - _rescued_from: classification.primary_intent, - _rescue: 'hotel_checkin_keyword', - }; - } + classification = applyRescues(classification, message); console.log('[step1][call1][classification]', { modelId, @@ -294,11 +399,11 @@ async function classifyIntent({ message, history }) { } function formatClassificationReply(classification) { - const intent = classification?.primary_intent || 'out_of_scope'; - const action = classification?.action_category || 'direct_reply'; + const domain = classification?.domain || 'system'; + const action = classification?.action || 'oos'; const conf = Number(classification?.confidence); const confLabel = Number.isFinite(conf) ? conf.toFixed(2) : '0.00'; - return `Intent: ${intent} · ${action} · ${confLabel}`; + return `Domain: ${domain} · ${action} · ${confLabel}`; } module.exports = { @@ -306,4 +411,5 @@ module.exports = { formatClassificationReply, normalizeClassification, fallbackClassification, + applyRescues, }; diff --git a/src/services/step2.service.js b/src/services/step2.service.js index db99e0d..4082113 100644 --- a/src/services/step2.service.js +++ b/src/services/step2.service.js @@ -22,6 +22,7 @@ const { converse, responseText, getRegion, + getCall2StrongModelId, } = require('../utils/bedrock'); const { buildSessionCards, resolveSessionIds } = require('../utils/sessionCards'); const { buildPlaceCards, resolvePlaceIds } = require('../utils/placeCards'); @@ -94,6 +95,8 @@ function formatStaticTipsReply(intro, tips) { } function getCall2ModelId() { + const strong = String(process.env.BEDROCK_CALL2_MODEL_STRONG_ID || '').trim(); + if (strong) return strong; return process.env.BEDROCK_CALL2_MODEL_ID || DEFAULT_CALL2_MODEL_ID; } @@ -654,36 +657,17 @@ function contactReachLine(data) { function ticketHowToRegisterFallback(data) { const links = Array.isArray(data?.registrationLinks) ? data.registrationLinks : []; - const reach = contactReachLine(data); - const faq = Array.isArray(data?.faqs) - ? data.faqs.map((f) => String(f?.a || '').trim()).filter(Boolean)[0] || '' - : ''; - if (faq && !/https?:\/\//i.test(faq) && !/i can only (assist|help)/i.test(faq)) { - return reach ? `${faq} Questions? ${reach}.` : faq; - } if (links.length > 1) { - return reach - ? `Here are the registration options — tap a button to open one. Questions? ${reach}.` - : 'Here are the registration options — tap a button to open one.'; + return 'You can join using the registration options below.'; } if (links.length === 1) { - return reach - ? `Tap below to register. Questions? ${reach}.` - : 'Tap below to register.'; + return 'You can join using the registration button below.'; } - if (reach) return `You can register with the organizers — ${reach}.`; + const reach = contactReachLine(data); + if (reach) return `I don’t have a registration form on file. You can reach the organizers — ${reach}.`; return TICKET_REGISTER_NONE_REPLY; } -function ticketHowToRegisterResult(data) { - return { - reply: ticketHowToRegisterFallback(data), - cards: buildRegistrationCtaCards(data?.registrationLinks), - modelId: null, - usage: { inputTokens: 0, outputTokens: 0 }, - }; -} - function sanitizeOutOfScopeReply(reply, eventName) { let text = String(reply || '').trim(); text = text.replace( @@ -706,16 +690,14 @@ function sanitizeOutOfScopeReply(reply, eventName) { * @param {{ intent: string, message: string, history?: object[], entities?: object, data?: object }} opts */ async function generateTicketReply({ intent, message, history, entities, data }) { - // Real event links only — never let the model invent a registration URL. - if (intent === 'ticket_how_to_register') { - return ticketHowToRegisterResult(data); - } + const registerMode = intent === 'ticket_how_to_register'; + const registerCards = registerMode ? buildRegistrationCtaCards(data?.registrationLinks) : []; const system = buildTicketCall2SystemPrompt(intent); if (!system) { return { - reply: TICKET_FALLBACK_REPLY, - cards: [], + reply: registerMode ? ticketHowToRegisterFallback(data) : TICKET_FALLBACK_REPLY, + cards: registerCards, modelId: null, usage: { inputTokens: 0, outputTokens: 0 }, }; @@ -822,7 +804,7 @@ async function generateTicketReply({ intent, message, history, entities, data }) reply = `${data.attendee?.name || 'Your registration'} is ${status}${ticket}.`; } else if (intent === 'ticket_qr_display' && qrMode) { reply = 'Here is your check-in QR code.'; - } else if (intent === 'ticket_how_to_register') { + } else if (registerMode) { reply = ticketHowToRegisterFallback(data); } else { reply = String(data?.note || '').trim() || TICKET_FALLBACK_REPLY; @@ -830,13 +812,22 @@ async function generateTicketReply({ intent, message, history, entities, data }) } if ( - intent === 'ticket_how_to_register' + registerMode && (/i can only (assist|help) with/i.test(reply) || /i('m| am) afraid/i.test(reply)) ) { reply = ticketHowToRegisterFallback(data); } - const cards = showQr && qrMode ? buildTicketQrCard(data) : []; + if (registerMode) { + reply = String(reply || '').replace(/https?:\/\/\S+/gi, '').replace(/\s{2,}/g, ' ').trim() + || ticketHowToRegisterFallback(data); + } + + const cards = registerMode + ? registerCards + : showQr && qrMode + ? buildTicketQrCard(data) + : []; console.log('[step2][ticket][parsed]', { intent, @@ -856,9 +847,13 @@ async function generateTicketReply({ intent, message, history, entities, data }) }; } catch (err) { console.warn('[step2] Ticket Call 2 failed:', err?.message || err); - const cards = intent === 'ticket_qr_display' && qrMode ? buildTicketQrCard(data) : []; + const cards = registerMode + ? registerCards + : intent === 'ticket_qr_display' && qrMode + ? buildTicketQrCard(data) + : []; let reply = TICKET_FALLBACK_REPLY; - if (intent === 'ticket_how_to_register') { + if (registerMode) { reply = ticketHowToRegisterFallback(data); } else if (cards.length) { reply = 'Here is your check-in QR code.'; @@ -1453,6 +1448,16 @@ function systemDeterministicFallback(intent, data) { if (intent === 'out_of_scope') { return 'I can only help with this event — try the agenda, venue, or your ticket.'; } + if (intent === 'event_adjacent') { + const city = data?.city || data?.location; + if (data?.dish && city) { + return `Around ${city} you can look for ${data.dish} at local spots — check current hours on a maps app before you go.`; + } + if (city) { + return `Happy to suggest a plan around ${city} after the event. Tell me what you like (food, a fort, a relaxed walk) and I’ll keep it to this city.`; + } + return 'I don’t have the event city on file, so I can’t suggest a local itinerary. Ask about the agenda or venue instead.'; + } return SYSTEM_FALLBACK_REPLY; } @@ -1471,11 +1476,14 @@ function ensureEmergencyLead(reply) { async function generateSystemReply({ intent, message, history, entities, data }) { const system = buildSystemCall2SystemPrompt(intent); const greetingChips = intent === 'chitchat_greeting' ? DEFAULT_QUICK_REPLIES : null; + const adjacent = intent === 'event_adjacent'; + const placeMode = adjacent && Array.isArray(data?.places) && data.places.length; if (!system) { return { reply: systemDeterministicFallback(intent, data), cards: [], + placeIds: [], quickReplies: greetingChips, modelId: null, usage: { inputTokens: 0, outputTokens: 0 }, @@ -1498,16 +1506,21 @@ async function generateSystemReply({ intent, message, history, entities, data }) 'DATA:', JSON.stringify(slimData), '', - jsonOnlyHint('{"message":string}'), + placeMode + ? jsonOnlyHint('{"message":string,"place_ids":string[]}', 'using DATA.places[].id when useful') + : jsonOnlyHint('{"message":string}'), ); const userText = userParts.join('\n'); console.log('[step2][system][context]', { intent, + adjacent, + strongModel: adjacent, emailSent: Boolean(data?.emailSent), rateLimited: Boolean(data?.rateLimited), hasContact: Boolean(data?.contact?.email || data?.contact?.phone || data?.contact?.text), faqCount: Array.isArray(data?.faqs) ? data.faqs.length : 0, + placesIn: Array.isArray(data?.places) ? data.places.length : 0, contextChars: userText.length, }); @@ -1516,7 +1529,7 @@ async function generateSystemReply({ intent, message, history, entities, data }) system, messages: [{ role: 'user', content: [{ text: userText }] }], inferenceConfig: CALL2_INFERENCE, - logLabel: 'call2-system', + logLabel: adjacent ? 'call2-adjacent' : 'call2-system', }); const raw = responseText(response); @@ -1527,6 +1540,10 @@ async function generateSystemReply({ intent, message, history, entities, data }) if (intent === 'out_of_scope') reply = sanitizeOutOfScopeReply(reply, data?.eventName); if (intent === 'emergency_medical') reply = ensureEmergencyLead(reply); + let placeIds = []; + if (placeMode) placeIds = resolvePlaceIds(parsed?.placeIds, data); + const cards = placeMode ? buildPlaceCards(placeIds, data) : []; + // Never let the model invent a successful organizer email if ( data?.emailSent !== true @@ -1552,12 +1569,15 @@ async function generateSystemReply({ intent, message, history, entities, data }) console.log('[step2][system][parsed]', { intent, messagePreview: reply.slice(0, 160), + placeIds, + cardCount: cards.length, quickReplies: greetingChips ? greetingChips.length : 0, }); return { - reply: reply.slice(0, 1800), - cards: [], + reply: reply.slice(0, adjacent ? 2200 : 1800), + cards, + placeIds, quickReplies: greetingChips, modelId, usage: { @@ -1572,6 +1592,7 @@ async function generateSystemReply({ intent, message, history, entities, data }) return { reply, cards: [], + placeIds: [], quickReplies: greetingChips, modelId, usage: { inputTokens: 0, outputTokens: 0 }, @@ -1740,6 +1761,7 @@ module.exports = { generateSystemReply, generateLocalReply, getCall2ModelId, + getCall2StrongModelId, DEFAULT_CALL2_MODEL_ID, FALLBACK_REPLY, VENUE_FALLBACK_REPLY, diff --git a/src/services/systemData.service.js b/src/services/systemData.service.js index c6f22d1..3ec973c 100644 --- a/src/services/systemData.service.js +++ b/src/services/systemData.service.js @@ -9,6 +9,7 @@ const SYSTEM_INTENTS = new Set([ 'human_agent_escalation', 'chitchat_greeting', 'out_of_scope', + 'event_adjacent', ]); const ESCALATION_INTENTS = new Set([ @@ -21,6 +22,23 @@ const ESCALATION_EMAIL_COOLDOWN_MS = 60 * 1000; const HELPDESK = 'Please ask at the venue help desk or contact event staff on site.'; const ANSWER_MAX = 600; +function cityHint(location) { + const loc = String(location || '').trim(); + if (!loc) return null; + const parts = loc.split(',').map((p) => p.trim()).filter(Boolean); + return parts[parts.length - 1] || loc; +} + +function compactEventDates(event) { + if (!event || typeof event !== 'object') return null; + const start = event.startDate || event.start || event.dateFrom || event.startsAt; + const end = event.endDate || event.end || event.dateTo || event.endsAt; + const out = {}; + if (start) out.start = start instanceof Date ? start.toISOString() : String(start); + if (end) out.end = end instanceof Date ? end.toISOString() : String(end); + return Object.keys(out).length ? out : null; +} + function isSystemIntent(intent) { return SYSTEM_INTENTS.has(String(intent || '')); } @@ -134,6 +152,15 @@ async function fetchSystemData({ intent, bot = null, event = null, session = nul } else if (intentKey === 'out_of_scope') { base.note = 'Unrelated question. One short sentence — do not name the event and do not start with "I\'m afraid". Point them toward agenda, venue, or ticket help.'; + } else if (intentKey === 'event_adjacent') { + const location = String(event?.location || '').trim() || null; + base.eventName = null; + base.location = location; + base.city = cityHint(location); + base.eventDates = compactEventDates(event); + base.allowGeneralLocal = true; + base.note = + 'World knowledge is allowed only for this city and event dates. Other cities: refuse. Never invent programme, tickets, Wi‑Fi, FAQ policy, or organizer contacts. Ask the attendee to verify hours and maps. Keep it decent; no PII.'; } return base; } diff --git a/src/services/techData.service.js b/src/services/techData.service.js index bc04bff..3980a00 100644 --- a/src/services/techData.service.js +++ b/src/services/techData.service.js @@ -51,6 +51,8 @@ function answeredFaqsForIntent(bot, intent) { .map((f) => ({ q: trimText(f.question, 200), a: trimText(faqAnswerText(f), ANSWER_MAX), + key: String(f?.key || f?.category || category || '').trim() || null, + category: String(f?.category || category || '').trim() || null, })) .filter((f) => f.q && f.a); } diff --git a/src/services/ticketData.service.js b/src/services/ticketData.service.js index bd85005..5951102 100644 --- a/src/services/ticketData.service.js +++ b/src/services/ticketData.service.js @@ -211,9 +211,12 @@ function registrationFaqsFromBot(bot) { .map((f) => { const q = String(f?.question || '').trim(); const a = faqAnswerText(f).trim(); + const cat = String(f?.category || '').trim(); return { q: q.length > 200 ? `${q.slice(0, 200)}…` : q, a: a.length > FAQ_ANSWER_MAX ? `${a.slice(0, FAQ_ANSWER_MAX)}…` : a, + key: String(f?.key || cat || 'registration').trim() || null, + category: cat || null, }; }) .filter((f) => f.a); diff --git a/src/services/venueData.service.js b/src/services/venueData.service.js index 997de80..4651d93 100644 --- a/src/services/venueData.service.js +++ b/src/services/venueData.service.js @@ -5,6 +5,7 @@ const Session = require('../models/Session'); const { faqAnswerText, mapMediaFromBot } = require('../utils/faqAnswer'); const { transportModesFromBot } = require('../utils/transportModes'); const { looksLikeFrontDesk } = require('../utils/organizerContact'); +const { extractTravelOrigin } = require('../utils/domainAction'); const VENUE_INTENTS = new Set([ 'venue_map_request', @@ -58,6 +59,8 @@ function answeredFaqsForCategories(bot, categories) { .map((f) => ({ q: trimText(f.question, 200), a: trimText(faqAnswerText(f), ANSWER_MAX), + key: String(f?.key || f?.category || '').trim() || null, + category: String(f?.category || '').trim() || null, })) .filter((f) => f.q && f.a); } @@ -167,7 +170,9 @@ async function fetchVenueData({ intent, eventId, bot = null, entities = {}, mess const hasFaqs = faqs.length > 0 || Boolean(mapMedia?.url); const hasTransportModes = Boolean(transportModes?.enforce); const origin = - String(entities?.location || entities?.topic || '').trim() || null; + String(entities?.location || '').trim() + || extractTravelOrigin(message, null) + || null; const meta = { intent, diff --git a/src/services/whatsappFollowup.service.js b/src/services/whatsappFollowup.service.js index 72fefd8..3fd8575 100644 --- a/src/services/whatsappFollowup.service.js +++ b/src/services/whatsappFollowup.service.js @@ -1,6 +1,7 @@ const crypto = require('crypto'); const { handleChatTurn } = require('./orchestrator.service'); const { sendConciergeResultAsWhatsApp } = require('../utils/whatsappFormat'); +const { mergeAlreadyTold } = require('../utils/alreadyTold'); const { getOrgWhatsAppCredentials } = require('../utils/whatsappCloud'); const { sendTextMessage } = require('../utils/whatsappSend'); const { logWhatsApp } = require('../utils/whatsappLog'); @@ -76,6 +77,7 @@ async function handleFollowup({ bot, event, session, messageData }) { if (Array.isArray(result.sessionIds) && result.sessionIds.length) { session.lastSessionIds = result.sessionIds.map((id) => String(id)).filter(Boolean).slice(0, 8); } + mergeAlreadyTold(session, result.faqKeysUsed); await session.save(); try { diff --git a/src/services/whatsappInbound.service.js b/src/services/whatsappInbound.service.js index 8448229..7d949ca 100644 --- a/src/services/whatsappInbound.service.js +++ b/src/services/whatsappInbound.service.js @@ -58,7 +58,7 @@ async function processInboundMessage(jobData) { } const eventId = bot.event; - const event = await Event.findById(eventId).select('name location locationType').lean(); + const event = await Event.findById(eventId).select('name location locationType startDate endDate').lean(); const eventName = event?.name || 'Event'; const userPhone = String(messageData.from || '').replace(/\D/g, ''); if (!userPhone) { diff --git a/src/services/whatsappReception.service.js b/src/services/whatsappReception.service.js index 6320fc5..ecf354d 100644 --- a/src/services/whatsappReception.service.js +++ b/src/services/whatsappReception.service.js @@ -1,6 +1,7 @@ const Participant = require('../models/Participant'); const { matchParticipantByPhone, + matchOrgParticipantByPhone, matchParticipantByName, participantFullName, membershipForEvent, @@ -28,6 +29,81 @@ function receptionCopy(bot) { }; } +function allowNonRegistered(bot) { + return Boolean(bot.reception?.allowNonRegistered); +} + +function splitDisplayName(raw) { + const name = String(raw || '').trim(); + if (!name) return { firstName: 'Guest', lastName: '' }; + const parts = name.split(/\s+/).filter(Boolean); + return { + firstName: parts[0] || 'Guest', + lastName: parts.slice(1).join(' ') || '', + }; +} + +async function addEventMembership(participantDoc, eventId) { + const mem = membershipForEvent(participantDoc, eventId); + if (mem) return mem; + participantDoc.eventMemberships = participantDoc.eventMemberships || []; + participantDoc.eventMemberships.push({ + event: eventId, + status: 'confirmed', + registrationDate: new Date(), + }); + await participantDoc.save(); + return membershipForEvent(participantDoc, eventId); +} + +/** + * Reuse an org-wide phone match or create a walk-in from WhatsApp phone + profile name. + * @returns {{ kind: 'linked', participant: object, method: string, score: number } | { kind: 'skip' }} + */ +async function ensureWalkInParticipant(bot, session, { eventId, waName }) { + const orgPhoneHit = await matchOrgParticipantByPhone({ + Participant, + organizationId: bot.organization, + waDigits: session.phoneNumber, + }); + if (orgPhoneHit.kind === 'ambiguous') { + logWhatsApp('walkin.skip', { reason: 'org_phone_ambiguous', candidates: (orgPhoneHit.candidates || []).length }); + return { kind: 'skip' }; + } + if (orgPhoneHit.kind === 'unique') { + const existing = await Participant.findById(orgPhoneHit.participant._id); + if (!existing || existing.deletedAt) return { kind: 'skip' }; + const mem = membershipForEvent(existing, eventId); + if (mem && !membershipAllowed(mem)) { + logWhatsApp('walkin.skip', { reason: 'membership_blocked', participantId: String(existing._id) }); + return { kind: 'skip' }; + } + if (!mem) await addEventMembership(existing, eventId); + logWhatsApp('walkin.existing', { participantId: String(existing._id) }); + return { kind: 'linked', participant: existing, method: 'phone', score: 1 }; + } + + const { firstName, lastName } = splitDisplayName(waName || session.waProfileName); + const displayName = String(waName || session.waProfileName || '').trim(); + const created = await Participant.create({ + tenantOrganization: bot.organization, + firstName, + lastName, + displayName: displayName || undefined, + phone: session.phoneNumber, + notes: 'Created from WhatsApp reception', + eventMemberships: [ + { + event: eventId, + status: 'confirmed', + registrationDate: new Date(), + }, + ], + }); + logWhatsApp('walkin.created', { participantId: String(created._id), from: maskPhone(session.phoneNumber) }); + return { kind: 'linked', participant: created, method: 'created', score: 1 }; +} + async function credsForBot(bot) { return getOrgWhatsAppCredentials(String(bot.organization)); } @@ -109,6 +185,25 @@ async function tryIdentify(bot, session, { waName, eventId, eventName }) { } logWhatsApp('match.name', { kind: nameHit.kind, score: nameHit.topScore || 0, waName }); + if (phoneHit.kind === 'ambiguous' || nameHit.kind === 'ambiguous') { + session.receptionState = 'AWAITING_BADGE'; + await session.save(); + return false; + } + + if (allowNonRegistered(bot)) { + try { + const walkIn = await ensureWalkInParticipant(bot, session, { eventId, waName }); + if (walkIn.kind === 'linked') { + await linkParticipant(session, walkIn.participant, walkIn.method, walkIn.score); + await sendWelcomeAndDrips(bot, session, walkIn.participant, eventName); + return true; + } + } catch (err) { + console.error('[whatsapp] reception walk-in create failed', err.message); + } + } + session.receptionState = 'AWAITING_BADGE'; await session.save(); return false; @@ -137,43 +232,57 @@ async function handleFirstInbound(bot, session, { profileName, eventId, eventNam async function handleBadgeImage(bot, session, { mediaId, eventId, eventName }) { const creds = await credsForBot(bot); - if (!creds?.accessToken) { - await sendToSession(bot, session, receptionCopy(bot).staff); - return; - } - const media = await downloadWhatsAppMedia(mediaId, creds.accessToken); - session.badgeAttempts = Number(session.badgeAttempts || 0) + 1; + if (creds?.accessToken) { + const media = await downloadWhatsAppMedia(mediaId, creds.accessToken); + session.badgeAttempts = Number(session.badgeAttempts || 0) + 1; - if (media?.buffer) { - const participantId = await decodeParticipantIdFromImage(media.buffer); - if (participantId) { - const p = await Participant.findById(participantId).lean(); - const mem = p && membershipForEvent(p, eventId); - if ( - p && - !p.deletedAt && - String(p.tenantOrganization) === String(bot.organization) && - membershipAllowed(mem) - ) { - await linkParticipant(session, p, 'badge_qr', 1); - await sendWelcomeAndDrips(bot, session, p, eventName); - return; + if (media?.buffer) { + const participantId = await decodeParticipantIdFromImage(media.buffer); + if (participantId) { + const p = await Participant.findById(participantId).lean(); + const mem = p && membershipForEvent(p, eventId); + if ( + p && + !p.deletedAt && + String(p.tenantOrganization) === String(bot.organization) && + membershipAllowed(mem) + ) { + await linkParticipant(session, p, 'badge_qr', 1); + await sendWelcomeAndDrips(bot, session, p, eventName); + return; + } + } + + const lines = await detectBadgeTextLines(media.buffer); + for (const line of lines) { + const nameHit = await matchParticipantByName({ + Participant, + organizationId: bot.organization, + eventId, + waName: line, + }); + if (nameHit.kind === 'unique') { + await linkParticipant(session, nameHit.participant, 'badge_ocr', nameHit.score); + await sendWelcomeAndDrips(bot, session, nameHit.participant, eventName); + return; + } } } + } - const lines = await detectBadgeTextLines(media.buffer); - for (const line of lines) { - const nameHit = await matchParticipantByName({ - Participant, - organizationId: bot.organization, + if (allowNonRegistered(bot)) { + try { + const walkIn = await ensureWalkInParticipant(bot, session, { eventId, - waName: line, + waName: session.waProfileName, }); - if (nameHit.kind === 'unique') { - await linkParticipant(session, nameHit.participant, 'badge_ocr', nameHit.score); - await sendWelcomeAndDrips(bot, session, nameHit.participant, eventName); + if (walkIn.kind === 'linked') { + await linkParticipant(session, walkIn.participant, walkIn.method, walkIn.score); + await sendWelcomeAndDrips(bot, session, walkIn.participant, eventName); return; } + } catch (err) { + console.error('[whatsapp] reception walk-in create failed', err.message); } } @@ -192,6 +301,14 @@ async function handleUnlinkedMessage(bot, session, { messageData, eventId, event }); return; } + if (allowNonRegistered(bot)) { + const linked = await tryIdentify(bot, session, { + waName: session.waProfileName, + eventId, + eventName, + }); + if (linked) return; + } const copy = receptionCopy(bot); if (session.receptionState === 'AWAITING_STAFF') { await sendToSession(bot, session, copy.staff); diff --git a/src/utils/adjacentSafety.js b/src/utils/adjacentSafety.js new file mode 100644 index 0000000..3d16fe3 --- /dev/null +++ b/src/utils/adjacentSafety.js @@ -0,0 +1,24 @@ +/** + * Block unsafe asks before the strong adjacent model. + */ +const UNSAFE_RE = + /\b(porn|xxx|nude|nudes|sex with|sexual|onlyfans|child porn|rape|kill (him|her|them)|bomb how|how to (make|build) (a )?(bomb|weapon)|give me (their|attendee|everyone'?s) (phone|email|number|address)|list (all )?attendees|who is staying (in|at)|home address)\b/i; + +const MEDICAL_ADVICE_RE = + /\b(what (medicine|drug|dose)|prescribe|am i having a heart attack)\b/i; + +function isUnsafeAdjacentMessage(message) { + const t = String(message || ''); + if (!t.trim()) return false; + if (UNSAFE_RE.test(t)) return true; + if (MEDICAL_ADVICE_RE.test(t)) return true; + return false; +} + +const UNSAFE_REFUSE = + 'I can’t help with that. If you need event help, ask about the agenda, venue, or your ticket. For an emergency, go to on-site security or medical staff.'; + +module.exports = { + isUnsafeAdjacentMessage, + UNSAFE_REFUSE, +}; diff --git a/src/utils/alreadyTold.js b/src/utils/alreadyTold.js new file mode 100644 index 0000000..263c481 --- /dev/null +++ b/src/utils/alreadyTold.js @@ -0,0 +1,10 @@ +function mergeAlreadyTold(session, keys) { + const incoming = (Array.isArray(keys) ? keys : []) + .map((k) => String(k || '').trim()) + .filter(Boolean); + if (!incoming.length || !session) return; + const prev = Array.isArray(session.alreadyTold) ? session.alreadyTold : []; + session.alreadyTold = [...new Set([...prev, ...incoming])].slice(-50); +} + +module.exports = { mergeAlreadyTold }; diff --git a/src/utils/bedrock.js b/src/utils/bedrock.js index be41f75..954159b 100644 --- a/src/utils/bedrock.js +++ b/src/utils/bedrock.js @@ -3,7 +3,7 @@ const { ConverseCommand, } = require('@aws-sdk/client-bedrock-runtime'); -const DEFAULT_ROUTER_MODEL_ID = 'amazon.nova-micro-v1:0'; +const DEFAULT_ROUTER_MODEL_ID = 'amazon.nova-lite-v1:0'; const DEFAULT_CALL2_MODEL_ID = 'anthropic.claude-3-haiku-20240307-v1:0'; function getRegion() { @@ -23,6 +23,12 @@ function getCall2ModelId() { return process.env.BEDROCK_CALL2_MODEL_ID || DEFAULT_CALL2_MODEL_ID; } +/** Stronger Call 2 for event-adjacent only. Unset → same as Haiku Call 2. */ +function getCall2StrongModelId() { + const id = String(process.env.BEDROCK_CALL2_MODEL_STRONG_ID || '').trim(); + return id || getCall2ModelId(); +} + /** * Nova models in APAC often need inference profile IDs (apac.*) instead of foundation model IDs. * Anthropic / other foundation model IDs are left unchanged. @@ -81,6 +87,62 @@ function messageTexts(messages) { })); } +/** Claude 4.x on Bedrock rejects inference configs that set both temperature and topP. */ +function inferenceForbidsTempAndTopP(modelId) { + const id = String(modelId || '').toLowerCase(); + return /claude-(haiku|sonnet|opus)-4/.test(id) || /anthropic\.claude-4/.test(id); +} + +function sanitizeInferenceConfig(modelId, config) { + const next = { ...(config || {}) }; + if ( + inferenceForbidsTempAndTopP(modelId) + && next.temperature != null + && next.topP != null + ) { + delete next.topP; + } + return next; +} + +function isPromptCacheEnabled() { + const v = String(process.env.BEDROCK_PROMPT_CACHE ?? 'true').toLowerCase(); + return v !== 'false' && v !== '0' && v !== 'no'; +} + +/** + * Claude 3 Haiku (20240307) rejects cachePoint. Nova and later Claude models accept it. + */ +function supportsPromptCache(modelId) { + const id = String(modelId || '').toLowerCase(); + if (/claude-3-haiku-20240307/.test(id)) return false; + if (id.includes('amazon.nova-')) return true; + if (/claude-(haiku|sonnet|opus)-4/.test(id) || /anthropic\.claude-4/.test(id)) return true; + if (/claude-3-7/.test(id)) return true; + if (/claude-3-5-sonnet/.test(id) || /claude-3-5-haiku/.test(id)) return true; + return false; +} + +function systemBlocksFor(system, modelId) { + if (!system) return undefined; + const blocks = [{ text: system }]; + if (isPromptCacheEnabled() && supportsPromptCache(modelId)) { + blocks.push({ cachePoint: { type: 'default' } }); + } + return blocks; +} + +function usageForLog(usage) { + if (!usage) return null; + return { + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + totalTokens: usage.totalTokens, + cacheReadInputTokens: usage.cacheReadInputTokens ?? 0, + cacheWriteInputTokens: usage.cacheWriteInputTokens ?? 0, + }; +} + /** * @param {import('@aws-sdk/client-bedrock-runtime').BedrockRuntimeClient} client * @param {string} modelId @@ -90,13 +152,13 @@ async function converse(client, modelId, opts) { const { system, messages, inferenceConfig, logLabel } = opts; const region = getRegion(); const resolvedModelId = resolveModelId(modelId, region); - const systemBlocks = system ? [{ text: system }] : undefined; - const mergedInferenceConfig = { + const systemBlocks = systemBlocksFor(system, resolvedModelId); + const mergedInferenceConfig = sanitizeInferenceConfig(resolvedModelId, { maxTokens: 400, temperature: 0, topP: 0.1, ...inferenceConfig, - }; + }); const label = logLabel || 'converse'; const shouldLog = Boolean(logLabel) || isLlmLogEnabled(); const startedAt = Date.now(); @@ -107,6 +169,7 @@ async function converse(client, modelId, opts) { modelId: resolvedModelId, requestedModelId: modelId !== resolvedModelId ? modelId : undefined, inferenceConfig: mergedInferenceConfig, + promptCache: Boolean(systemBlocks?.some((b) => b.cachePoint)), system, messages: messageTexts(messages), }); @@ -126,7 +189,7 @@ async function converse(client, modelId, opts) { console.log(`[bedrock][${label}][response]`, { modelId: resolvedModelId, latencyMs: Date.now() - startedAt, - usage: response.usage || null, + usage: usageForLog(response.usage), stopReason: response.stopReason || null, text: responseText(response), }); @@ -152,6 +215,7 @@ module.exports = { getRegion, getRouterModelId, getCall2ModelId, + getCall2StrongModelId, resolveModelId, createClient, converse, diff --git a/src/utils/call2Context.js b/src/utils/call2Context.js index 5b8bca7..e3c2f4b 100644 --- a/src/utils/call2Context.js +++ b/src/utils/call2Context.js @@ -27,6 +27,13 @@ function compactEntities(entities) { return omitEmpty(entities && typeof entities === 'object' ? entities : {}); } +function alreadyToldField(src) { + const keys = Array.isArray(src?.alreadyTold) + ? [...new Set(src.alreadyTold.map((k) => String(k || '').trim()).filter(Boolean))].slice(0, 24) + : []; + return keys.length ? keys : undefined; +} + function slimTrack(track) { if (!track) return undefined; if (typeof track === 'string') return track; @@ -156,6 +163,7 @@ function slimDataForLlm(data, intent) { query: src.query ? omitEmpty(src.query) : null, tracks: slimTracksList(src.tracks), conflicts: slimConflicts(src.conflicts), + alreadyTold: alreadyToldField(src), }), sessions, }; @@ -228,6 +236,7 @@ function slimVenueDataForLlm(data) { halls: Array.isArray(src.halls) && src.halls.length ? src.halls.slice(0, 20) : undefined, rooms: Array.isArray(src.rooms) && src.rooms.length ? src.rooms.slice(0, 25) : undefined, places: places.length ? places : undefined, + alreadyTold: alreadyToldField(src), }); } @@ -291,18 +300,33 @@ function slimTicketDataForLlm(data) { found: src.found === false ? false : src.found ? true : null, policy: src.policy || null, note: hideEventName ? null : src.note || null, - registrationLinks: Array.isArray(src.registrationLinks) - ? src.registrationLinks - .map((l) => - omitEmpty({ - label: l.label || null, - url: l.url || null, - }), - ) - .filter((l) => l.url) - .slice(0, 8) - : undefined, - contact: contact && (contact.email || contact.phone || contact.text) ? contact : undefined, + registrationLinks: hideEventName + ? undefined + : Array.isArray(src.registrationLinks) + ? src.registrationLinks + .map((l) => + omitEmpty({ + label: l.label || null, + url: l.url || null, + }), + ) + .filter((l) => l.url) + .slice(0, 8) + : undefined, + hasRegistrationButtons: + hideEventName && Array.isArray(src.registrationLinks) && src.registrationLinks.some((l) => l.url) + ? true + : null, + registrationOptions: + hideEventName && Array.isArray(src.registrationLinks) + ? src.registrationLinks.map((l) => l.label).filter(Boolean).slice(0, 8) + : undefined, + contact: + hideEventName + ? undefined + : contact && (contact.email || contact.phone || contact.text) + ? contact + : undefined, faqs: faqs.length ? faqs : undefined, tickets: tickets.length ? tickets : undefined, emailSent: src.emailSent === true ? true : src.emailSent === false ? false : null, @@ -343,6 +367,7 @@ function slimTicketDataForLlm(data) { }) : null, receipt: receipt && Object.keys(receipt).length ? receipt : null, + alreadyTold: alreadyToldField(src), }); } @@ -431,6 +456,7 @@ function slimSpeakerDataForLlm(data) { !isList && Array.isArray(src.meetSessions) && src.meetSessions.length ? src.meetSessions.length : undefined, + alreadyTold: alreadyToldField(src), }); } @@ -515,6 +541,7 @@ function slimExhibitorDataForLlm(data) { exhibitors: exhibitors.length ? exhibitors : undefined, candidates: candidates.length ? candidates : undefined, faqs: faqs.length ? faqs : undefined, + alreadyTold: alreadyToldField(src), }); } @@ -544,6 +571,7 @@ function slimTechDataForLlm(data) { note: src.note || null, faqs: faqs.length ? faqs : undefined, staticTips: staticTips.length ? staticTips : undefined, + alreadyTold: alreadyToldField(src), }); } @@ -574,15 +602,52 @@ function slimSystemDataForLlm(data) { text: src.contact.text || null, }) : null; - const hideEventName = src.intent === 'out_of_scope' || src.intent === 'chitchat_greeting'; + const hideEventName = + src.intent === 'out_of_scope' + || src.intent === 'chitchat_greeting' + || src.intent === 'event_adjacent'; + const places = Array.isArray(src.places) + ? src.places + .map((p) => + omitEmpty({ + id: p.id, + name: p.name || null, + address: p.address || null, + rating: p.rating != null ? p.rating : null, + category: p.category || null, + }), + ) + .filter((p) => p.id && p.name) + .slice(0, 8) + : []; + const hotels = Array.isArray(src.hotels) + ? src.hotels + .map((h) => + omitEmpty({ + id: h.id, + name: h.name || null, + address: h.address || null, + }), + ) + .filter((h) => h.id && h.name) + .slice(0, 8) + : []; return omitEmpty({ eventName: hideEventName ? null : src.eventName || null, + location: src.location || null, + city: src.city || null, + eventDates: src.eventDates || null, + allowGeneralLocal: src.allowGeneralLocal ? true : null, + dish: src.dish || null, emailSent: src.emailSent === true ? true : null, rateLimited: src.rateLimited === true ? true : null, emailError: src.emailError || null, note: src.note || null, contact: contact && (contact.email || contact.phone || contact.text) ? contact : undefined, faqs: faqs.length ? faqs : undefined, + places: places.length ? places : undefined, + hotels: hotels.length ? hotels : undefined, + alreadyTold: alreadyToldField(src), }); } @@ -646,6 +711,7 @@ function slimLocalDataForLlm(data) { note: src.note || null, places: places.length ? places : undefined, hotels: hotels.length ? hotels : undefined, + alreadyTold: alreadyToldField(src), }); } diff --git a/src/utils/domainAction.js b/src/utils/domainAction.js new file mode 100644 index 0000000..a5796ab --- /dev/null +++ b/src/utils/domainAction.js @@ -0,0 +1,334 @@ +/** + * Call 1 surface: domain + action. Fetchers still use a derived fetchIntent. + */ + +const DOMAINS = [ + 'agenda', + 'speaker', + 'venue', + 'local', + 'ticket', + 'exhibitor', + 'tech', + 'system', +]; + +const ACTIONS = [ + 'read', + 'bookmark_add', + 'bookmark_remove', + 'email_qr', + 'emergency', + 'escalate_human', + 'adjacent', + 'oos', + 'greeting', +]; + +const DOMAIN_SET = new Set(DOMAINS); +const ACTION_SET = new Set(ACTIONS); + +/** Old Call 1 intent ids → domain/action (model aliases + pendingIntent). */ +const LEGACY_TO_DOMAIN_ACTION = { + agenda_current_now: { domain: 'agenda', action: 'read' }, + agenda_upcoming_next: { domain: 'agenda', action: 'read' }, + agenda_by_track: { domain: 'agenda', action: 'read' }, + agenda_by_speaker: { domain: 'agenda', action: 'read' }, + agenda_session_details: { domain: 'agenda', action: 'read' }, + agenda_personal_schedule: { domain: 'agenda', action: 'read' }, + agenda_conflict_check: { domain: 'agenda', action: 'read' }, + agenda_download_materials: { domain: 'agenda', action: 'read' }, + agenda_bookmark_add: { domain: 'agenda', action: 'bookmark_add' }, + agenda_bookmark_remove: { domain: 'agenda', action: 'bookmark_remove' }, + speaker_bio_lookup: { domain: 'speaker', action: 'read' }, + speaker_meet_and_greet: { domain: 'speaker', action: 'read' }, + speaker_contact_info: { domain: 'speaker', action: 'read' }, + venue_map_request: { domain: 'venue', action: 'read' }, + venue_wayfinding: { domain: 'venue', action: 'read' }, + venue_nearest_amenity: { domain: 'venue', action: 'read' }, + venue_parking_transport: { domain: 'venue', action: 'read' }, + venue_travel_directions: { domain: 'venue', action: 'read' }, + venue_food_beverage: { domain: 'venue', action: 'read' }, + venue_accessibility: { domain: 'venue', action: 'read' }, + tourist_places: { domain: 'local', action: 'read' }, + accommodation: { domain: 'local', action: 'read' }, + event_adjacent: { domain: 'local', action: 'adjacent' }, + ticket_qr_display: { domain: 'ticket', action: 'read' }, + ticket_email_qr: { domain: 'ticket', action: 'email_qr' }, + ticket_status_lookup: { domain: 'ticket', action: 'read' }, + ticket_how_to_register: { domain: 'ticket', action: 'read' }, + ticket_document_receipt: { domain: 'ticket', action: 'read' }, + ticket_transfer_request: { domain: 'ticket', action: 'read' }, + ticket_upgrade: { domain: 'ticket', action: 'read' }, + exhibitor_location: { domain: 'exhibitor', action: 'read' }, + exhibitor_by_category: { domain: 'exhibitor', action: 'read' }, + exhibitor_demo_schedule: { domain: 'exhibitor', action: 'read' }, + exhibitor_book_meeting: { domain: 'exhibitor', action: 'read' }, + wifi_credentials: { domain: 'tech', action: 'read' }, + app_issue_streaming: { domain: 'tech', action: 'read' }, + app_issue_account: { domain: 'tech', action: 'read' }, + app_feature_guidance: { domain: 'tech', action: 'read' }, + emergency_medical: { domain: 'system', action: 'emergency' }, + lost_and_found: { domain: 'system', action: 'read' }, + human_agent_escalation: { domain: 'system', action: 'escalate_human' }, + chitchat_greeting: { domain: 'system', action: 'greeting' }, + out_of_scope: { domain: 'system', action: 'oos' }, +}; + +function normMsg(message) { + return String(message || '').toLowerCase().replace(/\s+/g, ' ').trim(); +} + +function looksLikeFoodPolicy(message) { + const t = normMsg(message); + return ( + /\b(is|are) (food|lunch|dinner|breakfast|meals?|tea|coffee) (provided|included|served)\b/.test(t) + || /\b(food|meals?|lunch|dinner) provided\b/.test(t) + || /\bdietary (options?|needs|requirements?)\b/.test(t) + || /\btea\/coffee\b/.test(t) + || /\bwill (there be|you serve|they serve) (food|lunch|meals?)\b/.test(t) + ); +} + +function looksLikeDishAsk(message, dish) { + if (String(dish || '').trim()) return true; + const t = normMsg(message); + if (looksLikeFoodPolicy(t)) return false; + return ( + /\b(want to|wanted to|wanna|like to|crave|craving) eat\b/.test(t) + || /\b(eat|eating|try|trying) (some )?(puttu|dosa|idli|biryani|appam|porotta|parotta)\b/.test(t) + || /\b(puttu|dosa|idli|appam|porotta|parotta|biryani)\b/.test(t) + ); +} + +function looksLikeItinerary(message) { + const t = normMsg(message); + return ( + /\b(itinerary|day plan|one[- ]day|1[- ]day|tourist plan)\b/.test(t) + || /\bafter (the |this )?event\b/.test(t) + && /\b(plan|roam|visit|sightseeing|tour|explore)\b/.test(t) + || /\bwhat (can|should) i (do|see|visit).{0,40}(after|free time|spare)\b/.test(t) + ); +} + +function inferAgendaFetchIntent(message, entities) { + const t = normMsg(message); + if (/\b(add|save|bookmark|put on my agenda)\b/.test(t)) return 'agenda_bookmark_add'; + if (/\b(remove|unsave|unbookmark)\b/.test(t)) return 'agenda_bookmark_remove'; + if (/\b(my (sessions|schedule|agenda)|saved sessions|bookmarked)\b/.test(t)) { + return 'agenda_personal_schedule'; + } + if (/\bconflict|overlap\b/.test(t)) return 'agenda_conflict_check'; + if (/\b(slides?|materials?|handouts?|pdfs?|download)\b/.test(t)) return 'agenda_download_materials'; + if (entities?.speaker_name && /\b(when|session|speak|talk|lecture)\b/.test(t)) { + return 'agenda_by_speaker'; + } + if (entities?.speaker_name && !/\bwho is|bio|about\b/.test(t)) return 'agenda_by_speaker'; + if (/\b(now|currently|happening right now|live now)\b/.test(t)) return 'agenda_current_now'; + if (/\b(what'?s next|upcoming|coming up)\b/.test(t)) return 'agenda_upcoming_next'; + if (entities?.topic && /\btrack|stream\b/.test(t)) return 'agenda_by_track'; + return 'agenda_session_details'; +} + +function looksLikeEventDeskQuestion(message) { + const t = normMsg(message); + if (!t) return false; + return /\b(event|summit|conference|congress|attend|attending|attendee|register|registration|ticket|session|speaker|venue|agenda|wifi|wi-fi|exhibitor|exhibit)\b/.test( + t, + ); +} + +function looksLikeCityToVenueTravel(message) { + const t = normMsg(message); + if (!t) return false; + const fromOrigin = + /\b(travell?ing|traveling|coming|arriving)\s+from\b/.test(t) + || /\bfrom (?!the\b|this\b|that\b|here\b|there\b|home\b)[a-z]{3,}\b/.test(t); + const sightseeingOnly = + /\b(forts?|beaches?|churches?|sightseeing|places to visit|tourist)\b/.test(t) + && !fromOrigin + && !/\b(flight|train|how to reach|travel options?)\b/.test(t); + if (sightseeingOnly) return false; + return ( + /\b(travell?ing|traveling)\s+from\b/.test(t) + || /\btravel options?\b/.test(t) + || /\bhow (do|can|to) (i |we )?(get to|reach|travel to|come to)\b/.test(t) + || /\b(flights?|trains?)\s+(from|to)\b/.test(t) + || (fromOrigin && /\b(travel|reach|flight|train|options?|how (do|can) i get)\b/.test(t)) + ); +} + +const TRAVEL_ORIGIN_STOP = new Set(['the', 'this', 'that', 'there', 'here', 'home', 'work', 'my', 'our']); + +function extractTravelOrigin(message, existing) { + const already = String(existing || '').trim(); + if (already) return already; + const t = String(message || ''); + const m = + t.match(/\b(?:travell?ing|traveling|coming|arriving)\s+from\s+([A-Za-z][A-Za-z.\s-]{1,32}?)(?:\s*[,.?!]|$)/i) + || t.match(/\bfrom\s+([A-Za-z][A-Za-z.]{2,32})\b/i); + if (!m) return null; + const city = String(m[1] || '').replace(/[.,]+$/g, '').trim(); + const first = city.split(/\s+/)[0].toLowerCase(); + if (!city || TRAVEL_ORIGIN_STOP.has(first)) return null; + return city; +} + +function inferVenueFetchIntent(message, entities) { + const t = normMsg(message); + if (/\b(map|floor plan|layout)\b/.test(t)) return 'venue_map_request'; + if (/\b(restroom|washroom|toilet|water|charging|info desk)\b/.test(t)) { + return 'venue_nearest_amenity'; + } + if (/\baccessib|wheelchair\b/.test(t)) return 'venue_accessibility'; + if (looksLikeCityToVenueTravel(t) || (entities?.location && /\b(train|flight|travel|reach|options?)\b/.test(t))) { + return 'venue_travel_directions'; + } + if (/\b(parking|taxi|uber|ola|shuttle|metro near)\b/.test(t) && !/\bfrom (?!the\b)[a-z]{3,}\b/.test(t)) { + return 'venue_parking_transport'; + } + if (looksLikeFoodPolicy(t) || (/\b(food|lunch|meals?|tea|coffee|dietary)\b/.test(t) && !looksLikeDishAsk(t))) { + return 'venue_food_beverage'; + } + if (/\b(where is|how do i find|hall|room|registration desk|stage)\b/.test(t)) { + return 'venue_wayfinding'; + } + return 'venue_wayfinding'; +} + +function inferLocalFetchIntent(message) { + const t = normMsg(message); + if (/\b(hotel|stay|lodging|accommodation|check-?in)\b/.test(t)) return 'accommodation'; + return 'tourist_places'; +} + +function inferTicketFetchIntent(message, action) { + if (action === 'email_qr') return 'ticket_email_qr'; + const t = normMsg(message); + if (/\b(show|display|see) (my )?(qr|badge)\b/.test(t) || /\bmy qr\b/.test(t)) { + return 'ticket_qr_display'; + } + if (/\b(invoice|receipt)\b/.test(t)) return 'ticket_document_receipt'; + if (/\btransfer\b/.test(t)) return 'ticket_transfer_request'; + if (/\bupgrade\b/.test(t)) return 'ticket_upgrade'; + if (/\b(my (registration|ticket|status)|already registered|check[- ]?in status|payment status)\b/.test(t)) { + return 'ticket_status_lookup'; + } + // Guest attending/joining/eligibility — public FAQs + registration links, not personal status. + return 'ticket_how_to_register'; +} + +function inferSpeakerFetchIntent(message) { + const t = normMsg(message); + if (/\b(meet|greeting|networking)\b/.test(t)) return 'speaker_meet_and_greet'; + if (/\b(linkedin|twitter|website|social)\b/.test(t)) return 'speaker_contact_info'; + return 'speaker_bio_lookup'; +} + +function inferExhibitorFetchIntent(message, entities) { + const t = normMsg(message); + if (/\bbook|meeting|appointment\b/.test(t)) return 'exhibitor_book_meeting'; + if (/\bdemo\b/.test(t)) return 'exhibitor_demo_schedule'; + if (entities?.topic && !entities?.company_name) return 'exhibitor_by_category'; + return 'exhibitor_location'; +} + +function inferTechFetchIntent(message) { + const t = normMsg(message); + if (/\bwifi|wi-fi|ssid|password\b/.test(t)) return 'wifi_credentials'; + if (/\bstream|video|audio|can'?t hear|black screen\b/.test(t)) return 'app_issue_streaming'; + if (/\blogin|otp|account|password reset\b/.test(t)) return 'app_issue_account'; + return 'app_feature_guidance'; +} + +/** + * Derive the fetch/Call-2 intent id used by existing services. + */ +function inferFetchIntent({ domain, action, entities = {}, message = '' } = {}) { + const d = String(domain || ''); + const a = String(action || 'read'); + if (a === 'bookmark_add') return 'agenda_bookmark_add'; + if (a === 'bookmark_remove') return 'agenda_bookmark_remove'; + if (a === 'email_qr') return 'ticket_email_qr'; + if (a === 'emergency') return 'emergency_medical'; + if (a === 'escalate_human') return 'human_agent_escalation'; + if (a === 'adjacent') return 'event_adjacent'; + if (a === 'oos') return 'out_of_scope'; + if (a === 'greeting') return 'chitchat_greeting'; + + if (d === 'agenda') return inferAgendaFetchIntent(message, entities); + if (d === 'speaker') { + const t = normMsg(message); + if (entities?.speaker_name && /\b(when|session|speak|lecture|talk)\b/.test(t)) { + return 'agenda_by_speaker'; + } + return inferSpeakerFetchIntent(message); + } + if (d === 'venue') return inferVenueFetchIntent(message, entities); + if (d === 'local') return inferLocalFetchIntent(message); + if (d === 'ticket') return inferTicketFetchIntent(message, a); + if (d === 'exhibitor') return inferExhibitorFetchIntent(message, entities); + if (d === 'tech') return inferTechFetchIntent(message); + if (d === 'system') { + const t = normMsg(message); + if (/\blost\b/.test(t)) return 'lost_and_found'; + return 'out_of_scope'; + } + return 'out_of_scope'; +} + +function fromLegacyIntent(intent) { + const mapped = LEGACY_TO_DOMAIN_ACTION[String(intent || '')]; + if (!mapped) return null; + return { ...mapped, fetchIntent: String(intent) }; +} + +function isValidDomain(d) { + return DOMAIN_SET.has(String(d || '')); +} + +function isValidAction(a) { + return ACTION_SET.has(String(a || '')); +} + +function faqKeyOf(row) { + return String(row?.key || row?.category || '').trim() || null; +} + +function isBookmarkAction(action) { + return action === 'bookmark_add' || action === 'bookmark_remove'; +} + +function isAuthRequiredFetchIntent(intent) { + return [ + 'agenda_personal_schedule', + 'agenda_conflict_check', + 'agenda_bookmark_add', + 'agenda_bookmark_remove', + 'ticket_qr_display', + 'ticket_email_qr', + 'ticket_status_lookup', + 'ticket_document_receipt', + ].includes(String(intent || '')); +} + +module.exports = { + DOMAINS, + ACTIONS, + DOMAIN_SET, + ACTION_SET, + LEGACY_TO_DOMAIN_ACTION, + looksLikeFoodPolicy, + looksLikeDishAsk, + looksLikeItinerary, + looksLikeCityToVenueTravel, + looksLikeEventDeskQuestion, + extractTravelOrigin, + inferFetchIntent, + fromLegacyIntent, + isValidDomain, + isValidAction, + faqKeyOf, + isBookmarkAction, + isAuthRequiredFetchIntent, +}; diff --git a/src/utils/participantMatch.js b/src/utils/participantMatch.js index 4bb43c7..5a08336 100644 --- a/src/utils/participantMatch.js +++ b/src/utils/participantMatch.js @@ -135,14 +135,44 @@ async function loadEventParticipants(Participant, organizationId, eventId) { return rows.filter((p) => membershipAllowed(membershipForEvent(p, eventId))); } +async function loadOrgParticipantsWithPhone(Participant, organizationId) { + return Participant.find({ + tenantOrganization: organizationId, + deletedAt: null, + phone: { $exists: true, $nin: [null, ''] }, + }) + .select('firstName lastName displayName phone email eventMemberships') + .lean(); +} + +function phoneHitsForDigits(people, waDigits) { + const key = phoneLast10Digits(waDigits); + if (!key || key.length < 8) return []; + return people.filter((p) => + extractPhoneLast10Keys(p.phone).some( + (k) => k === key || (k.length >= 8 && key.endsWith(k)) || (key.length >= 8 && k.endsWith(key)), + ), + ); +} + /** * @returns {{ kind: 'unique', participant: object, score: number } | { kind: 'none' } | { kind: 'ambiguous', candidates: object[] }} */ async function matchParticipantByPhone({ Participant, organizationId, eventId, waDigits }) { - const key = phoneLast10Digits(waDigits); - if (!key || key.length < 8) return { kind: 'none' }; const people = await loadEventParticipants(Participant, organizationId, eventId); - const hits = people.filter((p) => extractPhoneLast10Keys(p.phone).some((k) => k === key || (k.length >= 8 && key.endsWith(k)) || (key.length >= 8 && k.endsWith(key)))); + const hits = phoneHitsForDigits(people, waDigits); + if (hits.length === 1) return { kind: 'unique', participant: hits[0], score: 1 }; + if (hits.length > 1) return { kind: 'ambiguous', candidates: hits }; + return { kind: 'none' }; +} + +/** + * Org-wide phone match (not limited to this event). Used before creating a walk-in. + * @returns {{ kind: 'unique', participant: object, score: number } | { kind: 'none' } | { kind: 'ambiguous', candidates: object[] }} + */ +async function matchOrgParticipantByPhone({ Participant, organizationId, waDigits }) { + const people = await loadOrgParticipantsWithPhone(Participant, organizationId); + const hits = phoneHitsForDigits(people, waDigits); if (hits.length === 1) return { kind: 'unique', participant: hits[0], score: 1 }; if (hits.length > 1) return { kind: 'ambiguous', candidates: hits }; return { kind: 'none' }; @@ -177,6 +207,7 @@ module.exports = { participantFullName, scoreName, matchParticipantByPhone, + matchOrgParticipantByPhone, matchParticipantByName, membershipForEvent, membershipAllowed, diff --git a/src/utils/webChatTurn.js b/src/utils/webChatTurn.js index bd128ec..7ea6c3a 100644 --- a/src/utils/webChatTurn.js +++ b/src/utils/webChatTurn.js @@ -1,6 +1,7 @@ const crypto = require('crypto'); const WebChatSession = require('../models/WebChatSession'); const { handleChatTurn } = require('../services/orchestrator.service'); +const { mergeAlreadyTold } = require('./alreadyTold'); const MAX_MESSAGE_LENGTH = 800; const MAX_HISTORY_MESSAGES = 40; @@ -83,6 +84,7 @@ async function runPublicChatTurn({ bot, event, session, message }) { if (Array.isArray(result.sessionIds) && result.sessionIds.length) { session.lastSessionIds = result.sessionIds.map((id) => String(id)).filter(Boolean).slice(0, 8); } + mergeAlreadyTold(session, result.faqKeysUsed); await session.save(); return {