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