apps/web/src/routes/api/ai/index.ts
1import { createGateway } from '@ai-sdk/gateway'
2import { propagateAttributes } from '@langfuse/tracing'
3import type {
4 ChatMessage,
5 ChatMessageType,
6 ChatRequestPayload,
7 UserProfile,
8} from '@local/ai'
9import {
10 loadAgendaPage,
11 loadAgendaSessions,
12 mediaIndexName,
13 type PineconeSearchResult,
14 queryPineconeStores,
15 type StoreQuery,
16 scopeKeyToAgendaIndexName,
17 scopeKeyToIndexName,
18 sitemapIndexName,
19 summarizeAgenda,
20} from '@local/ai/components/api/pineconeUpload'
21import {
22 confirmedHandoff,
23 emailFromMessage,
24 HANDOFF_FAILURE_REPLY,
25 historyAfterHandoffCancellation,
26 isHandoffCancellation,
27 normalizeEmailEscapes,
28 resolveConversationEvent,
29 sessionConsent,
30} from '@local/ai/components/workflows'
31import {
32 type AiCapabilities,
33 aiToolEnabled,
34} from '@local/config/ai-capabilities'
35import { isHandoffQuestion } from '@local/config/ai-consent'
36import {
37 DECISION_PILLAR_QUESTION,
38 DECISION_PILLARS,
39 parseDecisionPillars,
40} from '@local/config/decision-pillars'
41import { getBusinessEmailValidationMessage } from '@local/forms/business-email'
42import type { APIEvent } from '@solidjs/start/server'
43import { waitUntil } from '@vercel/functions'
44import type { ModelMessage, ToolSet } from 'ai'
45import { jsonSchema, stepCountIs, streamText, tool } from 'ai'
46import { speakingPerson, stageListingReply } from '~/lib/agendaListing'
47import {
48 createAgendaPlan,
49 formatPlan,
50 requestedAgendaDay,
51 validateAgendaSelection,
52} from '~/lib/agendaPlan'
53import {
54 getAiCapabilities,
55 runAllowedAiSubmission,
56} from '~/lib/aiCapabilities.server'
57import {
58 availableWorkflow,
59 capabilityPrompt,
60 filterAiTools,
61 filterCapabilityInstructions,
62} from '~/lib/aiCapabilityPolicy'
63import { disabledWorkflowReply } from '~/lib/aiDisabledWorkflow'
64import { loadFlowSession, saveFlowSession } from '~/lib/aiFlowSessions'
65import {
66 AGENDA_INTEREST_QUESTION,
67 acceptedAgendaEmail,
68 acceptedAgendaOffer,
69 acceptedSalesOffer,
70 agendaEmailInProgress,
71 agendaOfferSuffix,
72 asksRestrictedSessionAccess,
73 asksSponsorshipInformation,
74 isCommercialInformationRequest,
75 isCommercialLogisticsQuestion,
76 isContentInformationRequest,
77 isConversationClosing,
78 isSelfServiceApplication,
79 isTicketInformationRequest,
80 isTicketSalesConversation,
81 needsAgendaInterests,
82 requestsAgendaPlan,
83 requestsTicketBooking,
84 TICKET_QUALIFICATION,
85} from '~/lib/aiFlows'
86import {
87 checkAiGuards,
88 checkRepeatedExchange,
89 guardTextResponse,
90} from '~/lib/aiGuards'
91import { type HandoffOutcome, selectedAgendaUrls } from '~/lib/aiHandoff'
92import { getAiPrompts, resolveAiModel } from '~/lib/aiPrompts'
93import {
94 monitorChatResponse,
95 type ReviewCapture,
96} from '~/lib/aiResponseMonitor.server'
97import {
98 policyReply,
99 RESPONSE_POLICY,
100 responsePolicyStream,
101 type SupportEmail,
102 supportEmail,
103} from '~/lib/aiResponsePolicy'
104import { isRefusalTemplate, normalizeContactRoute } from '~/lib/aiRetrieval'
105import {
106 buildClassifierState,
107 type RoutingDecision,
108 routingMode,
109} from '~/lib/aiRouting'
110import { classifyRouting } from '~/lib/aiRouting.server'
111import { flushAiTelemetry, initAiTelemetry } from '~/lib/aiTelemetry'
112import { loadBadgePages } from '~/lib/badges'
113import { createChatDelivery } from '~/lib/chatDelivery'
114import {
115 type EventDates,
116 type eventScopeFromSlug,
117 getEventDates,
118} from '~/lib/eventDates'
119import {
120 type EventFactTopic,
121 eventFactReply,
122 eventFactTopic,
123 recordingPolicyReply,
124 startupAwardReply,
125 startupFactReply,
126} from '~/lib/eventFacts'
127import { loadEventFacts } from '~/lib/eventFacts.server'
128import { selectFlow } from '~/lib/flowSelector.server'
129import {
130 agendaContactReply,
131 groundedContact,
132 hasContactSummary,
133 malformedContactEmail,
134 missingContactQuestion,
135} from '~/lib/handoffContact'
136import { asksForHotelBooking, hotelBookingReply } from '~/lib/hotelBooking'
137import {
138 type LeadAttribution,
139 readLeadAttribution,
140} from '~/lib/leadAttribution'
141import type { LeadIntent } from '~/lib/leads'
142import {
143 isLeadIntent,
144 LEAD_INTENTS,
145 leadIntentFor,
146 submitAgendaEmailToZapier,
147 submitLeadToZapier,
148} from '~/lib/leads'
149import { loadBadgePricing } from '~/lib/livePricing.server'
150import { markdownDeliveryStream } from '~/lib/markdownDelivery'
151import { mediaTypeFilter } from '~/lib/mediaSearch'
152import { loadEventPageDirectory } from '~/lib/pageDirectory'
153import { fetchRecentArticles } from '~/lib/recentArticles'
154import {
155 commercialRequestDetails,
156 confirmedSalesRequest,
157 salesAcceptanceReply,
158} from '~/lib/salesConfirmation'
159import {
160 specificKnowledgeFact,
161 supportsSpecificKnowledgeFact,
162} from '~/lib/specificKnowledgeFact'
163import { ticketFactsReply } from '~/lib/ticketAnswer'
164import { resolveTicketAnswerKind } from '~/lib/ticketAnswer.server'
165import { fetchUpcomingWebinars } from '~/lib/upcomingWebinars'
166import {
167 formatProfileTable,
168 parseUserProfile,
169 ticketEnquiryReply,
170} from '~/lib/userProfile'
171
172type ChatPayload = Partial<Record<keyof ChatRequestPayload, unknown>>
173
174const MAX_MESSAGE_LENGTH = 4000
175const MAX_OUTPUT_TOKENS = 3500
176const MAX_STEPS = 8
177const SEARCH_TOP_K = 6
178/**
179 * Agenda stores use session-complete chunks (one chunk = one session), so
180 * topK caps how many sessions a search can surface. 6 missed most of the
181 * keynote line-up (Paris alone has 6 keynote sessions competing with stage
182 * sessions for the same query); 14 covers a full day's shortlist.
183 */
184const AGENDA_SEARCH_TOP_K = 14
185
186/**
187 * Per-task models (Vercel AI Gateway ids), each overridable via env.
188 * The concierge needs strong guardrail adherence + multi-tool routing; the
189 * agenda builder does long-form multi-source synthesis; profile extraction
190 * (in /api/ai/profile) is a cheap structured task.
191 */
192function getModels(sanityModels: { concierge?: string; agenda?: string }) {
193 return {
194 concierge: resolveAiModel('concierge', sanityModels.concierge).id,
195 agenda: resolveAiModel('agenda', sanityModels.agenda).id,
196 }
197}
198
199function getGateway() {
200 const apiKey = process.env.AI_GATEWAY_API_KEY || process.env.AI_GATEWAY
201 return createGateway(apiKey ? { apiKey } : {})
202}
203
204type EventScope = ReturnType<typeof eventScopeFromSlug>
205
206const SEARCH_INPUT = jsonSchema<{ query: string }>({
207 type: 'object',
208 properties: {
209 query: {
210 type: 'string',
211 description: 'Natural-language search query for this source.',
212 },
213 },
214 required: ['query'],
215})
216
217/**
218 * What the model gets per hit. Only page/media records carry a `title` and a
219 * `url`; uploaded-file chunks carry neither. Internal metadata (file names,
220 * chunk ids, event titles, event slugs) is dropped on purpose — exposed, the
221 * model turns it into link labels ("UNLEASH in Paris 2026 | travel_venue")
222 * and links them to the event root.
223 */
224function toSearchResult(r: PineconeSearchResult) {
225 const title =
226 typeof r.metadata.title === 'string' ? r.metadata.title : undefined
227 const url = typeof r.metadata.slug === 'string' ? r.metadata.slug : undefined
228 const question =
229 typeof r.metadata.question === 'string' ? r.metadata.question : undefined
230 return { text: normalizeContactRoute(r.text), title, url, question }
231}
232
233/**
234 * A long page can fill every slot with its own chunks, hiding the more
235 * specific page one rank below. Cap chunks per page so the model sees more
236 * distinct pages and can link the precise one. Results stay score-ordered.
237 */
238const MAX_CHUNKS_PER_PAGE = 2
239function limitPerPage(
240 results: PineconeSearchResult[],
241 topK: number,
242): PineconeSearchResult[] {
243 const perPage = new Map<string, number>()
244 const kept: PineconeSearchResult[] = []
245 for (const r of results) {
246 const page = typeof r.metadata.slug === 'string' ? r.metadata.slug : null
247 if (page) {
248 const n = perPage.get(page) ?? 0
249 if (n >= MAX_CHUNKS_PER_PAGE) continue
250 perPage.set(page, n + 1)
251 }
252 kept.push(r)
253 if (kept.length >= topK) break
254 }
255 return kept
256}
257
258function makeSearchTool(
259 description: string,
260 stores: readonly (string | StoreQuery)[],
261 topK = SEARCH_TOP_K,
262) {
263 return tool({
264 description,
265 inputSchema: SEARCH_INPUT,
266 execute: async ({ query }) => {
267 try {
268 const results = limitPerPage(
269 (await queryPineconeStores(stores, query, topK * 2)).filter(
270 (r) => !isRefusalTemplate(r.text),
271 ),
272 topK,
273 )
274 if (results.length === 0) return { results: [], note: 'No matches.' }
275 return {
276 results: results.map(toSearchResult),
277 rules:
278 'These are similarity matches, not proof that they answer the question. An answer must explicitly support the requested fact for the same named entity, item and event. Never substitute a similar room, session, package or document. If none supports that exact fact, say you cannot confirm it. Only link a returned URL; missing URLs stay plain text.',
279 }
280 } catch (error) {
281 const message = error instanceof Error ? error.message : 'Search failed'
282 // Surfaced to the model as "nothing found"; make it visible to ops.
283 console.warn('[ai] source search failed', message)
284 return { results: [], error: message }
285 }
286 },
287 })
288}
289
290/**
291 * Source tools mirroring the n8n setup, physically filtered by the visitor's
292 * page so an America visitor can never reach World sources (and vice versa) —
293 * routing is enforced in code, not just in the prompt.
294 */
295function buildTools(options: {
296 attribution: LeadAttribution
297 history: ChatMessage[]
298 capabilities: AiCapabilities
299 onPlan: (text: string) => void
300 onHandoff: (outcome: HandoffOutcome) => void | Promise<void>
301 selectedSessionUrls: string[]
302 eventScope: EventScope
303 type: ChatMessageType
304 abortSignal: AbortSignal | undefined
305 chatId: string
306 location: string
307 /** Server-known visitor profile — merged into handoff payloads so role/company/interests reach the Zap even when the model omits them. */
308 userProfile: UserProfile | undefined
309 /** Consent granted in this chat session and not subsequently withdrawn. */
310 consentGiven: boolean
311}): ToolSet {
312 const { eventScope, type, chatId, location, userProfile, consentGiven } =
313 options
314 const invalidWorkEmail = async (
315 email: string,
316 kind: HandoffOutcome['kind'],
317 ) => {
318 const error = getBusinessEmailValidationMessage(email)
319 if (!error) return undefined
320 await options.onHandoff({ kind, ok: false, validationReply: error })
321 return { ok: false, error }
322 }
323 const failedSubmission = async (kind: HandoffOutcome['kind']) => {
324 await options.onHandoff({ kind, ok: false })
325 return { ok: false, error: HANDOFF_FAILURE_REPLY }
326 }
327
328 // Both hand-off tools post to the Unleash Zapier hook; Salesforce leads and
329 // Postmark agenda emails happen downstream on the Unleash side (SoW §7.4).
330 const submitLead = tool({
331 description:
332 'Create a lead with the Unleash team via Zapier. Use ONLY after the visitor has confirmed interest, seen the consent note, and provided their name, work email, company and job role. Call this only on the affirmative reply to the final request summary and consent question. Always set intent to exactly one allowed Salesforce Intent value. Returns ok — tell the visitor the team will be in touch ONLY when ok is true; if ok is false, stop the handoff and direct the visitor to support@unleash.ai. Do not repeat qualification or consent questions.',
333 inputSchema: jsonSchema<{
334 name: string
335 email: string
336 company?: string
337 role?: string
338 enquiry: string
339 intent: LeadIntent
340 }>({
341 type: 'object',
342 properties: {
343 name: { type: 'string' },
344 email: { type: 'string' },
345 company: { type: 'string' },
346 role: { type: 'string' },
347 enquiry: {
348 type: 'string',
349 description:
350 "One line describing what the visitor wants (e.g. 'group tickets x8 for UNLEASH in Miami', 'sponsorship enquiry').",
351 },
352 intent: {
353 type: 'string',
354 enum: [...LEAD_INTENTS],
355 description:
356 'Salesforce Intent picklist. Suffix: UNW=World, UNA=America, FB=show unknown. Startup/Spex always trump agenda/brochure/register when that commercial category is clear. Otherwise: Delegate Agenda for agenda requests, Delegate Brochure for brochure offers, Register Interest for future-show marketing opt-ins, Delegate when no more specific category fits.',
357 },
358 },
359 required: ['name', 'email', 'enquiry', 'intent'],
360 }),
361 execute: async ({ name, email, company, role, enquiry, intent }) => {
362 if (!eventScope) return failedSubmission('lead')
363 if (!isLeadIntent(intent)) {
364 return failedSubmission('lead')
365 }
366 if (!consentGiven) return failedSubmission('lead')
367 email = normalizeEmailEscapes(email).trim()
368 const contact = groundedContact(
369 userProfile,
370 { name, email, company, role },
371 options.history,
372 )
373 const emailError = await invalidWorkEmail(contact.email || email, 'lead')
374 if (emailError) return emailError
375 const question = missingContactQuestion(contact, false)
376 if (question) return failedSubmission('lead')
377 name = contact.name || ''
378 email = contact.email || ''
379 const result = await runAllowedAiSubmission('submitLead', () =>
380 submitLeadToZapier({
381 attribution: options.attribution,
382 consent: true,
383 profile: {
384 name,
385 email,
386 // Model args win (fresher, from this turn); the extracted
387 // profile fills what the model didn't pass.
388 company: contact.company,
389 role: contact.role,
390 interests: userProfile?.interests,
391 decisionPillars: userProfile?.decisionPillars,
392 stage: 'qualified',
393 },
394 chatId,
395 location,
396 trigger: 'chat-tool',
397 enquiry,
398 // SAFETY: intent was validated above; replacing its known suffix preserves the picklist.
399 intent: intent.replace(
400 /(?:UNW|UNA|FB)$/,
401 eventScope === 'world' ? 'UNW' : 'UNA',
402 ) as LeadIntent,
403 }),
404 )
405 await options.onHandoff({
406 kind: 'lead',
407 ok: result.ok,
408 ticketEnquiry: intent.startsWith('Delegate '),
409 })
410 return result
411 },
412 })
413
414 const sendAgendaByEmail = tool({
415 description:
416 'Email the visitor their personalised agenda via Zapier (downstream delivery is Unleash-side). Use ONLY after you have presented an agenda, the visitor asked to receive it by email, saw the consent note, and provided their name, work email, company and job role. Call this only on the affirmative reply to the final request summary and consent question. Default optIn false unless explicitly requested. Pass the full agenda text. Returns ok — confirm dispatch ONLY when ok is true. If sending fails, stop the handoff and direct the visitor to support@unleash.ai; do not repeat qualification or consent questions.',
417 inputSchema: jsonSchema<{
418 name: string
419 email: string
420 company?: string
421 role?: string
422 sessionUrls: string[]
423 event?: string
424 optIn?: boolean
425 }>({
426 type: 'object',
427 properties: {
428 name: { type: 'string' },
429 email: { type: 'string' },
430 company: { type: 'string' },
431 role: { type: 'string' },
432 sessionUrls: {
433 type: 'array',
434 items: { type: 'string' },
435 description:
436 'Exact session URLs from the selected plan only. Exclude alternatives and other page links.',
437 },
438 event: { type: 'string' },
439 optIn: {
440 type: 'boolean',
441 description:
442 'Whether the visitor opted in to hear from Unleash about the event.',
443 },
444 },
445 required: ['name', 'email', 'sessionUrls'],
446 }),
447 execute: async ({ name, email, company, role, sessionUrls, optIn }) => {
448 if (!consentGiven) return failedSubmission('agenda-email')
449 email = normalizeEmailEscapes(email).trim()
450 const contact = groundedContact(
451 userProfile,
452 { name, email, company, role },
453 options.history,
454 )
455 const emailError = await invalidWorkEmail(
456 contact.email || email,
457 'agenda-email',
458 )
459 if (emailError) return emailError
460 const question = missingContactQuestion(contact, true)
461 if (question) return failedSubmission('agenda-email')
462 name = contact.name || ''
463 email = contact.email || ''
464 if (!eventScope) return failedSubmission('agenda-email')
465 try {
466 const scope = eventScope === 'world' ? 'event-world' : 'event-america'
467 const [all, page] = await Promise.all([
468 loadAgendaSessions(scope),
469 loadAgendaPage(scope),
470 ])
471 const selected = validateAgendaSelection(
472 all,
473 options.selectedSessionUrls.length
474 ? options.selectedSessionUrls
475 : sessionUrls,
476 )
477 if (!selected) return failedSubmission('agenda-email')
478 const result = await runAllowedAiSubmission('sendAgendaByEmail', () =>
479 submitAgendaEmailToZapier({
480 attribution: options.attribution,
481 decisionPillars: userProfile?.decisionPillars,
482 consent: true,
483 chatId,
484 location,
485 name,
486 email,
487 agenda: formatPlan(
488 eventScope === 'world' ? 'paris' : 'miami',
489 selected,
490 page,
491 ),
492 event: eventScope === 'world' ? 'Paris' : 'Miami',
493 optIn,
494 intent: leadIntentFor(
495 'Delegate Agenda',
496 eventScope === 'world'
497 ? '/events/unleash-paris'
498 : '/events/unleash-miami',
499 ),
500 role: contact.role,
501 company: contact.company,
502 interests: userProfile?.interests,
503 }),
504 )
505 await options.onHandoff({ kind: 'agenda-email', ok: result.ok })
506 return result
507 } catch {
508 return failedSubmission('agenda-email')
509 }
510 },
511 })
512
513 // Event stores are searched together with the general FAQ store (and vice
514 // versa on event pages): uploaded FAQs land in either, and a question like
515 // "is there a cloakroom?" must not fail because the model picked the wrong
516 // one first.
517 const generalIndex = scopeKeyToIndexName('general')
518 // The event pages' own body copy (hotels, badges, volunteering, policies)
519 // lives in the sitemap store, tagged with the event it belongs to. Event
520 // tools search it filtered to their event, so a logistics question is
521 // answered from the live page even when no FAQ file covers it — and a
522 // Paris visitor can never surface a Miami page this way.
523 const eventPages = (eventSlug: string): StoreQuery => ({
524 indexName: sitemapIndexName(),
525 filter: { eventSlug: { $eq: eventSlug } },
526 })
527 const america = {
528 unleashAmerica: makeSearchTool(
529 'Specific knowledge for the UNLEASH in Miami event (legacy name: Unleash America): passes, tickets, hotels, venue, on-site facilities, speakers, sponsors, logistics, the full text of every /events/unleash-miami/ page, plus the FAQ files uploaded by the Unleash team.',
530 [
531 scopeKeyToIndexName('event-america'),
532 generalIndex,
533 eventPages('/events/unleash-miami'),
534 ],
535 ),
536 unleashAmericaAgenda: makeSearchTool(
537 'Semantic search over the UNLEASH in Miami agenda (legacy name: Unleash America): talks, sessions, roundtables, times, speakers, plus the event FAQ (stage themes, hosts/MCs). For "what is on stage N", "how many stages", "which day" or "which keynotes" use agendaLookup instead — it reads the whole agenda.',
538 [
539 scopeKeyToAgendaIndexName('event-america'),
540 scopeKeyToIndexName('event-america'),
541 ],
542 AGENDA_SEARCH_TOP_K,
543 ),
544 }
545 const world = {
546 unleashWorld: makeSearchTool(
547 'Specific knowledge for the UNLEASH in Paris event (legacy name: UNLEASH WORLD): passes, tickets, hotels, venue, on-site facilities, speakers, sponsors, logistics, the full text of every /events/unleash-paris/ page, plus the FAQ files uploaded by the Unleash team.',
548 [
549 scopeKeyToIndexName('event-world'),
550 generalIndex,
551 eventPages('/events/unleash-paris'),
552 ],
553 ),
554 unleashWorldAgenda: makeSearchTool(
555 'Semantic search over the UNLEASH in Paris agenda (legacy name: UNLEASH WORLD): talks, sessions, roundtables, times, speakers, plus the event FAQ (stage themes, hosts/MCs). For "what is on stage N", "how many stages", "which day" or "which keynotes" use agendaLookup instead — it reads the whole agenda.',
556 [
557 scopeKeyToAgendaIndexName('event-world'),
558 scopeKeyToIndexName('event-world'),
559 ],
560 AGENDA_SEARCH_TOP_K,
561 ),
562 }
563 // Deterministic agenda lookup: similarity search returns the top-K chunks,
564 // so "what's on Stage 2?" or "how many stages?" can miss whole rooms and the
565 // model concludes they don't exist. This reads the complete session list.
566 const agendaLookup = tool({
567 description:
568 'Complete, structured agenda for an event: agendaPage (the URL of the agenda page on the site — link THIS when the visitor asks where to see the agenda or whether there is an agenda page), the list of stages (rooms named "Stage N - …") with session counts, the event days, and every session matching the filter with exact date/time, room, format, keynote flag, speakers and (when present) a url to open the session on the site. ALWAYS use this for: how many stages / which stages, what is on stage N, what is on a given day, which sessions are keynotes, when a session takes place, and whether a named person is speaking (set speaker). A missing speaker means no match in this synced agenda, not proof that the person is unannounced or will not attend. Link the official speakers page for updates. For personalised plans call buildAgenda; do not compose an unchecked schedule.',
569 inputSchema: jsonSchema<{
570 event?: 'paris' | 'miami'
571 stage?: string
572 day?: string
573 keynotesOnly?: boolean
574 speaker?: string
575 title?: string
576 topics?: string
577 }>({
578 type: 'object',
579 properties: {
580 event: {
581 type: 'string',
582 enum: ['paris', 'miami'],
583 description:
584 'Which event. Defaults to the event of the page the visitor is on.',
585 },
586 stage: {
587 type: 'string',
588 description:
589 'Stage/room filter, e.g. "stage 2", "Stage 4 - Skills", "spotlight". Omit for all rooms.',
590 },
591 day: {
592 type: 'string',
593 description: 'Day filter as YYYY-MM-DD. Omit for all days.',
594 },
595 keynotesOnly: {
596 type: 'boolean',
597 description: 'Return only sessions flagged as keynotes.',
598 },
599 speaker: {
600 type: 'string',
601 description:
602 'Person lookup: a speaker name or surname ("Josh Bersin", "Bersin"). Returns every session listing them.',
603 },
604 title: {
605 type: 'string',
606 description:
607 'Session lookup: words from a session title. Returns the matching sessions with their exact time and room.',
608 },
609 topics: {
610 type: 'string',
611 description:
612 'Interest filter: prefer a canonical decision-pillar ID when filtering by a pillar. Explicit free-text interests also match the stored pillar label, topics, track, title or format.',
613 },
614 },
615 }),
616 execute: async ({
617 event,
618 stage,
619 day,
620 keynotesOnly,
621 speaker,
622 title,
623 topics,
624 }) => {
625 const scope =
626 event === 'miami' || (!event && eventScope === 'america')
627 ? 'event-america'
628 : event === 'paris' || (!event && eventScope === 'world')
629 ? 'event-world'
630 : null
631 if (!scope) {
632 return {
633 error:
634 'No event selected. Ask which event (UNLEASH in Miami or UNLEASH in Paris) and call again with event set.',
635 }
636 }
637 try {
638 const sessions = await loadAgendaSessions(scope)
639 if (sessions.length === 0) {
640 return { error: 'The agenda for this event is not available yet.' }
641 }
642 return {
643 agendaPage: await loadAgendaPage(scope),
644 ...summarizeAgenda(sessions, {
645 stage,
646 day,
647 keynotesOnly:
648 keynotesOnly ||
649 (options.history.at(-1)?.role === 'user' &&
650 /\bkeynote speakers?\b|who.*\bkeynotes?\b/i.test(
651 options.history.at(-1)?.text || '',
652 )),
653 speaker,
654 title,
655 topics,
656 }),
657 }
658 } catch (error) {
659 const message =
660 error instanceof Error ? error.message : 'Agenda lookup failed'
661 return { error: message }
662 }
663 },
664 })
665 // Vector search over the content catalogue can't filter by date, so
666 // historic webinars surface for "upcoming webinar" questions. This tool is
667 // a direct Sanity query — the authoritative future-webinars list.
668 const upcomingWebinars = tool({
669 description:
670 'Definitive list of confirmed UPCOMING Unleash webinars (not yet aired), straight from the website database with exact dates, speakers and URLs. ALWAYS use this for "upcoming / next / live webinar" questions instead of the content catalogue, which also returns past webinars. An empty list means no upcoming webinars are confirmed right now — say so and link the /webinars page (live & on-demand catalogue).',
671 inputSchema: jsonSchema<Record<string, never>>({
672 type: 'object',
673 properties: {},
674 }),
675 execute: async () => {
676 try {
677 const webinars = await fetchUpcomingWebinars()
678 if (webinars.length === 0) {
679 return {
680 webinars: [],
681 note:
682 'No upcoming webinars are confirmed right now. Say so and point the visitor to /webinars for the on-demand catalogue.',
683 }
684 }
685 return { webinars }
686 } catch (error) {
687 const message =
688 error instanceof Error ? error.message : 'Webinar lookup failed'
689 return { webinars: [], error: message }
690 }
691 },
692 })
693
694 const shared = {
695 eventFacts: tool({
696 description:
697 'Exact, complete FAQ policies and published page bodies for registration, free-ticket routes, onsite registration, refunds, transfers, upgrades, invoice payments, floorplans, volunteering and exhibitor stand plans. Use before broad search for these subjects. Read only; cannot collect details or send enquiries.',
698 inputSchema: jsonSchema<{
699 event?: 'paris' | 'miami'
700 topic: EventFactTopic
701 }>({
702 type: 'object',
703 properties: {
704 event: { type: 'string', enum: ['paris', 'miami'] },
705 topic: {
706 type: 'string',
707 enum: [
708 'freeTickets',
709 'onsiteRegistration',
710 'exhibitorStandPlans',
711 'volunteering',
712 'registration',
713 'refunds',
714 'ticketTransfers',
715 'ticketUpgrades',
716 'invoicePayment',
717 'floorplan',
718 'startupEligibility',
719 'startupOptions',
720 'startupInclusions',
721 'hrLeaderEligibility',
722 'explorerApplication',
723 'discounts',
724 'parking',
725 'openingHours',
726 'attendance',
727 'industries',
728 'recordingAccess',
729 ],
730 },
731 },
732 required: ['topic'],
733 }),
734 execute: async ({ event, topic }) => {
735 const selected =
736 eventScope === 'world'
737 ? 'paris'
738 : eventScope === 'america'
739 ? 'miami'
740 : event
741 if (!selected) return { error: 'Ask which event: Paris or Miami.' }
742 try {
743 return await loadEventFacts(selected, topic)
744 } catch {
745 return {
746 error:
747 'Verified event facts are temporarily unavailable. Do not invent an answer.',
748 }
749 }
750 },
751 }),
752 recentArticles: tool({
753 description:
754 'Use for article recommendations and latest news. Searches published articles directly, newest first by publication date. Supply a short topic (e.g. "AI" or "talent acquisition"), not a full question; use an empty topic for the latest articles overall. For multiple interests search each separately. Preserve newest-first order and show publication dates. If empty, try a broader related topic; never substitute unrelated articles or describe archival content as recent.',
755 inputSchema: jsonSchema<{ topic: string }>({
756 type: 'object',
757 properties: { topic: { type: 'string' } },
758 required: ['topic'],
759 }),
760 execute: async ({ topic }) => {
761 try {
762 return { articles: await fetchRecentArticles(topic) }
763 } catch (error) {
764 console.warn('[ai] recent article lookup failed', error)
765 return {
766 error:
767 'Recent articles are temporarily unavailable. Do not claim older search results are the latest.',
768 }
769 }
770 },
771 }),
772 upcomingWebinars,
773 generalKnowledge: makeSearchTool(
774 'General Unleash knowledge and FAQs: what Unleash is, history, brand, careers, company information, event policies and practical FAQs (refunds, timings, venues, facilities, on-site spaces), plus knowledge files uploaded by the Unleash team. Consult this before saying information is unavailable.',
775 [
776 generalIndex,
777 ...(eventScope === 'world'
778 ? [scopeKeyToIndexName('event-world'), eventPages('/events/unleash-paris')]
779 : eventScope === 'america'
780 ? [
781 scopeKeyToIndexName('event-america'),
782 eventPages('/events/unleash-miami'),
783 ]
784 : []),
785 ],
786 ),
787 unleashSales: makeSearchTool(
788 'Sales knowledge: sponsorship, exhibiting, partnerships and commercial opportunities.',
789 [scopeKeyToIndexName('sales-agent')],
790 ),
791 unleashSalesSpex: makeSearchTool(
792 'SPEX (sponsor & exhibitor) knowledge: packages, logistics and commercial details.',
793 [scopeKeyToIndexName('sales-agent-spex')],
794 ),
795 mediaCatalogue: makeSearchTool(
796 'Unleash content catalogue: podcasts, webinars, reports, videos and explicitly requested archival articles by topic. For article recommendations ALWAYS use recentArticles instead: this similarity index does not rank by publication date. Results include the item URL (slug) — always share it when suggesting content. Contains PAST webinars too — for upcoming/next webinar questions use the upcomingWebinars tool instead.',
797 [
798 {
799 indexName: mediaIndexName(),
800 filter: mediaTypeFilter(
801 options.history.findLast((turn) => turn.role === 'user')?.text || '',
802 ),
803 },
804 ],
805 ),
806 siteMap: makeSearchTool(
807 'Index of every page on unleash.ai with its URL, including the full on-page text of the event pages (/events/...). Use to point the visitor to the exact right page, and to answer from what an event page actually says (passes, experiences, policies, logistics).',
808 [sitemapIndexName()],
809 ),
810 }
811
812 // Deterministic badge / pass lookup: the badge pages read straight from the
813 // CMS, so a ticket question always gets every badge with its inclusions and
814 // published prices instead of whichever FAQ chunk similarity search ranks first.
815 const badgesLookup = tool({
816 description:
817 "The complete list of badges / passes / ticket types for an event, read directly from the event's Badges and Attend pages: each badge's name, who it is for, what it includes, eligibility notes, group/loyalty packages and verified structured pricing, with the URL of each badge's own page. ALWAYS call this for any ticket, pass, badge, pricing, registration or group-booking question before answering — Individual prices MUST come only from the structured pricing field, never SEO descriptions, page excerpts, other tools or history. Explorer is application-only: say Price on application and never quote a numeric Explorer price, including amounts from history. If pricing is empty, say the price is not verified; do not estimate.",
818 inputSchema: jsonSchema<{ event?: 'paris' | 'miami' }>({
819 type: 'object',
820 properties: {
821 event: {
822 type: 'string',
823 enum: ['paris', 'miami'],
824 description:
825 'Which event. Defaults to the event of the page the visitor is on.',
826 },
827 },
828 }),
829 execute: async ({ event }) => {
830 const eventSlug =
831 event === 'miami' || (!event && eventScope === 'america')
832 ? '/events/unleash-miami'
833 : event === 'paris' || (!event && eventScope === 'world')
834 ? '/events/unleash-paris'
835 : null
836 if (!eventSlug) {
837 return {
838 error:
839 'No event selected. Ask which event (UNLEASH in Miami or UNLEASH in Paris) and call again with event set.',
840 }
841 }
842 try {
843 const lookup = await loadBadgePages(eventSlug)
844 if (lookup.pages.length === 0) {
845 return { error: 'No badge pages are published for this event yet.' }
846 }
847 return lookup
848 } catch (error) {
849 const message =
850 error instanceof Error ? error.message : 'Badge lookup failed'
851 console.warn('[ai] badge lookup failed', message)
852 return { error: message }
853 }
854 },
855 })
856
857 // Deterministic page directory: the complete list of an event's pages with
858 // what each covers, so "where do I find X" is answered by picking the most
859 // specific page from the full list rather than from six similarity hits.
860 const pageDirectory = tool({
861 description:
862 'Complete directory of every published page of an event: title, url, description, what the page covers (its section headings), and published links (labeled external booking buttons). For hotel/accommodation recommendations and booking questions ALWAYS call this tool and use the hotel booking destination in links directly; the internal travel page is optional supporting information, not a replacement for the booking link. ALWAYS use this for "where can I find…", "is there a page about…", "which page…" questions and whenever you are about to link a page — then link the MOST SPECIFIC page whose coverage matches (a subpage over its hub over the event root). Read-only and complete: if a topic is not in the directory, no page exists for it.',
863 inputSchema: jsonSchema<{ event?: 'paris' | 'miami' }>({
864 type: 'object',
865 properties: {
866 event: {
867 type: 'string',
868 enum: ['paris', 'miami'],
869 description:
870 'Which event. Defaults to the event of the page the visitor is on.',
871 },
872 },
873 }),
874 execute: async ({ event }) => {
875 const eventSlug =
876 event === 'miami' || (!event && eventScope === 'america')
877 ? '/events/unleash-miami'
878 : event === 'paris' || (!event && eventScope === 'world')
879 ? '/events/unleash-paris'
880 : null
881 if (!eventSlug) {
882 return {
883 error:
884 'No event selected. Ask which event (UNLEASH in Miami or UNLEASH in Paris) and call again with event set.',
885 }
886 }
887 try {
888 return { event: eventSlug, pages: await loadEventPageDirectory(eventSlug) }
889 } catch (error) {
890 const message =
891 error instanceof Error ? error.message : 'Page directory failed'
892 console.warn('[ai] page directory failed', message)
893 return { error: message }
894 }
895 },
896 })
897
898 const buildAgenda = tool({
899 description: `Build a validated personal agenda around the five decision pillars: ${JSON.stringify(DECISION_PILLARS)}. Ask which pillars the visitor wants if unknown; never assume all five from a bare yes. Pass selected canonical IDs in decisionPillars. Keep any explicit free-text interest in topics. Return the plan without changing times or adding sessions; show excluded alternatives separately.`,
900 inputSchema: jsonSchema<{
901 event?: 'paris' | 'miami'
902 decisionPillars?: string[]
903 topics: string
904 day?: string
905 }>({
906 type: 'object',
907 properties: {
908 event: { type: 'string', enum: ['paris', 'miami'] },
909 topics: { type: 'string' },
910 decisionPillars: {
911 type: 'array',
912 items: {
913 type: 'string',
914 enum: DECISION_PILLARS.map((pillar) => pillar.id),
915 },
916 },
917 day: { type: 'string', description: 'Optional YYYY-MM-DD' },
918 },
919 required: ['topics'],
920 }),
921 execute: async ({ event, topics, decisionPillars, day }) => {
922 const selected =
923 eventScope === 'world'
924 ? 'paris'
925 : eventScope === 'america'
926 ? 'miami'
927 : event
928 if (!selected) return { error: 'Ask which event: Paris or Miami.' }
929 try {
930 const scope = selected === 'paris' ? 'event-world' : 'event-america'
931 const [sessions, page] = await Promise.all([
932 loadAgendaSessions(scope),
933 loadAgendaPage(scope),
934 ])
935 const pillars = parseDecisionPillars(
936 decisionPillars || userProfile?.decisionPillars || [],
937 )
938 const plan = createAgendaPlan(
939 selected,
940 sessions,
941 page,
942 pillars.length ? pillars.join('; ') : topics,
943 requestedAgendaDay(sessions, options.history, day),
944 )
945 if (!plan.sessions.length)
946 return {
947 error:
948 'No matching public sessions are available. Ask whether the visitor wants to try other interests.',
949 }
950 options.onPlan(
951 plan.text +
952 (plan.alternatives.length
953 ? '\n\n**Alternatives — these conflict with the plan above; choose these instead of the overlapping session, not in addition:**\n\n' +
954 plan.alternatives
955 .map(
956 (session) =>
957 `- ${session.day}, ${session.start.slice(11, 16)}–${session.end.slice(11, 16)} — [${session.title}](${session.url}) — ${session.room}`,
958 )
959 .join('\n')
960 : '') +
961 (aiToolEnabled(options.capabilities, 'sendAgendaByEmail')
962 ? '\n\nWould you like me to email your selected agenda?'
963 : ''),
964 )
965 return plan
966 } catch {
967 return {
968 error:
969 'The agenda is temporarily unavailable. Ask the visitor to try again shortly; do not invent sessions.',
970 }
971 }
972 },
973 })
974
975 // Sales surfaces get a commercially-scoped toolset; event pages lose the
976 // other event's sources; everything else sees the full registry.
977 if (type === 'sales-agent' || type === 'sales-agent-spex') {
978 return {
979 unleashSales: shared.unleashSales,
980 unleashSalesSpex: shared.unleashSalesSpex,
981 generalKnowledge: shared.generalKnowledge,
982 eventFacts: shared.eventFacts,
983 siteMap: shared.siteMap,
984 ...(options.consentGiven ? { submitLead } : {}),
985 }
986 }
987 if (eventScope === 'america')
988 return {
989 ...america,
990 ...shared,
991 agendaLookup,
992 badgesLookup,
993 pageDirectory,
994 buildAgenda,
995 ...(options.consentGiven ? { submitLead, sendAgendaByEmail } : {}),
996 }
997 if (eventScope === 'world')
998 return {
999 ...world,
1000 ...shared,
1001 agendaLookup,
1002 badgesLookup,
1003 pageDirectory,
1004 buildAgenda,
1005 ...(options.consentGiven ? { submitLead, sendAgendaByEmail } : {}),
1006 }
1007 return {
1008 ...america,
1009 ...world,
1010 ...shared,
1011 agendaLookup,
1012 badgesLookup,
1013 pageDirectory,
1014 buildAgenda,
1015 ...(options.consentGiven ? { submitLead, sendAgendaByEmail } : {}),
1016 }
1017}
1018
1019/**
1020 * Confirmed run dates from the Sanity event records, phrased so the prompt's
1021 * "only assert dates a source returned" guardrail treats them as
1022 * source-confirmed. Injected in code so it also applies when the concierge
1023 * prompt is overridden in Sanity.
1024 */
1025function eventDatesLine(
1026 eventScope: EventScope,
1027 eventDates: EventDates,
1028): string | null {
1029 if (eventScope === 'america') {
1030 if (!eventDates.america) return null
1031 return `[CONFIRMED DATES: UNLEASH in Miami runs ${eventDates.america}. This comes from the official event record and counts as a source-confirmed fact — state it plainly when dates are relevant; never tell the visitor to check the website for the dates.]`
1032 }
1033 if (eventScope === 'world') {
1034 if (!eventDates.world) return null
1035 return `[CONFIRMED DATES: UNLEASH in Paris runs ${eventDates.world}. This comes from the official event record and counts as a source-confirmed fact — state it plainly when dates are relevant; never tell the visitor to check the website for the dates.]`
1036 }
1037 const known: string[] = []
1038 if (eventDates.america)
1039 known.push(`UNLEASH in Miami runs ${eventDates.america}`)
1040 if (eventDates.world) known.push(`UNLEASH in Paris runs ${eventDates.world}`)
1041 if (known.length === 0) return null
1042 return `[CONFIRMED DATES: ${known.join('; ')}. These come from the official event records and count as source-confirmed facts — but the clarify-first rule above still applies: only state an event's dates once the visitor has made clear which event they mean.]`
1043}
1044
1045function buildRoutingContext(options: {
1046 eventScope: EventScope
1047 location: string
1048 type: ChatMessageType
1049 browserProfile: string[]
1050 userProfile: UserProfile | undefined
1051 eventDates: EventDates
1052 /** The visitor just said yes to "Would you like our sales team to get in touch?" */
1053}): string {
1054 const { eventScope, location, type } = options
1055 const lines: string[] = ['', '---', 'Context for this conversation:']
1056 if (eventScope === 'america') {
1057 lines.push(
1058 '[ROUTING: Visitor is on the UNLEASH in Miami event page (legacy name Unleash America — call it UNLEASH in Miami; any "Las Vegas" venue in sources is a stale past edition, never present it as current). Treat every question as Miami-scoped unless the visitor explicitly asks about UNLEASH in Paris. Vague openers ("what will I learn?", "what do you offer?", "tell me more") are about THIS EVENT — lead with the event\'s agenda themes, tracks, and learning outcomes from the event sources, not a general description of Unleash the platform. When the visitor does ask about Paris: answer only what General/FAQ sources return for Paris, or say that Paris detail has not been published yet — NEVER fill the gap with Miami venue, parking, dates, or passes.]',
1059 )
1060 } else if (eventScope === 'world') {
1061 lines.push(
1062 '[ROUTING: Visitor is on the UNLEASH in Paris event page (legacy name UNLEASH WORLD — call it UNLEASH in Paris, never a separate "Paris event"). Treat every question as Paris-scoped unless the visitor explicitly asks about UNLEASH in Miami. Vague openers ("what will I learn?", "what do you offer?", "tell me more") are about THIS EVENT — lead with the event\'s agenda themes, tracks, and learning outcomes from the event sources, not a general description of Unleash the platform. When the visitor does ask about Miami: answer only what General/FAQ sources return for Miami, or say that Miami detail has not been published yet — NEVER fill the gap with Paris venue, pavilion, parking, dates, or passes (Porte de Versailles / Pavilion 7 / Parking P7 are Paris-only).]',
1063 )
1064 } else {
1065 lines.push(
1066 '[ROUTING: No event page in context. For any event-specific question (tickets, passes, speakers, agenda, hotels, logistics), your entire reply must be the clarifying question "Which event are you asking about — UNLEASH in Miami or UNLEASH in Paris?" — no speakers, sessions, passes, or dates for either event until they choose. Legacy names: Unleash America → UNLEASH in Miami; UNLEASH WORLD → UNLEASH in Paris ("Las Vegas" in sources is a stale past edition, never present it as current).]',
1067 )
1068 }
1069 const datesLine = eventDatesLine(eventScope, options.eventDates)
1070 if (datesLine) lines.push(datesLine)
1071 lines.push(`- Page: ${location}`)
1072 lines.push(`- Chat surface: ${type}`)
1073 lines.push(
1074 '- Contact details and consent are collected conversationally; never refer to interface controls.',
1075 )
1076 if (options.browserProfile.length > 0) {
1077 lines.push(`- Browser signals: ${options.browserProfile.join(', ')}`)
1078 }
1079 lines.push(formatProfileTable(options.userProfile))
1080 return lines.join('\n')
1081}
1082
1083export async function POST(event: APIEvent) {
1084 const capture: ReviewCapture = { toolEvidence: [], evidenceComplete: true }
1085 const policy: { email: SupportEmail } = { email: 'support@unleash.ai' }
1086 const response = await runChatRequest(event, capture, policy)
1087 if (
1088 !response.ok ||
1089 !response.body ||
1090 !response.headers.get('Content-Type')?.includes('text/plain')
1091 )
1092 return monitorChatResponse(response, capture)
1093 const headers = new Headers(response.headers)
1094 headers.delete('Content-Length')
1095 return monitorChatResponse(
1096 new Response(
1097 response.body
1098 .pipeThrough(responsePolicyStream(policy.email))
1099 .pipeThrough(markdownDeliveryStream()),
1100 {
1101 status: response.status,
1102 headers,
1103 },
1104 ),
1105 capture,
1106 )
1107}
1108
1109async function runChatRequest(
1110 { request }: APIEvent,
1111 reviewCapture: ReviewCapture,
1112 policy: { email: SupportEmail },
1113) {
1114 initAiTelemetry()
1115 let body: ChatPayload
1116 try {
1117 body = (await request.json()) as ChatPayload
1118 } catch {
1119 return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
1120 }
1121 if (!body || typeof body !== 'object' || Array.isArray(body))
1122 return Response.json({ error: 'Invalid request body' }, { status: 400 })
1123
1124 const message =
1125 typeof body.message === 'string'
1126 ? normalizeEmailEscapes(body.message.trim())
1127 : undefined
1128 if (!message) {
1129 return Response.json({ error: 'Missing message' }, { status: 400 })
1130 }
1131 if (message.length > MAX_MESSAGE_LENGTH) {
1132 return Response.json({ error: 'Message is too long' }, { status: 400 })
1133 }
1134
1135 const type = body.type
1136 if (
1137 type !== 'chat' &&
1138 type !== 'global' &&
1139 type !== 'agenda' &&
1140 type !== 'sales-agent' &&
1141 type !== 'sales-agent-spex'
1142 ) {
1143 return Response.json(
1144 {
1145 error:
1146 'Invalid type. Expected "chat", "global", "agenda", "sales-agent", or "sales-agent-spex"',
1147 },
1148 { status: 400 },
1149 )
1150 }
1151 const chatType = type as ChatMessageType
1152
1153 const location =
1154 typeof body.location === 'string'
1155 ? body.location.trim().slice(0, 500)
1156 : undefined
1157 if (!location) {
1158 return Response.json({ error: 'Missing location' }, { status: 400 })
1159 }
1160
1161 const chatId =
1162 typeof body.chat_id === 'string'
1163 ? body.chat_id.trim().slice(0, 100)
1164 : undefined
1165 if (!chatId) {
1166 return Response.json({ error: 'Missing chat_id' }, { status: 400 })
1167 }
1168
1169 // Pre-LLM guards: rate limit, duplicate-message soft block, injection
1170 // patterns — all answered before any model or retrieval work happens.
1171 const guard = checkAiGuards(request, chatId, message, [])
1172 if (guard.kind === 'rate-limited') {
1173 return Response.json(
1174 { error: 'Too many messages. Please wait a moment and try again.' },
1175 { status: 429 },
1176 )
1177 }
1178 if (guard.kind === 'soft-block' || guard.kind === 'injection') {
1179 return guardTextResponse(guard.reply)
1180 }
1181
1182 const browserProfile =
1183 Array.isArray(body.profile) &&
1184 body.profile.every((item) => typeof item === 'string')
1185 ? body.profile
1186 .slice(0, 10)
1187 .map((item) => item.trim().slice(0, 100))
1188 .filter(Boolean)
1189 : undefined
1190 if (!browserProfile) {
1191 return Response.json(
1192 { error: 'Invalid profile. Expected an array of strings' },
1193 { status: 400 },
1194 )
1195 }
1196
1197 const interests =
1198 Array.isArray(body.interests) &&
1199 body.interests.every((item) => typeof item === 'string')
1200 ? body.interests
1201 .slice(0, 20)
1202 .map((item) => item.trim().slice(0, 100))
1203 .filter(Boolean)
1204 : undefined
1205 if (!interests) {
1206 return Response.json(
1207 { error: 'Invalid interests. Expected an array of strings' },
1208 { status: 400 },
1209 )
1210 }
1211
1212 const userProfile = parseUserProfile(body.userProfile)
1213
1214 let historyCharacters = 0
1215 const history = Array.isArray(body.history)
1216 ? body.history
1217 .filter(
1218 (
1219 item,
1220 ): item is {
1221 role: 'assistant' | 'user'
1222 text: string
1223 } =>
1224 !!item &&
1225 typeof item === 'object' &&
1226 ((item as { role?: unknown }).role === 'assistant' ||
1227 (item as { role?: unknown }).role === 'user') &&
1228 typeof (item as { text?: unknown }).text === 'string',
1229 )
1230 .map((item) => ({
1231 role: item.role,
1232 // Bound input tokens even if a client sends oversized history entries.
1233 text: normalizeEmailEscapes(
1234 item.text
1235 .trim()
1236 .slice(0, item.role === 'assistant' ? 24000 : MAX_MESSAGE_LENGTH),
1237 ),
1238 }))
1239 .filter((item) => item.text.length > 0)
1240 .slice(-20)
1241 .reverse()
1242 .filter((turn) => {
1243 historyCharacters += turn.text.length
1244 return historyCharacters <= 80000
1245 })
1246 .reverse()
1247 : []
1248
1249 const repeatGuard = checkRepeatedExchange(history, message)
1250 if (repeatGuard) return guardTextResponse(repeatGuard)
1251
1252 reviewCapture.input = {
1253 message,
1254 profileContext: JSON.stringify({
1255 company: userProfile?.company,
1256 role: userProfile?.role,
1257 interests: userProfile?.interests,
1258 nameKnown: Boolean(userProfile?.name),
1259 emailKnown: Boolean(userProfile?.email),
1260 }),
1261 history: history
1262 .filter(
1263 (turn, i) =>
1264 !(
1265 i === history.length - 1 &&
1266 turn.role === 'user' &&
1267 turn.text === message
1268 ),
1269 )
1270 .slice(-6)
1271 .map((turn) => ({ ...turn, text: turn.text.slice(0, 4000) })),
1272 location,
1273 }
1274 reviewCapture.sessionId = chatId
1275
1276 policy.email = supportEmail(message)
1277
1278 const capabilities = await getAiCapabilities()
1279 const leadEnabled = aiToolEnabled(capabilities, 'submitLead')
1280 const agendaEmailEnabled = aiToolEnabled(capabilities, 'sendAgendaByEmail')
1281 const agendaEnabled = aiToolEnabled(capabilities, 'buildAgenda')
1282 const selectorEnabled = process.env.AI_JEV_FLOW_SELECTOR === 'active'
1283 const flowEvent =
1284 resolveConversationEvent(location, message, history, body.selectedEvent) ||
1285 'unknown'
1286 // Existing workflow guards own cancellation. Clear routing state before returning.
1287 if (isHandoffCancellation(history, message)) {
1288 if (selectorEnabled) await saveFlowSession(chatId, null, flowEvent)
1289 return guardTextResponse(
1290 'Understood — I’ve cancelled the contact request. We won’t send it or ask for your contact details.',
1291 )
1292 }
1293 const lastReply = history.findLast((turn) => turn.role === 'assistant')?.text
1294 if (
1295 lastReply?.includes(HANDOFF_FAILURE_REPLY) &&
1296 /^(?:thanks|thank you|ok(?:ay)?|understood|got it)[.!\s]*$/i.test(
1297 message.trim(),
1298 )
1299 )
1300 return guardTextResponse(
1301 'You’re welcome. Please contact support@unleash.ai if you need help with the request.',
1302 )
1303 const policyAnswer = policyReply(message, history)
1304 if (policyAnswer) return guardTextResponse(policyAnswer)
1305 if (isConversationClosing(message))
1306 return guardTextResponse(
1307 'You’re welcome. Let me know if you need anything else.',
1308 )
1309 if (isSelfServiceApplication(message))
1310 return guardTextResponse(
1311 'You can continue with the application on that page. Applying there doesn’t submit a separate enquiry through this chat. Let me know if you have another question.',
1312 )
1313 if (asksRestrictedSessionAccess(message, history))
1314 return guardTextResponse(
1315 'Invitation-only sessions require separate approval or an invitation. Buying or upgrading a badge does not confirm access to them. Please contact [support@unleash.ai](mailto:support@unleash.ai) with the session name for access questions.',
1316 )
1317 const flowSession = selectorEnabled
1318 ? await loadFlowSession(chatId, flowEvent)
1319 : null
1320 const activeWorkflow = availableWorkflow(
1321 flowSession?.workflow ?? null,
1322 capabilities,
1323 )
1324 if (flowSession?.workflow && !activeWorkflow)
1325 await saveFlowSession(chatId, null, flowEvent)
1326 const unavailable = disabledWorkflowReply(capabilities, message, history)
1327 if (unavailable) {
1328 policy.email = unavailable.includes('customersuccess@unleash.ai')
1329 ? 'customersuccess@unleash.ai'
1330 : 'support@unleash.ai'
1331 return guardTextResponse(unavailable)
1332 }
1333 let selection = selectorEnabled
1334 ? await selectFlow(
1335 {
1336 message,
1337 history,
1338 page: location,
1339 active: activeWorkflow,
1340 chatId,
1341 },
1342 request.signal,
1343 )
1344 : undefined
1345 if (leadEnabled && requestsTicketBooking(message, history))
1346 selection = {
1347 destination: 'tickets',
1348 active: 'tickets',
1349 source: activeWorkflow ? 'change' : 'entry',
1350 }
1351 if (
1352 selection &&
1353 (isContentInformationRequest(message) ||
1354 isCommercialLogisticsQuestion(message) ||
1355 isCommercialInformationRequest(message)) &&
1356 !requestsTicketBooking(message, history)
1357 )
1358 selection = {
1359 destination: 'information',
1360 active: activeWorkflow,
1361 source: activeWorkflow ? 'change' : 'entry',
1362 }
1363 // Published capability changes also stop workflows persisted by older turns.
1364 if (
1365 selection &&
1366 ((!leadEnabled &&
1367 (selection.destination === 'tickets' || selection.destination === 'spex')) ||
1368 (!agendaEnabled && selection.destination === 'agenda'))
1369 ) {
1370 selection = { destination: 'information', active: null, source: 'change' }
1371 }
1372 if (selection)
1373 selection = {
1374 ...selection,
1375 active: availableWorkflow(selection.active, capabilities),
1376 }
1377 policy.email = supportEmail(
1378 message,
1379 selection?.source !== 'fallback' && selection?.destination === 'spex',
1380 )
1381 if (selection) waitUntil(flushAiTelemetry())
1382 if (selection && selection.source !== 'fallback') {
1383 await saveFlowSession(chatId, selection.active, flowEvent)
1384 }
1385 const classifierMode = selectorEnabled
1386 ? 'off'
1387 : routingMode(process.env.AI_JEV_ROUTING_MODE)
1388 let classifierDecision: RoutingDecision | undefined
1389 if (classifierMode !== 'off') {
1390 const state = buildClassifierState({
1391 message,
1392 history,
1393 page: location,
1394 activeWorkflow: isTicketSalesConversation(history)
1395 ? 'tickets'
1396 : chatType === 'sales-agent-spex'
1397 ? 'spex'
1398 : chatType === 'agenda'
1399 ? 'agenda'
1400 : 'unknown',
1401 knownProfileFields: Object.entries(userProfile ?? {})
1402 .filter(
1403 ([, value]) =>
1404 value !== undefined &&
1405 value !== '' &&
1406 (!Array.isArray(value) || value.length > 0),
1407 )
1408 .map(([key]) => key),
1409 })
1410 const classification = classifyRouting(
1411 state,
1412 chatId,
1413 classifierMode === 'active' ? request.signal : undefined,
1414 )
1415 if (classifierMode === 'active') {
1416 classifierDecision = await classification
1417 waitUntil(flushAiTelemetry())
1418 } else waitUntil(classification.finally(flushAiTelemetry))
1419 }
1420 const semanticSalesStart =
1421 leadEnabled &&
1422 selection?.destination === 'spex' &&
1423 selection.source === 'entry'
1424 const semanticTicketStart =
1425 leadEnabled &&
1426 (selection?.destination === 'tickets' ||
1427 (classifierDecision?.status === 'accepted' &&
1428 classifierDecision.ticketStart))
1429 const semanticInformation =
1430 isCommercialLogisticsQuestion(message) ||
1431 selection?.destination === 'information' ||
1432 (classifierDecision?.status === 'accepted' &&
1433 classifierDecision.informationTurn)
1434
1435 if (isHandoffCancellation(history, message))
1436 return guardTextResponse(
1437 'Understood — I’ve cancelled the contact request. We won’t send it or ask for your contact details.',
1438 )
1439 const handoffHistory = historyAfterHandoffCancellation(history)
1440 const ticketHistory = [
1441 ...handoffHistory,
1442 { role: 'user' as const, text: message },
1443 ]
1444 const ticketBookingActive =
1445 leadEnabled &&
1446 (selection && selection.source !== 'fallback'
1447 ? selection.destination === 'tickets'
1448 : isTicketSalesConversation(ticketHistory) &&
1449 ticketHistory.some(
1450 (turn, i) =>
1451 turn.role === 'user' &&
1452 (requestsTicketBooking(turn.text, ticketHistory.slice(0, i)) ||
1453 acceptedSalesOffer(ticketHistory.slice(0, i), turn.text)),
1454 ))
1455
1456 const consentGiven = sessionConsent(history, message, body.consent === true)
1457 const selectedEvent = resolveConversationEvent(
1458 location,
1459 message,
1460 history,
1461 body.selectedEvent,
1462 )
1463 const person = speakingPerson(message)
1464 const namedFact = specificKnowledgeFact(message)
1465 if (namedFact && aiToolEnabled(capabilities, 'generalKnowledge')) {
1466 try {
1467 const stores: Array<string | StoreQuery> = [scopeKeyToIndexName('general')]
1468 if (selectedEvent)
1469 stores.push(
1470 scopeKeyToIndexName(
1471 selectedEvent === 'paris' ? 'event-world' : 'event-america',
1472 ),
1473 )
1474 if (aiToolEnabled(capabilities, 'siteMap'))
1475 stores.push({
1476 indexName: sitemapIndexName(),
1477 ...(selectedEvent
1478 ? { filter: { eventSlug: { $eq: `/events/unleash-${selectedEvent}` } } }
1479 : {}),
1480 })
1481 const matches = await queryPineconeStores(stores, message, 12)
1482 if (
1483 !matches.some((match) =>
1484 supportsSpecificKnowledgeFact(namedFact, match.text),
1485 )
1486 )
1487 return guardTextResponse(
1488 'I can’t confirm that specific detail from the current published information. Please contact [support@unleash.ai](mailto:support@unleash.ai) to check it.',
1489 )
1490 } catch (error) {
1491 console.warn(
1492 '[ai] named fact lookup unavailable',
1493 error instanceof Error ? error.message : 'Unknown error',
1494 )
1495 return guardTextResponse(
1496 'I can’t verify that specific detail right now. Please contact [support@unleash.ai](mailto:support@unleash.ai) to check it.',
1497 )
1498 }
1499 }
1500 if (person && selectedEvent && aiToolEnabled(capabilities, 'agendaLookup')) {
1501 try {
1502 const scope = selectedEvent === 'paris' ? 'event-world' : 'event-america'
1503 const result = summarizeAgenda(await loadAgendaSessions(scope), {
1504 speaker: person,
1505 })
1506 if (result.speakerLookup?.listed === false) {
1507 const pages = aiToolEnabled(capabilities, 'pageDirectory')
1508 ? await loadEventPageDirectory(`/events/unleash-${selectedEvent}`)
1509 : []
1510 const speakers = pages.find((page) => page.url.endsWith('/speakers'))
1511 return guardTextResponse(
1512 `I can’t confirm whether ${person} is speaking.${speakers ? ` Check [${speakers.title}](${speakers.url}) for current announcements.` : ' Please contact support@unleash.ai for current announcements.'}`,
1513 )
1514 }
1515 } catch (error) {
1516 console.warn(
1517 '[ai] speaker status unavailable',
1518 error instanceof Error ? error.message : 'Unknown error',
1519 )
1520 }
1521 }
1522 if (
1523 selectedEvent &&
1524 aiToolEnabled(capabilities, 'agendaLookup') &&
1525 /^(?:(?:can|could) you |please )?(?:show(?: me)? |where (?:can I (?:see|find)|is) )(?:the )?(?:full )?agenda[?.!\s]*$/i.test(
1526 message.trim(),
1527 )
1528 ) {
1529 try {
1530 const page = await loadAgendaPage(
1531 selectedEvent === 'paris' ? 'event-world' : 'event-america',
1532 )
1533 if (page)
1534 return guardTextResponse(
1535 `You can view the [full agenda page](${page}). The programme may be updated.`,
1536 )
1537 } catch (error) {
1538 console.warn(
1539 '[ai] agenda page unavailable',
1540 error instanceof Error ? error.message : 'Unknown error',
1541 )
1542 }
1543 }
1544 if (
1545 selectedEvent &&
1546 aiToolEnabled(capabilities, 'agendaLookup') &&
1547 /\b(?:what(?:['’]s| is)?|show|list)\b.{0,55}\bstage\s+\d+\b/i.test(message)
1548 ) {
1549 try {
1550 const scope = selectedEvent === 'paris' ? 'event-world' : 'event-america'
1551 const reply = stageListingReply(message, await loadAgendaSessions(scope))
1552 if (reply) return guardTextResponse(reply)
1553 } catch (error) {
1554 console.warn(
1555 '[ai] stage listing unavailable',
1556 error instanceof Error ? error.message : 'Unknown error',
1557 )
1558 }
1559 }
1560 if (!selectedEvent && /\b(?:refund|cancellation|transfer)\b/i.test(message))
1561 return guardTextResponse(
1562 'Refunds, cancellations and ticket transfers are subject to our [Terms & Conditions](/terms-and-conditions). Please contact [support@unleash.ai](mailto:support@unleash.ai) with your booking reference and event name so our team can help with your request.',
1563 )
1564
1565 if (
1566 selectedEvent &&
1567 aiToolEnabled(capabilities, 'agendaLookup') &&
1568 /\b(?:who (?:are|is)(?: the)?(?: current)? (?:keynote|featured) speakers?|who\b.{0,30}\bkeynotes?|(?:list|show|name)(?: me)?(?: the)?(?: current)? keynote speakers?)\b/i.test(
1569 message,
1570 )
1571 ) {
1572 try {
1573 const scope = selectedEvent === 'paris' ? 'event-world' : 'event-america'
1574 const sessions = (await loadAgendaSessions(scope)).filter(
1575 (session) => session.format.trim().toLowerCase() === 'keynote',
1576 )
1577 const listed = sessions.filter((session) => session.speakers.trim())
1578 if (listed.length)
1579 return guardTextResponse(
1580 `The currently listed keynote speakers are:\n\n${listed.map((session) => `- **${session.speakers}** — ${session.url ? `[${session.title}](${session.url})` : session.title}`).join('\n')}`,
1581 )
1582 return guardTextResponse(
1583 'No keynote speakers are currently listed in the published agenda. Please check the agenda again for updates.',
1584 )
1585 } catch (error) {
1586 console.warn(
1587 '[ai] keynote roster unavailable',
1588 error instanceof Error ? error.message : 'Unknown error',
1589 )
1590 }
1591 }
1592 const factTopic = eventFactTopic(message, history)
1593 if (factTopic && selectedEvent && aiToolEnabled(capabilities, 'eventFacts')) {
1594 try {
1595 const facts = await loadEventFacts(selectedEvent, factTopic)
1596 let reply = eventFactReply(factTopic, facts.answers, message)
1597 if (factTopic === 'startupAward') {
1598 policy.email = 'customersuccess@unleash.ai'
1599 const award = facts.pages.find((page) =>
1600 page.url.endsWith('/startup-award'),
1601 )
1602 return guardTextResponse(
1603 award
1604 ? startupAwardReply(award)
1605 : 'I can’t confirm current Startup Award details. Please contact [customersuccess@unleash.ai](mailto:customersuccess@unleash.ai).',
1606 )
1607 }
1608 if (
1609 reply &&
1610 (factTopic === 'onsiteRegistration' || factTopic === 'registration')
1611 ) {
1612 const registration = facts.pages.find((page) =>
1613 page.url.endsWith('/attend'),
1614 )
1615 if (registration)
1616 reply += `\n\nYou can register in advance on [${registration.title}](${registration.url}).`
1617 }
1618 if (
1619 factTopic === 'startupEligibility' ||
1620 factTopic === 'startupOptions' ||
1621 factTopic === 'startupInclusions'
1622 ) {
1623 const startup = facts.pages.find((page) =>
1624 /startup-networking-package$/.test(page.url),
1625 )
1626 if (startup)
1627 reply = startupFactReply(factTopic, startup, message, history, reply)
1628 policy.email = 'customersuccess@unleash.ai'
1629 }
1630 if (factTopic === 'hrLeaderEligibility')
1631 reply = `The HR professionals route is for employer-side HR practitioners; being a vendor or solution provider does not establish eligibility. Applications are reviewed by the team, so I can’t approve a pass here.\n\n${reply || ''}`
1632 if (factTopic === 'explorerApplication') {
1633 const explorer = facts.pages.find((page) =>
1634 page.url.endsWith('/attend/explorer'),
1635 )
1636 if (explorer)
1637 reply = `Explorer is application-only, with limited capacity and allocation based on seniority. Applying is not confirmation that your place is approved. I can’t check your application or confirm a review timeline here.\n\nFor the next step or status, contact [support@unleash.ai](mailto:support@unleash.ai) with the email you applied with. See [${explorer.title}](${explorer.url}).`
1638 }
1639 if (reply && factTopic === 'discounts') {
1640 const attend = facts.pages.find((page) => page.url.endsWith('/attend'))
1641 if (attend)
1642 reply += `\n\nCheck [${attend.title}](${attend.url}) for current offers and registration options.`
1643 }
1644 if (!reply && factTopic === 'discounts') {
1645 const attend = facts.pages.find((page) => page.url.endsWith('/attend'))
1646 return guardTextResponse(
1647 `I can’t confirm a currently available early-booking or promotional offer, price or deadline.${attend ? ` Check [${attend.title}](${attend.url}) for current offers.` : ''} For help, contact [support@unleash.ai](mailto:support@unleash.ai).`,
1648 )
1649 }
1650 if (reply) return guardTextResponse(reply)
1651 if (factTopic === 'recordingAccess') {
1652 const publishedPolicy = recordingPolicyReply(facts.pages)
1653 if (publishedPolicy) return guardTextResponse(publishedPolicy)
1654 return guardTextResponse(
1655 `I can’t confirm the current recording-access policy. Please contact [support@unleash.ai](mailto:support@unleash.ai) about access for your pass; I won’t assume recordings are included or quote an unverified release date.`,
1656 )
1657 }
1658 if (factTopic === 'floorplan')
1659 return guardTextResponse(
1660 `For event floorplan information, please contact [${policy.email}](mailto:${policy.email}).`,
1661 )
1662 if (factTopic === 'parking') {
1663 const travel = facts.pages.find((page) =>
1664 page.url.endsWith('/plan-your-visit'),
1665 )
1666 return guardTextResponse(
1667 `I can’t confirm attendee parking availability from the current information.${travel ? ` See [${travel.title}](${travel.url}) for venue and travel guidance.` : ''} Please contact [support@unleash.ai](mailto:support@unleash.ai) to check before travelling.`,
1668 )
1669 }
1670 if (factTopic === 'openingHours') {
1671 const agenda = facts.pages.find((page) =>
1672 /\/agenda(?:-schedule)?$/.test(page.url),
1673 )
1674 return guardTextResponse(
1675 `I can’t confirm the venue opening time from the current information. Session start times are different from doors opening.${agenda ? ` Check [${agenda.title}](${agenda.url}) for the session schedule.` : ''} Contact [support@unleash.ai](mailto:support@unleash.ai) to confirm arrival times.`,
1676 )
1677 }
1678 if (factTopic === 'volunteering' && !facts.pages.length)
1679 return guardTextResponse(
1680 `I can’t confirm the current volunteer roles from the published information. Please contact [support@unleash.ai](mailto:support@unleash.ai) for volunteering details.`,
1681 )
1682 } catch (error) {
1683 console.warn(
1684 '[ai] exact event facts unavailable',
1685 error instanceof Error ? error.message : 'Unknown error',
1686 )
1687 }
1688 }
1689
1690 if (
1691 selectedEvent &&
1692 aiToolEnabled(capabilities, 'pageDirectory') &&
1693 asksSponsorshipInformation(message)
1694 ) {
1695 try {
1696 const pages = await loadEventPageDirectory(
1697 `/events/unleash-${selectedEvent}`,
1698 )
1699 const page = pages.find((item) =>
1700 /\/sponsorship-opportunities$/.test(item.url),
1701 )
1702 if (page) {
1703 policy.email = 'customersuccess@unleash.ai'
1704 const launchOptions = /\b(?:launching|launch)\b/i.test(message)
1705 ? (await loadEventFacts(selectedEvent, 'sponsorshipOptions')).answers
1706 .map((answer) => answer.answer)
1707 .join('\n\n')
1708 : ''
1709 return guardTextResponse(
1710 `${/\b(?:cost|price|pricing|square)\b/i.test(message) ? 'I can’t verify a current exhibition-space or sponsorship price for that request.' : 'For launching an HR product, you can explore sponsorship and exhibiting opportunities.'}${launchOptions ? `\n\n${launchOptions}` : ''} See [${page.title}](${page.url}).\n\n${/\b(?:launching|launch)\b/i.test(message) ? 'For your launch, is your priority brand awareness, lead generation, a product launch, or thought leadership?' : 'Would you like our sales team to get in touch?'}`,
1711 )
1712 }
1713 } catch (error) {
1714 console.warn(
1715 '[ai] sponsorship information unavailable',
1716 error instanceof Error ? error.message : 'Unknown error',
1717 )
1718 }
1719 }
1720 if (
1721 ticketBookingActive ||
1722 selection?.destination === 'spex' ||
1723 acceptedAgendaEmail(history, message) ||
1724 agendaEmailInProgress(handoffHistory)
1725 ) {
1726 const emailError = malformedContactEmail(message)
1727 if (emailError) return guardTextResponse(emailError)
1728 }
1729 const suppliedEmail = emailFromMessage(message)
1730 const emailForHandoff = suppliedEmail || userProfile?.email
1731 if (
1732 (leadEnabled || agendaEmailEnabled) &&
1733 emailForHandoff &&
1734 !semanticInformation &&
1735 !isTicketInformationRequest(message, history) &&
1736 ((suppliedEmail &&
1737 suppliedEmail.toLowerCase() === userProfile?.email?.toLowerCase()) ||
1738 ticketBookingActive ||
1739 selection?.destination === 'spex' ||
1740 acceptedAgendaEmail(history, message) ||
1741 confirmedHandoff(history, message))
1742 ) {
1743 const emailError = getBusinessEmailValidationMessage(emailForHandoff)
1744 if (emailError) return guardTextResponse(emailError)
1745 }
1746
1747 if (
1748 aiToolEnabled(capabilities, 'pageDirectory') &&
1749 selectedEvent &&
1750 asksForHotelBooking(message, history)
1751 ) {
1752 try {
1753 const pages = await loadEventPageDirectory(
1754 `/events/unleash-${selectedEvent}`,
1755 )
1756 const reply = hotelBookingReply(pages, selectedEvent)
1757 if (reply) return guardTextResponse(reply)
1758 } catch (error) {
1759 console.warn(
1760 '[ai] direct hotel booking lookup failed',
1761 error instanceof Error ? error.message : 'Unknown error',
1762 )
1763 }
1764 }
1765
1766 if (
1767 (acceptedSalesOffer(history, message) ||
1768 requestsTicketBooking(message, history) ||
1769 /\b(?:tickets?|badges?|passes|agenda|sponsor|exhibit)\b/i.test(message)) &&
1770 !/\b(?:refund|cancel(?:lation)?|transfer|upgrade|confirmation email|invoice|privacy)\b/i.test(
1771 message,
1772 ) &&
1773 !selectedEvent
1774 )
1775 return guardTextResponse(
1776 'Which event are you asking about — UNLEASH in Miami or UNLEASH in Paris?',
1777 )
1778 if (
1779 leadEnabled &&
1780 !semanticInformation &&
1781 (semanticTicketStart ||
1782 semanticSalesStart ||
1783 acceptedSalesOffer(history, message) ||
1784 requestsTicketBooking(message, history)) &&
1785 !userProfile?.company
1786 ) {
1787 if (
1788 semanticSalesStart &&
1789 !semanticTicketStart &&
1790 selectedEvent &&
1791 aiToolEnabled(capabilities, 'pageDirectory')
1792 ) {
1793 try {
1794 const pages = await loadEventPageDirectory(
1795 `/events/unleash-${selectedEvent}`,
1796 )
1797 const sponsorship = pages.find((page) =>
1798 /\/exhibition\/sponsorship-opportunities$/.test(page.url),
1799 )
1800 if (sponsorship)
1801 return guardTextResponse(
1802 `You can explore [${sponsorship.title}](${sponsorship.url}).${selectedEvent === 'paris' && /\b(?:stand|exhibit\w*)\b/i.test(message) ? ' Exhibition-only opportunities are available as well as integrated sponsorship packages; the team can discuss options for your objectives and budget.' : ''} For sponsor or exhibitor help, contact [customersuccess@unleash.ai](mailto:customersuccess@unleash.ai). I can also prepare your enquiry here.\n\nWhat company are you with, and what would you like to achieve through sponsoring or exhibiting?`,
1803 )
1804 } catch (error) {
1805 console.warn(
1806 '[ai] sponsorship introduction unavailable',
1807 error instanceof Error ? error.message : 'Unknown error',
1808 )
1809 }
1810 }
1811 if (
1812 selectedEvent &&
1813 (semanticTicketStart || isTicketSalesConversation(ticketHistory)) &&
1814 aiToolEnabled(capabilities, 'pageDirectory')
1815 ) {
1816 try {
1817 const pages = await loadEventPageDirectory(
1818 `/events/unleash-${selectedEvent}`,
1819 )
1820 const attend = pages.find((page) => page.url.endsWith('/attend'))
1821 if (attend)
1822 return guardTextResponse(
1823 `You can review registration options on [${attend.title}](${attend.url}). I can also prepare a ticket enquiry here.\n\n${userProfile?.role ? 'What company are you with?' : TICKET_QUALIFICATION}`,
1824 )
1825 } catch (error) {
1826 console.warn(
1827 '[ai] registration introduction unavailable',
1828 error instanceof Error ? error.message : 'Unknown error',
1829 )
1830 }
1831 }
1832 return guardTextResponse(
1833 semanticTicketStart || isTicketSalesConversation(ticketHistory)
1834 ? userProfile?.role
1835 ? 'What company are you with?'
1836 : TICKET_QUALIFICATION
1837 : 'What company are you with, and what would you like to achieve through sponsoring or exhibiting?',
1838 )
1839 }
1840
1841 const messages: ModelMessage[] = history.map((item) => ({
1842 role: item.role,
1843 content: item.text,
1844 }))
1845 // Anthropic models require the conversation to open with a user turn; the
1846 // widget's greeting would otherwise put an assistant message first.
1847 while (messages.length > 0 && messages[0].role !== 'user') {
1848 messages.shift()
1849 }
1850 // The client includes the current message as the last history entry; only
1851 // append it if the history was trimmed or omitted.
1852 const last = messages[messages.length - 1]
1853 const appendedCurrentMessage = !(
1854 last?.role === 'user' && last.content === message
1855 )
1856 if (appendedCurrentMessage) {
1857 messages.push({ role: 'user', content: message })
1858 }
1859
1860 const [prompts, eventDates] = await Promise.all([
1861 getAiPrompts(),
1862 getEventDates(),
1863 ])
1864 // Reuse saved details and consent, but confirm this new commercial request.
1865 if (
1866 leadEnabled &&
1867 !semanticInformation &&
1868 selectedEvent &&
1869 (acceptedSalesOffer(history, message) || semanticSalesStart) &&
1870 !isTicketSalesConversation(ticketHistory)
1871 )
1872 return guardTextResponse(
1873 salesAcceptanceReply(
1874 userProfile,
1875 selectedEvent,
1876 consentGiven,
1877 prompts.consent.sponsorship,
1878 commercialRequestDetails(handoffHistory, message),
1879 ),
1880 )
1881 const salesRequestConfirmed =
1882 leadEnabled && consentGiven && confirmedSalesRequest(history, message)
1883 const ticketSummaryResponse = async () => {
1884 if (!selectedEvent)
1885 throw new Error('A ticket summary requires the current event')
1886 const records =
1887 userProfile?.ticketPass && aiToolEnabled(capabilities, 'badgesLookup')
1888 ? await loadBadgePricing(`/events/unleash-${selectedEvent}`).catch(() => [])
1889 : []
1890 const registration = records.find((record) =>
1891 new RegExp(
1892 record.id === 'general'
1893 ? 'general (?:admission|attendee)'
1894 : `\\b${record.id}\\b`,
1895 'i',
1896 ).test(userProfile?.ticketPass || ''),
1897 )
1898 return guardTextResponse(
1899 ticketEnquiryReply(
1900 userProfile,
1901 selectedEvent,
1902 prompts.consent.tickets,
1903 registration,
1904 ),
1905 )
1906 }
1907 if (
1908 /\b(?:what is|what['’]s|what does|what do I get)\b.*\b(?:included|include|with)\b.*\bmy (?:delegate )?(?:ticket|pass|badge)\b/i.test(
1909 message,
1910 ) &&
1911 !/\b(?:general (?:admission|attendee)|explorer|diamond)\b/i.test(message)
1912 )
1913 return guardTextResponse(
1914 'Which badge do you have — General Attendee, Explorer, or Diamond? The inclusions depend on your pass; I can’t infer it from “delegate ticket”.',
1915 )
1916 if (
1917 aiToolEnabled(capabilities, 'badgesLookup') &&
1918 selectedEvent &&
1919 !confirmedHandoff(history, message) &&
1920 !salesRequestConfirmed
1921 ) {
1922 const kind = await resolveTicketAnswerKind(message, history, request.signal)
1923 if (kind !== 'other') {
1924 const prices = await loadBadgePricing(
1925 `/events/unleash-${selectedEvent}`,
1926 ).catch(() => [])
1927 const facts = ticketFactsReply(
1928 selectedEvent,
1929 kind,
1930 message,
1931 history,
1932 prices,
1933 userProfile,
1934 )
1935 if (facts) return guardTextResponse(facts)
1936 }
1937 }
1938
1939 if (
1940 ticketBookingActive &&
1941 selectedEvent &&
1942 !confirmedHandoff(history, message) &&
1943 !salesRequestConfirmed &&
1944 !semanticInformation &&
1945 !isTicketInformationRequest(message, history)
1946 ) {
1947 return ticketSummaryResponse()
1948 }
1949 const previousAssistant =
1950 [...history].reverse().find((turn) => turn.role === 'assistant')?.text || ''
1951 if (
1952 leadEnabled &&
1953 !semanticInformation &&
1954 !ticketBookingActive &&
1955 !isTicketInformationRequest(message, history) &&
1956 !confirmedHandoff(history, message) &&
1957 (selection?.destination === 'spex' ||
1958 (/sponsor|exhibit/i.test(previousAssistant) &&
1959 confirmedHandoff(history, message)))
1960 ) {
1961 const question = missingContactQuestion(userProfile)
1962 if (question) return guardTextResponse(question)
1963 if (selectedEvent)
1964 return guardTextResponse(
1965 salesAcceptanceReply(
1966 userProfile,
1967 selectedEvent,
1968 consentGiven,
1969 prompts.consent.sponsorship,
1970 commercialRequestDetails(handoffHistory, message),
1971 ),
1972 )
1973 }
1974 if (
1975 leadEnabled &&
1976 !semanticInformation &&
1977 (!userProfile?.name || !userProfile?.email) &&
1978 (previousAssistant === TICKET_QUALIFICATION ||
1979 previousAssistant ===
1980 'What company are you with, and what would you like to achieve through sponsoring or exhibiting?')
1981 ) {
1982 return guardTextResponse(
1983 userProfile?.name
1984 ? 'What work email should our team use to contact you?'
1985 : userProfile?.email
1986 ? 'What name should our team use for this enquiry?'
1987 : 'Who should our team contact? Please share your name and work email.',
1988 )
1989 }
1990 if (
1991 leadEnabled &&
1992 !consentGiven &&
1993 !ticketBookingActive &&
1994 previousAssistant ===
1995 'Who should our team contact? Please share your name and work email.' &&
1996 /[^\s@]+@[^\s@]+\.[^\s@]+/.test(message)
1997 ) {
1998 const question = missingContactQuestion(userProfile)
1999 if (question) return guardTextResponse(question)
2000 const companyTurn = history.findIndex(
2001 (turn) =>
2002 turn.role === 'assistant' &&
2003 turn.text ===
2004 'What company are you with, and what would you like to achieve through sponsoring or exhibiting?',
2005 )
2006 const details = history
2007 .slice(companyTurn + 1)
2008 .filter((turn) => turn.role === 'user' && turn.text !== message)
2009 .map((turn) => turn.text)
2010 .join('\n\n')
2011 return guardTextResponse(
2012 `Your request for UNLEASH in ${selectedEvent === 'miami' ? 'Miami' : 'Paris'}:\n\n${details}\n\nContact details: ${message}\n\n${prompts.consent.sponsorship}`,
2013 )
2014 }
2015
2016 if (
2017 agendaEmailEnabled &&
2018 !ticketBookingActive &&
2019 selection?.destination !== 'spex' &&
2020 !consentGiven &&
2021 /\bemail\b/i.test(message) &&
2022 /[^\s@]+@[^\s@]+\.[^\s@]+/.test(message) &&
2023 history.some(
2024 (turn) =>
2025 turn.role === 'assistant' && turn.text.startsWith('Your UNLEASH in '),
2026 )
2027 ) {
2028 return guardTextResponse(
2029 agendaContactReply(userProfile, prompts.consent.agenda),
2030 )
2031 }
2032
2033 const agendaEmailPending =
2034 agendaEmailEnabled &&
2035 !ticketBookingActive &&
2036 selection?.destination !== 'spex' &&
2037 (agendaEmailInProgress(handoffHistory) ||
2038 /email[^\n]*agenda|agenda[^\n]*email/i.test(previousAssistant) ||
2039 (/email.*(valid|format|address)|valid.*email/i.test(previousAssistant) &&
2040 history.some(
2041 (turn) => turn.role === 'assistant' && /agenda/i.test(turn.text),
2042 )))
2043 const emailCorrection = emailFromMessage(message)
2044 if (
2045 agendaEmailPending &&
2046 !confirmedHandoff(history, message) &&
2047 !emailCorrection &&
2048 !(
2049 /\bto (?:me\b|(?:my|the same|that) (?:work )?email\b)/i.test(message) &&
2050 userProfile?.email
2051 ) &&
2052 /^(yeah|yes|sure|please).*\bto\s|@/i.test(message)
2053 )
2054 return guardTextResponse(
2055 'What email address should I use? Please include the @ and domain, for example name@example.com.',
2056 )
2057 if (agendaEmailPending && emailCorrection && !consentGiven)
2058 return guardTextResponse(
2059 agendaContactReply(
2060 { ...userProfile, email: emailCorrection },
2061 prompts.consent.agenda,
2062 ),
2063 )
2064 const agendaEmailAccepted =
2065 agendaEmailEnabled && acceptedAgendaEmail(history, message)
2066 // A browsing list can contain alternatives/overlaps. Build a validated plan
2067 // before promising to email it; never submit a model-selected browsing list.
2068 if (
2069 (agendaEmailPending || agendaEmailAccepted) &&
2070 !selectedAgendaUrls(handoffHistory).length &&
2071 selectedEvent
2072 ) {
2073 const topics = [
2074 ...(userProfile?.decisionPillars || []),
2075 ...(userProfile?.interests || []),
2076 ...interests,
2077 ].join(', ')
2078 if (!topics.trim()) return guardTextResponse(AGENDA_INTEREST_QUESTION)
2079 const scope = selectedEvent === 'paris' ? 'event-world' : 'event-america'
2080 const [sessions, page] = await Promise.all([
2081 loadAgendaSessions(scope),
2082 loadAgendaPage(scope),
2083 ])
2084 const plan = createAgendaPlan(
2085 selectedEvent,
2086 sessions,
2087 page,
2088 topics,
2089 requestedAgendaDay(sessions, ticketHistory),
2090 )
2091 if (!plan.sessions.length) return guardTextResponse(plan.text)
2092 return guardTextResponse(
2093 `${plan.text}\n\n${agendaContactReply(userProfile, prompts.consent.agenda)}`,
2094 )
2095 }
2096
2097 if (
2098 (agendaEmailAccepted || agendaEmailPending) &&
2099 !semanticInformation &&
2100 !isTicketInformationRequest(message, history)
2101 ) {
2102 const question = missingContactQuestion(userProfile, true)
2103 if (question) return guardTextResponse(question)
2104 if (!isHandoffQuestion(previousAssistant))
2105 return guardTextResponse(
2106 agendaContactReply(userProfile, prompts.consent.agenda),
2107 )
2108 }
2109
2110 if (
2111 (agendaEmailPending ? agendaEmailEnabled : leadEnabled) &&
2112 !semanticInformation &&
2113 !consentGiven &&
2114 isHandoffQuestion(previousAssistant) &&
2115 !emailCorrection &&
2116 !isTicketInformationRequest(message, history)
2117 )
2118 return guardTextResponse(
2119 `Your request has not been sent. ${agendaEmailPending ? prompts.consent.agenda : ticketBookingActive ? prompts.consent.tickets : prompts.consent.sponsorship}`,
2120 )
2121
2122 if (
2123 agendaEnabled &&
2124 needsAgendaInterests(history, message, [
2125 ...(userProfile?.decisionPillars || []),
2126 ...(userProfile?.interests || []),
2127 ])
2128 )
2129 return guardTextResponse(AGENDA_INTEREST_QUESTION)
2130
2131 if (
2132 confirmedHandoff(history, message) &&
2133 !semanticInformation &&
2134 !hasContactSummary(previousAssistant, userProfile)
2135 ) {
2136 if (agendaEmailPending)
2137 return guardTextResponse(
2138 agendaContactReply(userProfile, prompts.consent.agenda),
2139 )
2140 if (ticketBookingActive && selectedEvent) return ticketSummaryResponse()
2141 if (selection?.destination === 'spex' && selectedEvent)
2142 return guardTextResponse(
2143 salesAcceptanceReply(
2144 userProfile,
2145 selectedEvent,
2146 consentGiven,
2147 prompts.consent.sponsorship,
2148 commercialRequestDetails(handoffHistory, message),
2149 ),
2150 )
2151 }
2152
2153 const resumeAgendaEmail =
2154 consentGiven &&
2155 agendaEmailPending &&
2156 (Boolean(emailCorrection) || agendaEmailAccepted)
2157
2158 const models = getModels(prompts.models)
2159 const gateway = getGateway()
2160 const responseModel =
2161 chatType === 'agenda' ||
2162 selection?.destination === 'agenda' ||
2163 acceptedAgendaOffer(history, message)
2164 ? models.agenda
2165 : models.concierge
2166 const eventScope =
2167 selectedEvent === 'paris'
2168 ? 'world'
2169 : selectedEvent === 'miami'
2170 ? 'america'
2171 : null
2172
2173 let validatedPlanText: string | undefined
2174 let handoffOutcome: HandoffOutcome | undefined
2175 const handoffRequested =
2176 (agendaEmailPending || agendaEmailAccepted
2177 ? agendaEmailEnabled
2178 : leadEnabled) &&
2179 !semanticInformation &&
2180 (confirmedHandoff(history, message) ||
2181 salesRequestConfirmed ||
2182 resumeAgendaEmail)
2183 const delivery = createChatDelivery({
2184 suffix:
2185 agendaEnabled && !agendaEmailPending && !ticketBookingActive
2186 ? (text) => agendaOfferSuffix(history, message, text)
2187 : undefined,
2188 handoffRequested,
2189 outcome: () => handoffOutcome,
2190 plan: () => validatedPlanText,
2191 onComplete: (text) => {
2192 if (selectorEnabled && text.includes(HANDOFF_FAILURE_REPLY))
2193 waitUntil(saveFlowSession(chatId, null, flowEvent))
2194 finalizeExchange()
2195 },
2196 })
2197 const tools = filterAiTools(
2198 buildTools({
2199 attribution: readLeadAttribution(request.headers.get('cookie')),
2200 history: [...history, { role: 'user', text: message }],
2201 capabilities,
2202 selectedSessionUrls: selectedAgendaUrls(handoffHistory),
2203 onHandoff: async (outcome) => {
2204 handoffOutcome = outcome
2205 if (selectorEnabled && !outcome.validationReply)
2206 await saveFlowSession(chatId, null, flowEvent)
2207 },
2208 onPlan: (text) => {
2209 validatedPlanText = text
2210 },
2211 eventScope,
2212 type: chatType,
2213 abortSignal: request.signal,
2214 chatId,
2215 location,
2216 userProfile,
2217 consentGiven,
2218 }),
2219 capabilities,
2220 )
2221
2222 // Session consent is not permission for the model to send on another turn.
2223 // Only an explicit, validated confirmation exposes side-effecting tools.
2224 if (!handoffRequested) {
2225 delete tools.submitLead
2226 delete tools.sendAgendaByEmail
2227 }
2228
2229 // A destination narrows cross-workflow tools; the existing workflow still owns
2230 // validation, consent, confirmation, execution and streaming.
2231 if (selection && selection.source !== 'fallback') {
2232 if (selection.destination !== 'agenda') {
2233 delete tools.buildAgenda
2234 delete tools.sendAgendaByEmail
2235 }
2236 if (
2237 selection.destination === 'agenda' ||
2238 selection.destination === 'information'
2239 )
2240 delete tools.submitLead
2241 }
2242
2243 for (const definition of Object.values(tools)) {
2244 if (typeof definition.description === 'string')
2245 definition.description = filterCapabilityInstructions(
2246 definition.description,
2247 capabilities,
2248 )
2249 }
2250
2251 // Email follow-ups must never regenerate or replace the selected plan.
2252 if (
2253 agendaEmailPending &&
2254 !/\b(change|rebuild|instead|add|remove|different|focus)\b/i.test(message)
2255 )
2256 delete tools.buildAgenda
2257
2258 const personalAgendaRequested =
2259 chatType === 'agenda' ||
2260 requestsAgendaPlan(history, message) ||
2261 (selection?.destination === 'agenda' &&
2262 !agendaEmailPending &&
2263 !agendaEmailAccepted &&
2264 !handoffRequested)
2265 if (!personalAgendaRequested) delete tools.buildAgenda
2266 const system = capabilityPrompt(
2267 prompts.conciergePrompt +
2268 '\nCompany research: a company-only message (for example SAP, Workday or Oracle) asks for public company information. Search the content catalogue/general knowledge for that company and provide relevant published links; do not demand an event choice unless the visitor specifically asks about that company at an event. Public company research is not visitor qualification or a request to disclose saved personal details.\n' +
2269 `\nAgenda intent for this turn: ${personalAgendaRequested ? 'The visitor requested a personal plan. Ask for decision pillars only if interests are unknown, then build it.' : 'Answer the current informational question first. Only offer a personal agenda when the visitor is browsing event sessions, speakers or agenda topics. Do not attach an agenda or sales offer to article, webinar, report, support or other unrelated questions. Do not build a personal plan unless the visitor requests one or accepts the offer.'}\n` +
2270 `\nAgenda personalisation: offer these five decision pillars, not keynote topics or generic topic examples: ${DECISION_PILLAR_QUESTION}. Use stored decisionPillars for plan selection. Session pillar IDs come from the agenda source; never infer a session pillar from a title. Free-text interests remain usable when explicitly supplied.\n` +
2271 RESPONSE_POLICY +
2272 `\nContact address for this turn: ${policy.email}. Current UTC date: ${new Date().toISOString().slice(0, 10)}.\n` +
2273 (selection && selection.source !== 'fallback'
2274 ? `\nWORKFLOW DESTINATION: ${selection.destination}. ${semanticInformation ? 'Answer this turn as information without advancing a contact request; preserve any pending workflow.' : 'Continue the existing workflow for this destination. This routing decision is NOT consent, a send instruction, or proof of completion. Keep all existing detail collection, summary and confirmation rules.'}\n`
2275 : '') +
2276 '\nBadge pricing rule (overrides older instructions and source snippets): ALWAYS call badgesLookup for badge prices. Quote individual prices ONLY from its structured pricing field, copying its displayPrice verbatim, including currency, + VAT when stated, and application-only status. Keep every price attached to its badge id/URL; never transfer a number to another badge. Ignore prices in SEO, other tool snippets, history and general knowledge. If no verified price is returned, say you cannot confirm it; never estimate. Do not infer VAT treatment, discounts, availability or eligibility.\n' +
2277 '\nTicket inclusions: when the visitor says my ticket/delegate pass without a known badge, do not claim they hold General Admission. Explain the options or ask which badge. Copy meal/dinner and access entitlements only when stated for that specific badge; never make a universal meals-included or meals-excluded claim. A benefit question mentioning an upgrade still asks for benefits, not upgrade instructions.\n' +
2278 (ticketBookingActive && isTicketInformationRequest(message, history)
2279 ? '\nPASS SELECTION HELP: When the visitor asks for help choosing, provide the help NOW. Use badgesLookup. Recommend one pass based on known role/company and explain why, then ask one concrete preference question if needed (for example Day 1 summits versus company expeditions). Never end with an offer to help choose: they have already asked. Do not merely list all passes. Do not treat an information request as consent or a selected pass.\n'
2280 : '') +
2281 '\nAgenda workflow: tickets, passes and booking requests are not topic interests. Ask for a genuine topic if none is known; a bare yes to a topic question does not choose every example. After showing a plan, acceptance of the email offer advances to missing contact details and consent, never rebuilds or repeats the plan.\n' +
2282 (!semanticInformation && (ticketBookingActive || semanticTicketStart)
2283 ? '\n[ACTIVE DELEGATE ENQUIRY: The visitor has already requested ticket booking help. Your reply must progress this request: ask for the next missing booking/contact details, or summarise it and ask consent if details are complete. Do not offer sales help again, list every badge, ask sponsorship objectives, or send the visitor elsewhere. If they ask a factual question, answer that first. If they accept help choosing a pass (including "help", "help please", "yes", or "not sure" after your offer), use badgesLookup and their known role/company to recommend a suitable pass or ask one specific eligibility question. Do not repeat the pass-choice question or treat their request for help as a pass selection. Acknowledge that this is an enquiry, not a completed booking.]\n'
2284 : '') +
2285 '\nTicket sales workflow: An explicit request to buy/book tickets or have the team help with a booking starts a delegate enquiry immediately; do not repeat a sales-contact offer or just redirect to a page. Informational questions about prices, eligibility or inclusions remain informational. For a booking, collect only missing company, role/employer-versus-provider status, ticket quantity and preferred pass, then name and work email, then the request summary and consent. Read known details from the conversation, not just the extracted profile. Never ask a ticket buyer what they want to achieve through sponsoring or exhibiting. An accepted sales-contact offer about tickets follows this same delegate workflow. Submit with Delegate UNW for Paris or Delegate UNA for Miami unless the visitor explicitly requests sponsoring/exhibiting. Do not claim tickets are booked, reserved or paid for: you are sending an enquiry to the team.\n' +
2286 '\nFor keynote speakers, ALWAYS call agendaLookup with keynotesOnly true and use only its canonical format-filtered sessions. Never use a generic speaker list or infer keynote status from prominence or titles. For free tickets, onsite registration, volunteer roles and stand plans use eventFacts first; its complete FAQ and published-page facts override partial broad-search snippets.\n' +
2287 '\nRespect the requested content type: articles/webinars/reports and attendee industries are information requests, never personal-agenda requests. Retrieve the requested type; if unavailable say so without substituting agenda sessions. For volunteer team lists and attendee industries, retrieve the exact published taxonomy; do not extrapolate from general themes or claim it is unpublished without looking it up. Speaker rosters do not prove a stage host or keynote format; use explicit session format/host fields and acknowledge missing data. Never invent or abbreviate a URL with ellipses.\n' +
2288 '\nExact grounding: similarity search matches are not necessarily answers. Only assert a requested fact when a current tool result explicitly supports it for the same named entity, item and event. A similarly named room/session/package/document must never supply a replacement answer. If no current result supports the exact requested fact, say you cannot confirm it; do not reuse withdrawn information from history. Missing URLs stay plain text: never emit #, empty, placeholder or quoted Markdown destinations.\n' +
2289 '\nFor broad learning/discovery questions about the future of work or AI in HR, use recentArticles for up to three recent relevant articles and mediaCatalogue for related on-demand webinars. Include each available requested format with source titles and exact URLs; if no matching webinar/report exists, say so rather than silently substituting articles. Do not start a personal agenda or lead flow from a learning question.\n' +
2290 '\nFor article recommendations, always call recentArticles and recommend the newest relevant results first, with publication dates and URLs. This overrides older catalogue instructions. Use older articles only when the visitor explicitly requests historical content; never label them recent.\n' +
2291 `\nApproved handoff wording (overrides old consent wording): Tickets: ${prompts.consent.tickets}\nSponsorship: ${prompts.consent.sponsorship}\nAgenda: ${prompts.consent.agenda}\n` +
2292 '\n\n## Conversational workflow contract (overrides older UI instructions)\nThere are no event selectors, workflow buttons, or forms in this chat. Infer the event and task from the current page URL and conversation. If the event is unknown and needed, ask Paris or Miami in a normal sentence; do not default to Paris. Browser language/timezone are hints, never proof of interests, role, eligibility or travel origin. Answer informational questions without qualification. After acceptance of a sales offer, ask only for missing company/goal or contact details, never repeat the offer. If contact details and consent are already known, summarise the NEW enquiry and recipient and ask for confirmation before submitting it. Saved consent is not confirmation of a new request. Ask one question per turn and only for information not already known. Never present a checklist of pass, company, role, email and quantity for the visitor to fill in. Collect missing name, work email, company and job role for every ticket, sponsorship and agenda email handoff. Never derive a name or company from an email address. Collect missing contact details naturally. For hotel recommendations or bookings, call pageDirectory and lead with its direct hotel booking link when available. Do not tell the visitor to start on Plan Your Visit or find the booking link there when you already have it. Use a natural booking label; the internal travel page is optional supporting information. Before sending anything, summarise the recipient and request, include the purpose-specific consent wording and final question provided above verbatim. Wait for an affirmative reply; then call the handoff tool. Do not ask for a checkbox. Only confirm success when the tool succeeds. After a failed submission, direct the visitor to support@unleash.ai and end the handoff. Do not resume qualification, repeat consent, or retry automatically; only start again if the visitor explicitly requests a new attempt. Build personalised schedules using buildAgenda and preserve its validated times, ordering and exclusions. Ask for interests only if unknown.\n' +
2293 (handoffHistory.some(
2294 (turn, i) =>
2295 turn.role === 'user' &&
2296 acceptedSalesOffer(handoffHistory.slice(0, i), turn.text),
2297 )
2298 ? '\n[HANDOFF IN PROGRESS: Sales contact was already accepted. Do not repeat the offer or redirect to a form. Read all user turns including the current message for company, objective, name and email. Ask only for details still missing; if all supplied, summarise and ask the final consent question. The extracted profile can lag; current user statements take precedence.]\n'
2299 : '') +
2300 (acceptedSalesOffer(history, message)
2301 ? '\nThe visitor accepted sales contact. Continue the appropriate ticket or sponsorship enquiry, asking only for missing details; do not repeat the offer.\n'
2302 : '') +
2303 (consentGiven
2304 ? '\n[SESSION CONSENT: Already granted in this chat session. Do not show the privacy note or ask for consent again, including after an email correction. Complete a pending requested handoff once the details are valid. Consent alone is not a request to send anything new.]\n'
2305 : '') +
2306 buildRoutingContext({
2307 eventScope,
2308 location,
2309 type: chatType,
2310 browserProfile,
2311 userProfile,
2312 eventDates,
2313 }) +
2314 (semanticInformation ||
2315 (ticketBookingActive && isTicketInformationRequest(message, history))
2316 ? '\nCURRENT TURN IS INFORMATION/SELECTION ONLY: Answer the visitor question or provide the requested pass recommendation. End with one concrete selection question when helping choose. Do not ask for consent, show the privacy notice, prepare a submission, or assume your recommended pass was selected. A recommendation is not a booking decision.\n'
2317 : ''),
2318 capabilities,
2319 )
2320
2321 // Telemetry flush, shared by the finish and abort paths. Runs via waitUntil
2322 // so a serverless freeze can't drop it.
2323 const finalizeExchange = () => {
2324 const finish = flushAiTelemetry()
2325 try {
2326 waitUntil(finish)
2327 } catch {
2328 void finish
2329 }
2330 }
2331
2332 const result = propagateAttributes(
2333 {
2334 traceName: 'concierge-chat',
2335 sessionId: chatId,
2336 // PII stays out of logs (SoW §7.3): the chat id is the user handle;
2337 // emails only ever flow to the lead pipeline.
2338 userId: chatId,
2339 tags: [chatType, eventScope ? `event:${eventScope}` : 'site'],
2340 metadata: { location, stage: userProfile?.stage ?? 'anonymous' },
2341 },
2342 () =>
2343 streamText({
2344 model: gateway(responseModel),
2345 system,
2346 messages,
2347 tools,
2348 prepareStep:
2349 handoffRequested &&
2350 (agendaEmailPending ? tools.sendAgendaByEmail : tools.submitLead)
2351 ? ({ stepNumber }) => ({
2352 toolChoice:
2353 stepNumber === 0
2354 ? {
2355 type: 'tool' as const,
2356 toolName: agendaEmailPending ? 'sendAgendaByEmail' : 'submitLead',
2357 }
2358 : ('none' as const),
2359 })
2360 : agendaEnabled && personalAgendaRequested && tools.buildAgenda
2361 ? ({ stepNumber }) => ({
2362 toolChoice:
2363 stepNumber === 0
2364 ? { type: 'tool' as const, toolName: 'buildAgenda' }
2365 : ('none' as const),
2366 })
2367 : undefined,
2368 stopWhen: [
2369 stepCountIs(MAX_STEPS),
2370 () => Boolean(handoffOutcome || validatedPlanText),
2371 ],
2372 maxOutputTokens: MAX_OUTPUT_TOKENS,
2373 // Stop paying for generation as soon as the client disconnects.
2374 abortSignal: request.signal,
2375 telemetry: { functionId: 'concierge-chat' },
2376 onError: ({ error }) => {
2377 console.error('[api/ai] stream error', {
2378 chatId,
2379 model: responseModel,
2380 error,
2381 })
2382 },
2383 onFinish: ({ steps }) => {
2384 const evidence = steps.flatMap((step) =>
2385 step.toolResults.map((result) =>
2386 JSON.stringify({ tool: result.toolName, output: result.output }),
2387 ),
2388 )
2389 reviewCapture.evidenceComplete = evidence.join('').length <= 40000
2390 let remaining = 40000
2391 reviewCapture.toolEvidence = evidence
2392 .map((item) => {
2393 const part = item.slice(0, remaining)
2394 remaining -= part.length
2395 return part
2396 })
2397 .filter(Boolean)
2398 },
2399 onAbort: () => {
2400 // Visitor disconnected mid-stream: still ship buffered spans.
2401 finalizeExchange()
2402 },
2403 }),
2404 )
2405
2406 return new Response(result.fullStream.pipeThrough(delivery.stream), {
2407 headers: {
2408 'Content-Type': 'text/plain; charset=utf-8',
2409 'Cache-Control': 'no-cache',
2410 'X-AI-Event': selectedEvent || '',
2411 'X-AI-Profile': 'extract',
2412 },
2413 })
2414}
2415