Some Changes

This commit is contained in:
Ravi Gulhane 2026-09-10 15:04:35 +05:30
parent fb78920fe9
commit fb291f768b
40 changed files with 1602 additions and 1032 deletions

View File

@ -40,7 +40,9 @@ async function loadBotOr404(botId) {
if (!bot.enabled || bot.status !== 'ACTIVE') { if (!bot.enabled || bot.status !== 'ACTIVE') {
return { err: error('FORBIDDEN', 'Chatbot is not enabled') }; 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 }; return { bot, event };
} }

View File

@ -11,6 +11,8 @@ const participantSchema = new mongoose.Schema(
firstName: { type: String, trim: true }, firstName: { type: String, trim: true },
lastName: { type: String, trim: true }, lastName: { type: String, trim: true },
displayName: { type: String, trim: true }, displayName: { type: String, trim: true },
phone: { type: String, trim: true },
notes: { type: String, trim: true },
deletedAt: { type: Date, default: null }, deletedAt: { type: Date, default: null },
payment: mongoose.Schema.Types.Mixed, payment: mongoose.Schema.Types.Mixed,
eventMemberships: [ eventMemberships: [

View File

@ -52,6 +52,8 @@ const webChatSessionSchema = new mongoose.Schema(
pendingEntities: { type: mongoose.Schema.Types.Mixed, default: null }, pendingEntities: { type: mongoose.Schema.Types.Mixed, default: null },
/** Last session ids shown as cards — used to resolve "add it" / "yes". */ /** Last session ids shown as cards — used to resolve "add it" / "yes". */
lastSessionIds: { type: [String], default: [] }, 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. */ /** Rate-limit ticket QR emails from this chat session. */
lastTicketEmailAt: { type: Date, default: null }, lastTicketEmailAt: { type: Date, default: null },
/** Rate-limit organizer escalation emails from this chat session. */ /** Rate-limit organizer escalation emails from this chat session. */

View File

@ -61,6 +61,7 @@ const whatsAppChatSessionSchema = new mongoose.Schema(
pendingMessage: { type: String, default: null }, pendingMessage: { type: String, default: null },
pendingEntities: { type: mongoose.Schema.Types.Mixed, default: null }, pendingEntities: { type: mongoose.Schema.Types.Mixed, default: null },
lastSessionIds: { type: [String], default: [] }, lastSessionIds: { type: [String], default: [] },
alreadyTold: { type: [String], default: [] },
lastTicketEmailAt: { type: Date, default: null }, lastTicketEmailAt: { type: Date, default: null },
lastEscalationEmailAt: { type: Date, default: null }, lastEscalationEmailAt: { type: Date, default: null },
}, },

View File

@ -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. 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. 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. 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. 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. Do not add organizer email/phone unless they asked who to contact. 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. Never repeat the event name. Never say "I can only assist with [event]". 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(); `.trim();
module.exports = { CALL2_CONVERSATION_PROMPT }; module.exports = { CALL2_CONVERSATION_PROMPT };

View File

@ -4,7 +4,7 @@ module.exports = {
usesHotelCards: true, usesHotelCards: true,
promptlet: ` promptlet: `
### INTENT TASK: accommodation ### 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 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 []. 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. Else suggest stays from DATA.hotels only; set hotel_ids (up to 6). Remind them to verify rates themselves.

View File

@ -3,7 +3,7 @@ You are the Virtual Concierge for an EventBuzz event (nearby places and stays).
### GUARDRAILS: ### GUARDRAILS:
1. Use only DATA. Never invent attractions, hotels, addresses, ratings, or prices. 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. 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). 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. 5. Prefer at most 6 ids. Answer naturally in "message"; cards show details.

View File

@ -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(),
};

View File

@ -3,6 +3,7 @@ const lost_and_found = require('./lost_and_found');
const human_agent_escalation = require('./human_agent_escalation'); const human_agent_escalation = require('./human_agent_escalation');
const chitchat_greeting = require('./chitchat_greeting'); const chitchat_greeting = require('./chitchat_greeting');
const out_of_scope = require('./out_of_scope'); const out_of_scope = require('./out_of_scope');
const event_adjacent = require('./event_adjacent');
const { CALL2_SYSTEM_MAIN_PROMPT } = require('./main'); const { CALL2_SYSTEM_MAIN_PROMPT } = require('./main');
const { CALL2_CONVERSATION_PROMPT } = require('../conversation'); const { CALL2_CONVERSATION_PROMPT } = require('../conversation');
@ -12,6 +13,7 @@ const MODULES = [
human_agent_escalation, human_agent_escalation,
chitchat_greeting, chitchat_greeting,
out_of_scope, out_of_scope,
event_adjacent,
]; ];
const PROMPTLETS = Object.fromEntries(MODULES.map((m) => [m.intent, m.promptlet])); const PROMPTLETS = Object.fromEntries(MODULES.map((m) => [m.intent, m.promptlet]));

View File

@ -1,14 +1,15 @@
const CALL2_SYSTEM_MAIN_PROMPT = ` 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: ### GUARDRAILS:
1. Use only DATA. Never invent that organizers were emailed unless DATA.emailSent is true. 1. Use only DATA unless the event_adjacent task explicitly allows world knowledge for DATA.city.
2. NEVER give medical advice. For emergencies, urge immediate on-site security/medical staff first. 2. Never invent that organizers were emailed unless DATA.emailSent is true.
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. 3. NEVER give medical advice. For emergencies, urge immediate on-site security/medical staff first.
4. Do NOT invent other phone numbers, emails, or desk hours beyond DATA. 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. If DATA.contact is present, do NOT say you have no organizer contact information. 5. Do NOT invent other phone numbers, emails, or desk hours beyond DATA.
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. 6. If DATA.contact is present, do NOT say you have no organizer contact information.
7. Output ONLY the JSON key message — no other keys. 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): ### REQUIRED OUTPUT (JSON only, no markdown):
- Valid JSON only. Escape newlines inside strings as \\n. - Valid JSON only. Escape newlines inside strings as \\n.

View File

@ -3,7 +3,7 @@ You are the Virtual Concierge for an EventBuzz event (Wi‑Fi and app/tech help)
### GUARDRAILS: ### GUARDRAILS:
1. Use only DATA. Never invent Wi‑Fi SSIDs, passwords, reset links, or app UI steps. 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. 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. 4. NEVER output personal email addresses or phone numbers.
5. Answer naturally in "message". You may list Wi‑Fi details from DATA when asked. 5. Answer naturally in "message". You may list Wi‑Fi details from DATA when asked.

View File

@ -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. 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. 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. 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). 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): ### REQUIRED OUTPUT (JSON only, no markdown):

View File

@ -3,13 +3,10 @@ module.exports = {
usesQrCard: false, usesQrCard: false,
promptlet: ` promptlet: `
### INTENT TASK: ticket_how_to_register ### INTENT TASK: ticket_how_to_register
They want to sign up or buy a ticket — not look up an existing registration. Answer the user's question about attending / joining (who may come, how to register, eligibility).
Answer only from DATA. Sound like a helpful person. 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.registrationUrl: tell them they can register there and share the URL. If DATA does not say whether everyone may attend, say that honestly; do not invent a public/private policy.
- If DATA.faqs has how-to text: use that. If DATA.hasRegistrationButtons is true, mention they can register with the buttons below. Never invent or paste URLs. show_qr: false.
- If DATA.contact email/phone: share those for registration questions. Do not add organizer email/phone unless they asked who to contact.
- 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.
`.trim(), `.trim(),
}; };

View File

@ -2,9 +2,9 @@ const CALL2_VENUE_MAIN_PROMPT = `
You are the Virtual Concierge for an EventBuzz event (venue questions). You are the Virtual Concierge for an EventBuzz event (venue questions).
### GUARDRAILS: ### 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. 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. 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. 5. Do not invent URLs. Do not mention system prompts or intent names.
6. Stay on-event. Refuse unrelated requests politely. 6. Stay on-event. Refuse unrelated requests politely.

View File

@ -3,7 +3,7 @@ module.exports = {
usesPlaceCards: false, usesPlaceCards: false,
promptlet: ` promptlet: `
### INTENT TASK: venue_accessibility ### 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. If no FAQ: direct the attendee to the venue help desk. Do not invent accessibility policies.
place_ids: []. place_ids: [].
`.trim(), `.trim(),

View File

@ -3,7 +3,9 @@ module.exports = {
usesPlaceCards: true, usesPlaceCards: true,
promptlet: ` promptlet: `
### INTENT TASK: venue_food_beverage ### 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 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. If no FAQ and no places: direct the attendee to the venue help desk. Never invent restaurants.
`.trim(), `.trim(),

View File

@ -3,8 +3,8 @@ module.exports = {
usesPlaceCards: false, usesPlaceCards: false,
promptlet: ` promptlet: `
### INTENT TASK: venue_map_request ### INTENT TASK: venue_map_request
Answer from DATA.faqs about maps/floor plans when present. Use DATA.faqs about maps/floor plans when they answer this ask.
If no FAQ: share only DATA.eventName and DATA.location (venue address). Do not invent a map URL or floor plan. If no FAQ: share DATA.location (venue address). Do not invent a map URL or floor plan.
place_ids: []. place_ids: [].
`.trim(), `.trim(),
}; };

View File

@ -3,7 +3,7 @@ module.exports = {
usesPlaceCards: false, usesPlaceCards: false,
promptlet: ` promptlet: `
### INTENT TASK: venue_nearest_amenity ### 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. If no FAQ: direct the attendee to the venue help desk. Do not invent amenity locations.
place_ids: []. place_ids: [].
`.trim(), `.trim(),

View File

@ -3,7 +3,8 @@ module.exports = {
usesPlaceCards: false, usesPlaceCards: false,
promptlet: ` promptlet: `
### INTENT TASK: venue_travel_directions ### 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). Organizers cannot document every origin city — use general geographic knowledge for long-distance corridors (train/flight/road between cities).
Routing rules: Routing rules:

View File

@ -3,7 +3,7 @@ module.exports = {
usesPlaceCards: false, usesPlaceCards: false,
promptlet: ` promptlet: `
### INTENT TASK: venue_wayfinding ### 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. 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: []. place_ids: [].
`.trim(), `.trim(),

View File

@ -1,301 +1,69 @@
/** /**
* Call 1 — intent classification + entity extraction. * Call 1 — domain + action + entity extraction.
* INTENT_DESCRIPTIONS is the single source of truth; the system prompt is built from it.
*/ */
const INTENT_DESCRIPTIONS = { const { DOMAINS, ACTIONS } = require('../utils/domainAction');
// 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.',
// Venue const DOMAIN_DESCRIPTIONS = {
venue_map_request: 'Venue map, floor plan, or overall layout. Not session schedules.', agenda: 'This event’s programme: sessions, tracks, times, personal saved agenda, materials.',
venue_wayfinding: 'Directions inside the venue to a room, hall, booth, or stage ("where is Hall A", "how do I find registration").', speaker: 'People on the programme: bios, lineup, meet-and-greet, public socials. When they speak → agenda (set speaker_name).',
venue_nearest_amenity: 'Nearest restroom, water, charging, info desk, or similar amenity.', venue: 'The event site: map, halls, amenities, parking/shuttle, meals *policy*, accessibility, and reaching the venue from any origin city. Not city sightseeing.',
venue_parking_transport: local: 'Around the event city: hotels/stay policy, cached landmarks. City world-knowledge (a dish, a day plan) uses action adjacent, not read.',
'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".', ticket: 'Attending this event: who may come, how to join/register, eligibility, ticket status, QR, receipt. Emailing QR is action email_qr.',
venue_travel_directions: exhibitor: 'Booths, exhibitor list, category, demos, meeting requests.',
'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.', tech: 'Wi-Fi, streaming/AV, app login, how to use app features.',
venue_food_beverage: 'Food provided at the event, tea/coffee breaks, dietary options, cafes/restaurants near the venue.', system: 'Greeting, lost & found, true refuse (oos), emergency, organizer/human contact.',
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 INTENT_DOMAINS = [ const ACTION_DESCRIPTIONS = {
{ read: 'Default. Fetch facts and answer. Use for almost every in-scope question.',
name: 'Agenda Domain', bookmark_add: 'They asked to save/add a session. domain agenda.',
intents: [ bookmark_remove: 'They asked to remove a saved session. domain agenda.',
'agenda_current_now', email_qr: 'They asked to email their QR/ticket. domain ticket.',
'agenda_upcoming_next', emergency: 'Medical/safety emergency. domain system.',
'agenda_by_track', escalate_human: 'Talk to organizer / who to contact. domain system.',
'agenda_by_speaker', adjacent:
'agenda_session_details', '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.',
'agenda_personal_schedule', 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.',
'agenda_bookmark_add', greeting: 'Hello / thanks / goodbye with no event question. domain system.',
'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 ALLOWED_INTENTS = INTENT_DOMAINS.flatMap((d) => d.intents); function formatList(ids, descriptions) {
return ids.map((id) => ` - "${id}": ${descriptions[id] || ''}`).join('\n');
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');
} }
const STEP_1_SYSTEM_PROMPT = ` const STEP_1_SYSTEM_PROMPT = `
You are the Intent Classification and Entity Extraction Engine for eventbuzz.live Virtual Concierge. You are the classification engine for EventBuzz Virtual Concierge.
Your sole job is to analyze the incoming user message and short dialogue history, then produce a strictly formatted JSON object. Output JSON only — no markdown, no extra keys.
DO NOT output any conversational text, markdown wrapping, or explanations outside the JSON object.
### HARD CONSTRAINT — ALLOWED INTENTS ONLY: ### HARD CONSTRAINTS
- "primary_intent" MUST be copied EXACTLY from the ALLOWED INTENTS list below (same spelling, same underscores). - "domain" MUST be one of: ${DOMAINS.map((d) => `"${d}"`).join(', ')}
- The ALLOWED INTENTS list is a CLOSED set. You MUST NOT invent, rename, combine, or paraphrase intent ids. - "action" MUST be one of: ${ACTIONS.map((a) => `"${a}"`).join(', ')}
- 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. - Do not invent domain or action names. Do not output primary_intent.
- 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.
### INSTRUCTIONS: ### DOMAINS
1. Classify into exactly ONE primary_intent from ALLOWED INTENTS. Use each intent's description to disambiguate. ${formatList(DOMAINS, DOMAIN_DESCRIPTIONS)}
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.
### ALLOWED INTENTS (closed list — primary_intent must be one of these exact strings): ### ACTIONS
${buildAllowedIntentsSection()} ${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", "domain": "ticket",
"confidence": 0.98, "action": "read",
"action_category": "read", "confidence": 0.9,
"entities": { "entities": {
"speaker_name": null, "speaker_name": null,
"session_name": null, "session_name": null,
@ -303,131 +71,57 @@ ${buildAllowedIntentsSection()}
"location": null, "location": null,
"company_name": null, "company_name": null,
"time_reference": null, "time_reference": null,
"topic": "asthma" "topic": null,
"dish": null
}, },
"context_resolution": { "context_resolution": {
"is_followup": false, "is_followup": false,
"resolved_from_history": false "resolved_from_history": false
}, }
"requires_data_fetch": true
} }
`.trim(); `.trim();
/** Model sometimes invents near-miss intent ids — remap before rejecting. */ const DOMAIN_ALIASES = {
const INTENT_ALIASES = { agenda_domain: 'agenda',
agenda_by_topic: 'agenda_session_details', speakers: 'speaker',
agenda_topic: 'agenda_session_details', venue_domain: 'venue',
agenda_by_interest: 'agenda_session_details', places: 'local',
agenda_recommend: 'agenda_session_details', tickets: 'ticket',
agenda_recommendations: 'agenda_session_details', exhibitors: 'exhibitor',
agenda_suggestion: 'agenda_session_details', wifi: 'tech',
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',
}; };
function resolveIntent(rawIntent) { const ACTION_ALIASES = {
const primary = String(rawIntent || '').trim(); lookup: 'read',
if (!primary) return ''; query: 'read',
if (isAllowedIntent(primary)) return primary; itinerary: 'adjacent',
const aliased = INTENT_ALIASES[primary]; local_tips: 'adjacent',
return aliased && isAllowedIntent(aliased) ? aliased : ''; 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 = { module.exports = {
ALLOWED_INTENTS,
INTENT_DESCRIPTIONS,
INTENT_ALIASES,
STEP_1_SYSTEM_PROMPT, STEP_1_SYSTEM_PROMPT,
isAllowedIntent, DOMAINS,
resolveIntent, ACTIONS,
resolveDomain,
resolveAction,
}; };

View File

@ -22,9 +22,13 @@ function accommodationFaqsFromBot(bot) {
.map((f) => { .map((f) => {
const q = String(f?.question || '').trim(); const q = String(f?.question || '').trim();
const a = faqAnswerText(f).trim(); const a = faqAnswerText(f).trim();
const cat = String(f?.category || '').trim();
const key = String(f?.key || cat || 'accommodation').trim() || 'accommodation';
return { return {
q: q.length > 200 ? `${q.slice(0, 200)}…` : q, q: q.length > 200 ? `${q.slice(0, 200)}…` : q,
a: a.length > FAQ_ANSWER_MAX ? `${a.slice(0, FAQ_ANSWER_MAX)}…` : a, a: a.length > FAQ_ANSWER_MAX ? `${a.slice(0, FAQ_ANSWER_MAX)}…` : a,
key,
category: cat || 'accommodation',
}; };
}) })
.filter((f) => f.a); .filter((f) => f.a);

View File

@ -10,21 +10,19 @@ const {
generateLocalReply, generateLocalReply,
} = require('./step2.service'); } = require('./step2.service');
const { const {
fetchAgendaData,
isAgendaIntent, isAgendaIntent,
isAuthRequiredAgendaIntent, isAuthRequiredAgendaIntent,
} = require('./agendaData.service'); } = require('./agendaData.service');
const { fetchVenueData, isVenueIntent } = require('./venueData.service'); const { isVenueIntent } = require('./venueData.service');
const { const {
fetchTicketData,
isTicketIntent, isTicketIntent,
isAuthRequiredTicketIntent, isAuthRequiredTicketIntent,
} = require('./ticketData.service'); } = require('./ticketData.service');
const { fetchSpeakerData, isSpeakerIntent } = require('./speakerData.service'); const { isSpeakerIntent } = require('./speakerData.service');
const { fetchExhibitorData, isExhibitorIntent } = require('./exhibitorData.service'); const { isExhibitorIntent } = require('./exhibitorData.service');
const { fetchTechData, isTechIntent } = require('./techData.service'); const { isTechIntent } = require('./techData.service');
const { fetchSystemData, isSystemIntent } = require('./systemData.service'); const { isSystemIntent } = require('./systemData.service');
const { fetchLocalData, isLocalIntent } = require('./localData.service'); const { isLocalIntent } = require('./localData.service');
const { const {
addBookmark, addBookmark,
removeBookmark, removeBookmark,
@ -39,6 +37,9 @@ const {
} = require('./identity.service'); } = require('./identity.service');
const { sendTicketPassEmail } = require('../utils/mail'); const { sendTicketPassEmail } = require('../utils/mail');
const { withOrganizerContact } = require('../utils/organizerContact'); 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; 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 }) { async function generateForDomain({ intent, action, message, history, entities, data }) {
const intent = classification.primary_intent; if (action === 'adjacent' || intent === 'event_adjacent') {
const data = await fetchVenueData({ return generateSystemReply({
intent, intent: 'event_adjacent',
eventId: bot.event, message,
bot, history,
entities: classification.entities || {}, entities,
message, data,
}); });
}
const step2 = await generateVenueReply({ if (isAgendaIntent(intent)) {
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, message,
history, history,
entities: classification.entities || {}, entities,
data, 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 intent = classification.primary_intent;
const data = await fetchLocalData({ const action = classification.action;
intent, let data = await retrieveForTurn({
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,
bot, bot,
event, event,
session, session,
classification,
message, 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, intent,
action,
message, message,
history, history,
entities: classification.entities || {}, entities: classification.entities || {},
@ -338,13 +183,40 @@ async function runSystemCall2({ bot, event, session, message, history, classific
return { return {
reply: step2.reply, reply: step2.reply,
cards: [], cards: Array.isArray(step2.cards) ? step2.cards : [],
sessionIds: [], 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, quickReplies: Array.isArray(step2.quickReplies) ? step2.quickReplies : null,
classification: publicClassification(classification), classification: publicClassification(classification),
intent, intent,
modelId: step2.modelId, modelId: step2.modelId,
usage: step2.usage, 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, intent,
modelId: null, modelId: null,
usage: { inputTokens: 0, outputTokens: 0 }, 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, intent: null,
modelId: null, modelId: null,
usage: { inputTokens: 0, outputTokens: 0 }, usage: { inputTokens: 0, outputTokens: 0 },
faqKeysUsed: [],
}; };
} }
// New question while verifying — drop auth and answer it below. // 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, intent: session.pendingIntent || null,
modelId: null, modelId: null,
usage: { inputTokens: 0, outputTokens: 0 }, usage: { inputTokens: 0, outputTokens: 0 },
faqKeysUsed: [],
}; };
} else { } else {
@ -436,50 +324,15 @@ async function handleChatTurnCore({ bot, event, session, message }) {
clearPending(session); clearPending(session);
if (pendingIntent && isBookmarkMutationIntent(pendingIntent)) { if (pendingIntent && isBookmarkMutationIntent(pendingIntent)) {
const classification = { const classification = classificationFromPending(pendingIntent, pendingEntities);
primary_intent: pendingIntent,
confidence: 1,
action_category: 'mutation',
entities: pendingEntities,
context_resolution: { is_followup: true, resolved_from_history: true },
requires_data_fetch: false,
};
const result = await runBookmarkMutation({ bot, session, classification }); const result = await runBookmarkMutation({ bot, session, classification });
result.reply = `You're verified. ${result.reply}`; result.reply = `You're verified. ${result.reply}`;
return result; return result;
} }
if (pendingIntent && isAuthRequiredTicketIntent(pendingIntent)) { if (pendingIntent && (isAuthRequiredTicketIntent(pendingIntent) || isAgendaIntent(pendingIntent))) {
const classification = { const classification = classificationFromPending(pendingIntent, pendingEntities);
primary_intent: pendingIntent, const result = await completeReadTurn({
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({
bot, bot,
event, event,
session, session,
@ -499,6 +352,7 @@ async function handleChatTurnCore({ bot, event, session, message }) {
intent: pendingIntent, intent: pendingIntent,
modelId: null, modelId: null,
usage: { inputTokens: 0, outputTokens: 0 }, usage: { inputTokens: 0, outputTokens: 0 },
faqKeysUsed: [],
}; };
} }
} }
@ -507,8 +361,14 @@ async function handleChatTurnCore({ bot, event, session, message }) {
const step1 = await classifyIntent({ message, history }); const step1 = await classifyIntent({ message, history });
const classification = step1.classification; const classification = step1.classification;
const intent = classification.primary_intent; 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 ( if (
(isAuthRequiredAgendaIntent(intent) || isAuthRequiredTicketIntent(intent)) (isAuthRequiredAgendaIntent(intent) || isAuthRequiredTicketIntent(intent))
&& !isVerified(session) && !isVerified(session)
@ -527,11 +387,12 @@ async function handleChatTurnCore({ bot, event, session, message }) {
intent, intent,
modelId: step1.modelId, modelId: step1.modelId,
usage: step1.usage, usage: step1.usage,
faqKeysUsed: [],
}; };
} }
// 4) Bookmark mutations — write to SessionBookmark (no Call 2) // 5) Bookmark mutations — write to SessionBookmark (no Call 2)
if (isBookmarkMutationIntent(intent)) { if (isBookmarkMutationIntent(intent) || isBookmarkAction(action)) {
const result = await runBookmarkMutation({ bot, session, classification }); const result = await runBookmarkMutation({ bot, session, classification });
return { return {
...result, ...result,
@ -540,153 +401,19 @@ async function handleChatTurnCore({ bot, event, session, message }) {
}; };
} }
// 5) Agenda Call 2 // 6) Retrieve this-turn DATA, then Call 2 by domain (email QR / emergency stay code-backed)
if (isAgendaIntent(intent)) { const result = await completeReadTurn({
const result = await runAgendaCall2({ bot,
bot, event,
event, session,
session, message,
message, history,
history, classification,
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
return { return {
reply: ...result,
`I understood your request (${intent.replace(/_/g, ' ')}), but that topic isn't available in this concierge yet. ` usage: sumUsage(step1.usage, result.usage),
+ 'I can help with the agenda, speakers, exhibitors, venue, Wi‑Fi / app help, or your ticket (QR, status, receipt).', modelId: result.modelId || step1.modelId,
cards: [],
sessionIds: [],
classification: publicClassification(classification),
intent,
modelId: step1.modelId,
usage: step1.usage,
}; };
} }

View File

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

View File

@ -1,7 +1,4 @@
const { const { STEP_1_SYSTEM_PROMPT, resolveDomain, resolveAction } = require('../prompts/step1');
STEP_1_SYSTEM_PROMPT,
resolveIntent,
} = require('../prompts/step1');
const { const {
createClient, createClient,
converse, converse,
@ -12,19 +9,20 @@ const {
const { looksLikeTouristPlacesQuestion } = require('../utils/placeCategoryFilter'); const { looksLikeTouristPlacesQuestion } = require('../utils/placeCategoryFilter');
const { looksLikeWhoToContact, looksLikeHotelCheckIn } = require('../utils/organizerContact'); const { looksLikeWhoToContact, looksLikeHotelCheckIn } = require('../utils/organizerContact');
const { stripNameSalutations } = require('../utils/nameSalutations'); 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_ACTIONS = new Set(['oos', 'greeting', 'read']);
const REGISTER_RESCUE_FROM = new Set([
'out_of_scope',
'chitchat_greeting',
'ticket_status_lookup',
'venue_wayfinding',
]);
/**
* "How can I register / buy a ticket" — not desk wayfinding and not existing-status lookup.
* @param {string} message
*/
function looksLikeHowToRegisterQuestion(message) { function looksLikeHowToRegisterQuestion(message) {
const t = String(message || '') const t = String(message || '')
.toLowerCase() .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(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) || /\b(buy|purchase) (a |an )?(ticket|pass|registration)\b/.test(t)
|| /\bregistration (link|form|page|url|website|online)\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() { function emptyEntities() {
return { return {
speaker_name: null, speaker_name: null,
@ -66,11 +64,14 @@ function emptyEntities() {
company_name: null, company_name: null,
time_reference: null, time_reference: null,
topic: null, topic: null,
dish: null,
}; };
} }
function fallbackClassification(reason) { function fallbackClassification(reason) {
return { return {
domain: 'system',
action: 'oos',
primary_intent: 'out_of_scope', primary_intent: 'out_of_scope',
confidence: 0, confidence: 0,
action_category: 'direct_reply', action_category: 'direct_reply',
@ -106,59 +107,88 @@ function normalizeEntities(raw) {
base[key] = null; base[key] = null;
} else { } else {
const value = typeof raw[key] === 'string' ? raw[key].trim() : raw[key]; const value = typeof raw[key] === 'string' ? raw[key].trim() : raw[key];
base[key] = key === 'speaker_name' && typeof value === 'string' if (key === 'speaker_name' && typeof value === 'string') {
? stripNameSalutations(value) || null base[key] = stripNameSalutations(value) || null;
: value; } else {
base[key] = value;
}
} }
} }
return base; 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') { if (!parsed || typeof parsed !== 'object') {
return fallbackClassification('invalid_json_object'); return fallbackClassification('invalid_json_object');
} }
const rawPrimary = String(parsed.primary_intent || '').trim(); let domain = resolveDomain(parsed.domain);
const primary = resolveIntent(rawPrimary); let action = resolveAction(parsed.action);
if (!primary) {
return fallbackClassification(`unknown_intent:${rawPrimary || '(empty)'}`); // 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); let confidence = Number(parsed.confidence);
if (!Number.isFinite(confidence)) confidence = 0; if (!Number.isFinite(confidence)) confidence = 0;
confidence = Math.max(0, Math.min(1, confidence)); confidence = Math.max(0, Math.min(1, confidence));
let action = String(parsed.action_category || '').trim(); const ctx =
if (primary === 'agenda_bookmark_add' || primary === 'agenda_bookmark_remove') { parsed.context_resolution && typeof parsed.context_resolution === 'object'
action = 'mutation'; ? parsed.context_resolution
} 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 entities = normalizeEntities(parsed.entities); 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 { return attachFetchIntent(
primary_intent: primary, {
confidence, domain,
action_category: action, action,
entities, confidence,
context_resolution: { entities,
is_followup: Boolean(ctx.is_followup), context_resolution: {
resolved_from_history: Boolean(ctx.resolved_from_history), is_followup: Boolean(ctx.is_followup),
resolved_from_history: Boolean(ctx.resolved_from_history),
},
}, },
requires_data_fetch: requiresData, message,
...(rawPrimary !== primary ? { _aliased_from: rawPrimary } : {}), );
};
} }
function formatHistory(history) { function formatHistory(history) {
@ -172,11 +202,135 @@ function formatHistory(history) {
.join('\n'); .join('\n');
} }
/** function applyRescues(classification, message) {
* Run Call 1 intent classification. let next = { ...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 }>} // 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 }) { async function classifyIntent({ message, history }) {
const modelId = getRouterModelId(); const modelId = getRouterModelId();
const client = createClient(getRegion()); const client = createClient(getRegion());
@ -209,59 +363,10 @@ async function classifyIntent({ message, history }) {
} }
let classification = parsed let classification = parsed
? normalizeClassification(parsed) ? normalizeClassification(parsed, message)
: fallbackClassification('json_parse_failed'); : fallbackClassification('json_parse_failed');
// Local landmark questions (church/temple/fort/beach/…) must not fall through as trivia. classification = applyRescues(classification, message);
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',
};
}
console.log('[step1][call1][classification]', { console.log('[step1][call1][classification]', {
modelId, modelId,
@ -294,11 +399,11 @@ async function classifyIntent({ message, history }) {
} }
function formatClassificationReply(classification) { function formatClassificationReply(classification) {
const intent = classification?.primary_intent || 'out_of_scope'; const domain = classification?.domain || 'system';
const action = classification?.action_category || 'direct_reply'; const action = classification?.action || 'oos';
const conf = Number(classification?.confidence); const conf = Number(classification?.confidence);
const confLabel = Number.isFinite(conf) ? conf.toFixed(2) : '0.00'; const confLabel = Number.isFinite(conf) ? conf.toFixed(2) : '0.00';
return `Intent: ${intent} · ${action} · ${confLabel}`; return `Domain: ${domain} · ${action} · ${confLabel}`;
} }
module.exports = { module.exports = {
@ -306,4 +411,5 @@ module.exports = {
formatClassificationReply, formatClassificationReply,
normalizeClassification, normalizeClassification,
fallbackClassification, fallbackClassification,
applyRescues,
}; };

View File

@ -22,6 +22,7 @@ const {
converse, converse,
responseText, responseText,
getRegion, getRegion,
getCall2StrongModelId,
} = require('../utils/bedrock'); } = require('../utils/bedrock');
const { buildSessionCards, resolveSessionIds } = require('../utils/sessionCards'); const { buildSessionCards, resolveSessionIds } = require('../utils/sessionCards');
const { buildPlaceCards, resolvePlaceIds } = require('../utils/placeCards'); const { buildPlaceCards, resolvePlaceIds } = require('../utils/placeCards');
@ -94,6 +95,8 @@ function formatStaticTipsReply(intro, tips) {
} }
function getCall2ModelId() { 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; return process.env.BEDROCK_CALL2_MODEL_ID || DEFAULT_CALL2_MODEL_ID;
} }
@ -654,36 +657,17 @@ function contactReachLine(data) {
function ticketHowToRegisterFallback(data) { function ticketHowToRegisterFallback(data) {
const links = Array.isArray(data?.registrationLinks) ? data.registrationLinks : []; 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) { if (links.length > 1) {
return reach return 'You can join using the registration options below.';
? `Here are the registration options — tap a button to open one. Questions? ${reach}.`
: 'Here are the registration options — tap a button to open one.';
} }
if (links.length === 1) { if (links.length === 1) {
return reach return 'You can join using the registration button below.';
? `Tap below to register. Questions? ${reach}.`
: 'Tap below to register.';
} }
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; 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) { function sanitizeOutOfScopeReply(reply, eventName) {
let text = String(reply || '').trim(); let text = String(reply || '').trim();
text = text.replace( text = text.replace(
@ -706,16 +690,14 @@ function sanitizeOutOfScopeReply(reply, eventName) {
* @param {{ intent: string, message: string, history?: object[], entities?: object, data?: object }} opts * @param {{ intent: string, message: string, history?: object[], entities?: object, data?: object }} opts
*/ */
async function generateTicketReply({ intent, message, history, entities, data }) { async function generateTicketReply({ intent, message, history, entities, data }) {
// Real event links only — never let the model invent a registration URL. const registerMode = intent === 'ticket_how_to_register';
if (intent === 'ticket_how_to_register') { const registerCards = registerMode ? buildRegistrationCtaCards(data?.registrationLinks) : [];
return ticketHowToRegisterResult(data);
}
const system = buildTicketCall2SystemPrompt(intent); const system = buildTicketCall2SystemPrompt(intent);
if (!system) { if (!system) {
return { return {
reply: TICKET_FALLBACK_REPLY, reply: registerMode ? ticketHowToRegisterFallback(data) : TICKET_FALLBACK_REPLY,
cards: [], cards: registerCards,
modelId: null, modelId: null,
usage: { inputTokens: 0, outputTokens: 0 }, 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}.`; reply = `${data.attendee?.name || 'Your registration'} is ${status}${ticket}.`;
} else if (intent === 'ticket_qr_display' && qrMode) { } else if (intent === 'ticket_qr_display' && qrMode) {
reply = 'Here is your check-in QR code.'; reply = 'Here is your check-in QR code.';
} else if (intent === 'ticket_how_to_register') { } else if (registerMode) {
reply = ticketHowToRegisterFallback(data); reply = ticketHowToRegisterFallback(data);
} else { } else {
reply = String(data?.note || '').trim() || TICKET_FALLBACK_REPLY; reply = String(data?.note || '').trim() || TICKET_FALLBACK_REPLY;
@ -830,13 +812,22 @@ async function generateTicketReply({ intent, message, history, entities, data })
} }
if ( if (
intent === 'ticket_how_to_register' registerMode
&& (/i can only (assist|help) with/i.test(reply) || /i('m| am) afraid/i.test(reply)) && (/i can only (assist|help) with/i.test(reply) || /i('m| am) afraid/i.test(reply))
) { ) {
reply = ticketHowToRegisterFallback(data); 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]', { console.log('[step2][ticket][parsed]', {
intent, intent,
@ -856,9 +847,13 @@ async function generateTicketReply({ intent, message, history, entities, data })
}; };
} catch (err) { } catch (err) {
console.warn('[step2] Ticket Call 2 failed:', err?.message || 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; let reply = TICKET_FALLBACK_REPLY;
if (intent === 'ticket_how_to_register') { if (registerMode) {
reply = ticketHowToRegisterFallback(data); reply = ticketHowToRegisterFallback(data);
} else if (cards.length) { } else if (cards.length) {
reply = 'Here is your check-in QR code.'; reply = 'Here is your check-in QR code.';
@ -1453,6 +1448,16 @@ function systemDeterministicFallback(intent, data) {
if (intent === 'out_of_scope') { if (intent === 'out_of_scope') {
return 'I can only help with this event — try the agenda, venue, or your ticket.'; 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; return SYSTEM_FALLBACK_REPLY;
} }
@ -1471,11 +1476,14 @@ function ensureEmergencyLead(reply) {
async function generateSystemReply({ intent, message, history, entities, data }) { async function generateSystemReply({ intent, message, history, entities, data }) {
const system = buildSystemCall2SystemPrompt(intent); const system = buildSystemCall2SystemPrompt(intent);
const greetingChips = intent === 'chitchat_greeting' ? DEFAULT_QUICK_REPLIES : null; 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) { if (!system) {
return { return {
reply: systemDeterministicFallback(intent, data), reply: systemDeterministicFallback(intent, data),
cards: [], cards: [],
placeIds: [],
quickReplies: greetingChips, quickReplies: greetingChips,
modelId: null, modelId: null,
usage: { inputTokens: 0, outputTokens: 0 }, usage: { inputTokens: 0, outputTokens: 0 },
@ -1498,16 +1506,21 @@ async function generateSystemReply({ intent, message, history, entities, data })
'DATA:', 'DATA:',
JSON.stringify(slimData), 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'); const userText = userParts.join('\n');
console.log('[step2][system][context]', { console.log('[step2][system][context]', {
intent, intent,
adjacent,
strongModel: adjacent,
emailSent: Boolean(data?.emailSent), emailSent: Boolean(data?.emailSent),
rateLimited: Boolean(data?.rateLimited), rateLimited: Boolean(data?.rateLimited),
hasContact: Boolean(data?.contact?.email || data?.contact?.phone || data?.contact?.text), hasContact: Boolean(data?.contact?.email || data?.contact?.phone || data?.contact?.text),
faqCount: Array.isArray(data?.faqs) ? data.faqs.length : 0, faqCount: Array.isArray(data?.faqs) ? data.faqs.length : 0,
placesIn: Array.isArray(data?.places) ? data.places.length : 0,
contextChars: userText.length, contextChars: userText.length,
}); });
@ -1516,7 +1529,7 @@ async function generateSystemReply({ intent, message, history, entities, data })
system, system,
messages: [{ role: 'user', content: [{ text: userText }] }], messages: [{ role: 'user', content: [{ text: userText }] }],
inferenceConfig: CALL2_INFERENCE, inferenceConfig: CALL2_INFERENCE,
logLabel: 'call2-system', logLabel: adjacent ? 'call2-adjacent' : 'call2-system',
}); });
const raw = responseText(response); 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 === 'out_of_scope') reply = sanitizeOutOfScopeReply(reply, data?.eventName);
if (intent === 'emergency_medical') reply = ensureEmergencyLead(reply); 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 // Never let the model invent a successful organizer email
if ( if (
data?.emailSent !== true data?.emailSent !== true
@ -1552,12 +1569,15 @@ async function generateSystemReply({ intent, message, history, entities, data })
console.log('[step2][system][parsed]', { console.log('[step2][system][parsed]', {
intent, intent,
messagePreview: reply.slice(0, 160), messagePreview: reply.slice(0, 160),
placeIds,
cardCount: cards.length,
quickReplies: greetingChips ? greetingChips.length : 0, quickReplies: greetingChips ? greetingChips.length : 0,
}); });
return { return {
reply: reply.slice(0, 1800), reply: reply.slice(0, adjacent ? 2200 : 1800),
cards: [], cards,
placeIds,
quickReplies: greetingChips, quickReplies: greetingChips,
modelId, modelId,
usage: { usage: {
@ -1572,6 +1592,7 @@ async function generateSystemReply({ intent, message, history, entities, data })
return { return {
reply, reply,
cards: [], cards: [],
placeIds: [],
quickReplies: greetingChips, quickReplies: greetingChips,
modelId, modelId,
usage: { inputTokens: 0, outputTokens: 0 }, usage: { inputTokens: 0, outputTokens: 0 },
@ -1740,6 +1761,7 @@ module.exports = {
generateSystemReply, generateSystemReply,
generateLocalReply, generateLocalReply,
getCall2ModelId, getCall2ModelId,
getCall2StrongModelId,
DEFAULT_CALL2_MODEL_ID, DEFAULT_CALL2_MODEL_ID,
FALLBACK_REPLY, FALLBACK_REPLY,
VENUE_FALLBACK_REPLY, VENUE_FALLBACK_REPLY,

View File

@ -9,6 +9,7 @@ const SYSTEM_INTENTS = new Set([
'human_agent_escalation', 'human_agent_escalation',
'chitchat_greeting', 'chitchat_greeting',
'out_of_scope', 'out_of_scope',
'event_adjacent',
]); ]);
const ESCALATION_INTENTS = new Set([ 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 HELPDESK = 'Please ask at the venue help desk or contact event staff on site.';
const ANSWER_MAX = 600; 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) { function isSystemIntent(intent) {
return SYSTEM_INTENTS.has(String(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') { } else if (intentKey === 'out_of_scope') {
base.note = 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.'; '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; return base;
} }

View File

@ -51,6 +51,8 @@ function answeredFaqsForIntent(bot, intent) {
.map((f) => ({ .map((f) => ({
q: trimText(f.question, 200), q: trimText(f.question, 200),
a: trimText(faqAnswerText(f), ANSWER_MAX), 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); .filter((f) => f.q && f.a);
} }

View File

@ -211,9 +211,12 @@ function registrationFaqsFromBot(bot) {
.map((f) => { .map((f) => {
const q = String(f?.question || '').trim(); const q = String(f?.question || '').trim();
const a = faqAnswerText(f).trim(); const a = faqAnswerText(f).trim();
const cat = String(f?.category || '').trim();
return { return {
q: q.length > 200 ? `${q.slice(0, 200)}…` : q, q: q.length > 200 ? `${q.slice(0, 200)}…` : q,
a: a.length > FAQ_ANSWER_MAX ? `${a.slice(0, FAQ_ANSWER_MAX)}…` : a, 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); .filter((f) => f.a);

View File

@ -5,6 +5,7 @@ const Session = require('../models/Session');
const { faqAnswerText, mapMediaFromBot } = require('../utils/faqAnswer'); const { faqAnswerText, mapMediaFromBot } = require('../utils/faqAnswer');
const { transportModesFromBot } = require('../utils/transportModes'); const { transportModesFromBot } = require('../utils/transportModes');
const { looksLikeFrontDesk } = require('../utils/organizerContact'); const { looksLikeFrontDesk } = require('../utils/organizerContact');
const { extractTravelOrigin } = require('../utils/domainAction');
const VENUE_INTENTS = new Set([ const VENUE_INTENTS = new Set([
'venue_map_request', 'venue_map_request',
@ -58,6 +59,8 @@ function answeredFaqsForCategories(bot, categories) {
.map((f) => ({ .map((f) => ({
q: trimText(f.question, 200), q: trimText(f.question, 200),
a: trimText(faqAnswerText(f), ANSWER_MAX), 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); .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 hasFaqs = faqs.length > 0 || Boolean(mapMedia?.url);
const hasTransportModes = Boolean(transportModes?.enforce); const hasTransportModes = Boolean(transportModes?.enforce);
const origin = const origin =
String(entities?.location || entities?.topic || '').trim() || null; String(entities?.location || '').trim()
|| extractTravelOrigin(message, null)
|| null;
const meta = { const meta = {
intent, intent,

View File

@ -1,6 +1,7 @@
const crypto = require('crypto'); const crypto = require('crypto');
const { handleChatTurn } = require('./orchestrator.service'); const { handleChatTurn } = require('./orchestrator.service');
const { sendConciergeResultAsWhatsApp } = require('../utils/whatsappFormat'); const { sendConciergeResultAsWhatsApp } = require('../utils/whatsappFormat');
const { mergeAlreadyTold } = require('../utils/alreadyTold');
const { getOrgWhatsAppCredentials } = require('../utils/whatsappCloud'); const { getOrgWhatsAppCredentials } = require('../utils/whatsappCloud');
const { sendTextMessage } = require('../utils/whatsappSend'); const { sendTextMessage } = require('../utils/whatsappSend');
const { logWhatsApp } = require('../utils/whatsappLog'); const { logWhatsApp } = require('../utils/whatsappLog');
@ -76,6 +77,7 @@ async function handleFollowup({ bot, event, session, messageData }) {
if (Array.isArray(result.sessionIds) && result.sessionIds.length) { if (Array.isArray(result.sessionIds) && result.sessionIds.length) {
session.lastSessionIds = result.sessionIds.map((id) => String(id)).filter(Boolean).slice(0, 8); session.lastSessionIds = result.sessionIds.map((id) => String(id)).filter(Boolean).slice(0, 8);
} }
mergeAlreadyTold(session, result.faqKeysUsed);
await session.save(); await session.save();
try { try {

View File

@ -58,7 +58,7 @@ async function processInboundMessage(jobData) {
} }
const eventId = bot.event; 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 eventName = event?.name || 'Event';
const userPhone = String(messageData.from || '').replace(/\D/g, ''); const userPhone = String(messageData.from || '').replace(/\D/g, '');
if (!userPhone) { if (!userPhone) {

View File

@ -1,6 +1,7 @@
const Participant = require('../models/Participant'); const Participant = require('../models/Participant');
const { const {
matchParticipantByPhone, matchParticipantByPhone,
matchOrgParticipantByPhone,
matchParticipantByName, matchParticipantByName,
participantFullName, participantFullName,
membershipForEvent, 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) { async function credsForBot(bot) {
return getOrgWhatsAppCredentials(String(bot.organization)); 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 }); 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'; session.receptionState = 'AWAITING_BADGE';
await session.save(); await session.save();
return false; return false;
@ -137,43 +232,57 @@ async function handleFirstInbound(bot, session, { profileName, eventId, eventNam
async function handleBadgeImage(bot, session, { mediaId, eventId, eventName }) { async function handleBadgeImage(bot, session, { mediaId, eventId, eventName }) {
const creds = await credsForBot(bot); const creds = await credsForBot(bot);
if (!creds?.accessToken) { if (creds?.accessToken) {
await sendToSession(bot, session, receptionCopy(bot).staff); const media = await downloadWhatsAppMedia(mediaId, creds.accessToken);
return; session.badgeAttempts = Number(session.badgeAttempts || 0) + 1;
}
const media = await downloadWhatsAppMedia(mediaId, creds.accessToken);
session.badgeAttempts = Number(session.badgeAttempts || 0) + 1;
if (media?.buffer) { if (media?.buffer) {
const participantId = await decodeParticipantIdFromImage(media.buffer); const participantId = await decodeParticipantIdFromImage(media.buffer);
if (participantId) { if (participantId) {
const p = await Participant.findById(participantId).lean(); const p = await Participant.findById(participantId).lean();
const mem = p && membershipForEvent(p, eventId); const mem = p && membershipForEvent(p, eventId);
if ( if (
p && p &&
!p.deletedAt && !p.deletedAt &&
String(p.tenantOrganization) === String(bot.organization) && String(p.tenantOrganization) === String(bot.organization) &&
membershipAllowed(mem) membershipAllowed(mem)
) { ) {
await linkParticipant(session, p, 'badge_qr', 1); await linkParticipant(session, p, 'badge_qr', 1);
await sendWelcomeAndDrips(bot, session, p, eventName); await sendWelcomeAndDrips(bot, session, p, eventName);
return; 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); if (allowNonRegistered(bot)) {
for (const line of lines) { try {
const nameHit = await matchParticipantByName({ const walkIn = await ensureWalkInParticipant(bot, session, {
Participant,
organizationId: bot.organization,
eventId, eventId,
waName: line, waName: session.waProfileName,
}); });
if (nameHit.kind === 'unique') { if (walkIn.kind === 'linked') {
await linkParticipant(session, nameHit.participant, 'badge_ocr', nameHit.score); await linkParticipant(session, walkIn.participant, walkIn.method, walkIn.score);
await sendWelcomeAndDrips(bot, session, nameHit.participant, eventName); await sendWelcomeAndDrips(bot, session, walkIn.participant, eventName);
return; 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; return;
} }
if (allowNonRegistered(bot)) {
const linked = await tryIdentify(bot, session, {
waName: session.waProfileName,
eventId,
eventName,
});
if (linked) return;
}
const copy = receptionCopy(bot); const copy = receptionCopy(bot);
if (session.receptionState === 'AWAITING_STAFF') { if (session.receptionState === 'AWAITING_STAFF') {
await sendToSession(bot, session, copy.staff); await sendToSession(bot, session, copy.staff);

View File

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

10
src/utils/alreadyTold.js Normal file
View File

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

View File

@ -3,7 +3,7 @@ const {
ConverseCommand, ConverseCommand,
} = require('@aws-sdk/client-bedrock-runtime'); } = 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'; const DEFAULT_CALL2_MODEL_ID = 'anthropic.claude-3-haiku-20240307-v1:0';
function getRegion() { function getRegion() {
@ -23,6 +23,12 @@ function getCall2ModelId() {
return process.env.BEDROCK_CALL2_MODEL_ID || DEFAULT_CALL2_MODEL_ID; 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. * Nova models in APAC often need inference profile IDs (apac.*) instead of foundation model IDs.
* Anthropic / other foundation model IDs are left unchanged. * 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 {import('@aws-sdk/client-bedrock-runtime').BedrockRuntimeClient} client
* @param {string} modelId * @param {string} modelId
@ -90,13 +152,13 @@ async function converse(client, modelId, opts) {
const { system, messages, inferenceConfig, logLabel } = opts; const { system, messages, inferenceConfig, logLabel } = opts;
const region = getRegion(); const region = getRegion();
const resolvedModelId = resolveModelId(modelId, region); const resolvedModelId = resolveModelId(modelId, region);
const systemBlocks = system ? [{ text: system }] : undefined; const systemBlocks = systemBlocksFor(system, resolvedModelId);
const mergedInferenceConfig = { const mergedInferenceConfig = sanitizeInferenceConfig(resolvedModelId, {
maxTokens: 400, maxTokens: 400,
temperature: 0, temperature: 0,
topP: 0.1, topP: 0.1,
...inferenceConfig, ...inferenceConfig,
}; });
const label = logLabel || 'converse'; const label = logLabel || 'converse';
const shouldLog = Boolean(logLabel) || isLlmLogEnabled(); const shouldLog = Boolean(logLabel) || isLlmLogEnabled();
const startedAt = Date.now(); const startedAt = Date.now();
@ -107,6 +169,7 @@ async function converse(client, modelId, opts) {
modelId: resolvedModelId, modelId: resolvedModelId,
requestedModelId: modelId !== resolvedModelId ? modelId : undefined, requestedModelId: modelId !== resolvedModelId ? modelId : undefined,
inferenceConfig: mergedInferenceConfig, inferenceConfig: mergedInferenceConfig,
promptCache: Boolean(systemBlocks?.some((b) => b.cachePoint)),
system, system,
messages: messageTexts(messages), messages: messageTexts(messages),
}); });
@ -126,7 +189,7 @@ async function converse(client, modelId, opts) {
console.log(`[bedrock][${label}][response]`, { console.log(`[bedrock][${label}][response]`, {
modelId: resolvedModelId, modelId: resolvedModelId,
latencyMs: Date.now() - startedAt, latencyMs: Date.now() - startedAt,
usage: response.usage || null, usage: usageForLog(response.usage),
stopReason: response.stopReason || null, stopReason: response.stopReason || null,
text: responseText(response), text: responseText(response),
}); });
@ -152,6 +215,7 @@ module.exports = {
getRegion, getRegion,
getRouterModelId, getRouterModelId,
getCall2ModelId, getCall2ModelId,
getCall2StrongModelId,
resolveModelId, resolveModelId,
createClient, createClient,
converse, converse,

View File

@ -27,6 +27,13 @@ function compactEntities(entities) {
return omitEmpty(entities && typeof entities === 'object' ? 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) { function slimTrack(track) {
if (!track) return undefined; if (!track) return undefined;
if (typeof track === 'string') return track; if (typeof track === 'string') return track;
@ -156,6 +163,7 @@ function slimDataForLlm(data, intent) {
query: src.query ? omitEmpty(src.query) : null, query: src.query ? omitEmpty(src.query) : null,
tracks: slimTracksList(src.tracks), tracks: slimTracksList(src.tracks),
conflicts: slimConflicts(src.conflicts), conflicts: slimConflicts(src.conflicts),
alreadyTold: alreadyToldField(src),
}), }),
sessions, sessions,
}; };
@ -228,6 +236,7 @@ function slimVenueDataForLlm(data) {
halls: Array.isArray(src.halls) && src.halls.length ? src.halls.slice(0, 20) : undefined, 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, rooms: Array.isArray(src.rooms) && src.rooms.length ? src.rooms.slice(0, 25) : undefined,
places: places.length ? places : 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, found: src.found === false ? false : src.found ? true : null,
policy: src.policy || null, policy: src.policy || null,
note: hideEventName ? null : src.note || null, note: hideEventName ? null : src.note || null,
registrationLinks: Array.isArray(src.registrationLinks) registrationLinks: hideEventName
? src.registrationLinks ? undefined
.map((l) => : Array.isArray(src.registrationLinks)
omitEmpty({ ? src.registrationLinks
label: l.label || null, .map((l) =>
url: l.url || null, omitEmpty({
}), label: l.label || null,
) url: l.url || null,
.filter((l) => l.url) }),
.slice(0, 8) )
: undefined, .filter((l) => l.url)
contact: contact && (contact.email || contact.phone || contact.text) ? contact : undefined, .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, faqs: faqs.length ? faqs : undefined,
tickets: tickets.length ? tickets : undefined, tickets: tickets.length ? tickets : undefined,
emailSent: src.emailSent === true ? true : src.emailSent === false ? false : null, emailSent: src.emailSent === true ? true : src.emailSent === false ? false : null,
@ -343,6 +367,7 @@ function slimTicketDataForLlm(data) {
}) })
: null, : null,
receipt: receipt && Object.keys(receipt).length ? receipt : 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 !isList && Array.isArray(src.meetSessions) && src.meetSessions.length
? src.meetSessions.length ? src.meetSessions.length
: undefined, : undefined,
alreadyTold: alreadyToldField(src),
}); });
} }
@ -515,6 +541,7 @@ function slimExhibitorDataForLlm(data) {
exhibitors: exhibitors.length ? exhibitors : undefined, exhibitors: exhibitors.length ? exhibitors : undefined,
candidates: candidates.length ? candidates : undefined, candidates: candidates.length ? candidates : undefined,
faqs: faqs.length ? faqs : undefined, faqs: faqs.length ? faqs : undefined,
alreadyTold: alreadyToldField(src),
}); });
} }
@ -544,6 +571,7 @@ function slimTechDataForLlm(data) {
note: src.note || null, note: src.note || null,
faqs: faqs.length ? faqs : undefined, faqs: faqs.length ? faqs : undefined,
staticTips: staticTips.length ? staticTips : undefined, staticTips: staticTips.length ? staticTips : undefined,
alreadyTold: alreadyToldField(src),
}); });
} }
@ -574,15 +602,52 @@ function slimSystemDataForLlm(data) {
text: src.contact.text || null, text: src.contact.text || null,
}) })
: 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({ return omitEmpty({
eventName: hideEventName ? null : src.eventName || null, 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, emailSent: src.emailSent === true ? true : null,
rateLimited: src.rateLimited === true ? true : null, rateLimited: src.rateLimited === true ? true : null,
emailError: src.emailError || null, emailError: src.emailError || null,
note: src.note || null, note: src.note || null,
contact: contact && (contact.email || contact.phone || contact.text) ? contact : undefined, contact: contact && (contact.email || contact.phone || contact.text) ? contact : undefined,
faqs: faqs.length ? faqs : 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, note: src.note || null,
places: places.length ? places : undefined, places: places.length ? places : undefined,
hotels: hotels.length ? hotels : undefined, hotels: hotels.length ? hotels : undefined,
alreadyTold: alreadyToldField(src),
}); });
} }

334
src/utils/domainAction.js Normal file
View File

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

View File

@ -135,14 +135,44 @@ async function loadEventParticipants(Participant, organizationId, eventId) {
return rows.filter((p) => membershipAllowed(membershipForEvent(p, 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[] }} * @returns {{ kind: 'unique', participant: object, score: number } | { kind: 'none' } | { kind: 'ambiguous', candidates: object[] }}
*/ */
async function matchParticipantByPhone({ Participant, organizationId, eventId, waDigits }) { 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 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: 'unique', participant: hits[0], score: 1 };
if (hits.length > 1) return { kind: 'ambiguous', candidates: hits }; if (hits.length > 1) return { kind: 'ambiguous', candidates: hits };
return { kind: 'none' }; return { kind: 'none' };
@ -177,6 +207,7 @@ module.exports = {
participantFullName, participantFullName,
scoreName, scoreName,
matchParticipantByPhone, matchParticipantByPhone,
matchOrgParticipantByPhone,
matchParticipantByName, matchParticipantByName,
membershipForEvent, membershipForEvent,
membershipAllowed, membershipAllowed,

View File

@ -1,6 +1,7 @@
const crypto = require('crypto'); const crypto = require('crypto');
const WebChatSession = require('../models/WebChatSession'); const WebChatSession = require('../models/WebChatSession');
const { handleChatTurn } = require('../services/orchestrator.service'); const { handleChatTurn } = require('../services/orchestrator.service');
const { mergeAlreadyTold } = require('./alreadyTold');
const MAX_MESSAGE_LENGTH = 800; const MAX_MESSAGE_LENGTH = 800;
const MAX_HISTORY_MESSAGES = 40; const MAX_HISTORY_MESSAGES = 40;
@ -83,6 +84,7 @@ async function runPublicChatTurn({ bot, event, session, message }) {
if (Array.isArray(result.sessionIds) && result.sessionIds.length) { if (Array.isArray(result.sessionIds) && result.sessionIds.length) {
session.lastSessionIds = result.sessionIds.map((id) => String(id)).filter(Boolean).slice(0, 8); session.lastSessionIds = result.sessionIds.map((id) => String(id)).filter(Boolean).slice(0, 8);
} }
mergeAlreadyTold(session, result.faqKeysUsed);
await session.save(); await session.save();
return { return {