apps/web/src/lib/aiFlows.ts

1import { DECISION_PILLAR_QUESTION } from '@local/config/decision-pillars'
2
3/**
4 * Deterministic conversation-state hints for the concierge. The model is
5 * good at answering and poor at remembering which step of a multi-turn
6 * capture it is in; where the state is unambiguous from the transcript, the
7 * server says so in the routing context instead of hoping the prompt holds.
8 */
9
10type Turn = { role: 'assistant' | 'user'; text: string }
11
12/** A closing acknowledgement is not a new search or an acceptance of a handoff. */
13export function isConversationClosing(message: string): boolean {
14	if (
15		!/\?|\b(?:can|could|would|please) you\b|\b(?:send|submit|contact me|call me)\b/i.test(
16			message,
17		) &&
18		/\b(?:thanks|thank you)\b/i.test(message) &&
19		/\bI(?:['’]ll| will| am going to|'m going to)\b.{0,65}\b(?:register|apply|check|head over|reach out|contact support)\b/i.test(
20			message,
21		)
22	)
23		return true
24	return /^(?:great[,!\s]+)?(?:thanks|thank you)(?:[,!\s]+(?:that(?:['’]s| is) all|that helps|for your help))?[.!\s]*$/i.test(
25		message.trim(),
26	)
27}
28
29/** Requesting a team quote does not require choosing a published pass first. */
30export function requestsTicketQuote(message: string): boolean {
31	return (
32		/\b(?:custom quote|group pricing|pricing options|options for \d+ (?:attendees|tickets|passes))\b/i.test(
33			message,
34		) &&
35		/\b(?:follow up|reach out|contact me|take my details|leave my email|someone|team)\b/i.test(
36			message,
37		) &&
38		!/\b(?:do not|don't|cancel|no contact)\b/i.test(message)
39	)
40}
41
42/** Explicit content discovery stays read-only, even if a workflow classifier drifts. */
43export function isContentInformationRequest(message: string): boolean {
44	return (
45		/\b(?:articles?|webinars?|podcasts?|reports?|research|news)\b/i.test(
46			message,
47		) ||
48		(/\b(?:learn|read|find out)\b/i.test(message) &&
49			/\b(?:future of work|AI.*HR|HR.*AI)\b/i.test(message))
50	)
51}
52
53/** Applying on the published site is not an instruction to submit a chat enquiry. */
54export function isSelfServiceApplication(message: string): boolean {
55	return (
56		/\b(?:I(?:['’]ll| will)|we(?:['’]ll| will)|going to)\b.{0,65}\b(?:apply|submit an application|application page)\b/i.test(
57			message,
58		) && !/\b(?:can|could|please|help) you\b/i.test(message)
59	)
60}
61
62/** A commercial topic is not itself permission to begin collecting contact details. */
63export function isCommercialInformationRequest(message: string): boolean {
64	return (
65		isTicketInformationRequest(message) &&
66		/\b(?:tickets?|badges?|pass(?:es)?|Explorer|Diamond|sponsor(?:ship|ing)?|exhibit(?:ing|ors?)?|packages?|attend|eligibility|eligible|registration|stand (?:builder|contractor))\b/i.test(
67			message,
68		) &&
69		!/\b(?:contact me|call me|get in touch|send (?:my|an|the) enquiry|sales team.*help)\b/i.test(
70			message,
71		)
72	)
73}
74
75/** An ordinary badge purchase never proves access to a separately restricted session. */
76export function asksRestrictedSessionAccess(
77	message: string,
78	history: Turn[],
79): boolean {
80	const recent = history
81		.slice(-4)
82		.map((turn) => turn.text)
83		.join(' ')
84	return (
85		/\binvit(?:e|ation)[ -]only\b/i.test(`${message} ${recent}`) &&
86		/\b(?:eligible|eligibility|access|attend|upgrade|limited to|can I)\b/i.test(
87			message,
88		) &&
89		!/\b(?:build|create|plan)\b.{0,30}\bagenda\b/i.test(message)
90	)
91}
92
93export const TICKET_QUALIFICATION =
94	'What company are you with, and what is your role?'
95
96/** Product-launch and commercial price questions need the sponsorship destination first. */
97export function asksSponsorshipInformation(message: string): boolean {
98	return (
99		/\b(?:launching|launch)\b.{0,25}\bHR (?:product|platform)\b/i.test(message) ||
100		(/\b(?:exhibition|exhibiting|sponsorship)\b/i.test(message) &&
101			/\b(?:cost|price|pricing|square met(?:er|re)|per.sqm)\b/i.test(message))
102	)
103}
104
105const SALES_OFFER =
106	/(?:i|we) can help you (?:start|with) (?:the |a |your )?(?:ticket |booking |delegate )enquiry|would you like (?:our|the) (?:ticket )?sales team to get in touch|would you like me to (?:connect|put) you (?:with|in touch with) (?:our|the) (?:ticket )?sales team/i
107const AFFIRMATIVE =
108	/^(?:yes(?:[, ]+(?:please|pls))?|yeah|yep|yup|sure|ok(?:ay)?|please|please do|go ahead|absolutely|definitely|of course|that would be great|sounds good|i'?d like that|let'?s do (?:that|it))\b[.! ]*$/i
109
110export const AGENDA_INTEREST_QUESTION = DECISION_PILLAR_QUESTION
111
112export function acceptedAgendaEmail(history: Turn[], message: string): boolean {
113	const last = [...history].reverse().find((turn) => turn.role === 'assistant')
114	if (/\b(?:no|not|cancel|later|tomorrow)\b|don['’]t/i.test(message))
115		return false
116	const text = message
117		.trim()
118		.replace(/^(?:perfect|great|excellent|sounds good)[,!\s]+/i, '')
119	return Boolean(
120		last &&
121			/would you like me to email your selected agenda\?/i.test(last.text) &&
122			(AFFIRMATIVE.test(text) ||
123				(/\b(?:send|email)\b/i.test(text) &&
124					/[^\s@]+@[^\s@]+\.[^\s@]+/.test(text)) ||
125				/^(?:yes[,! ]+)?(?:please )?(?:email|send) (?:it|that|(?:my|the|your) (?:selected |personalised |personalized )?agenda)(?: to (?:me|my (?:work )?email))?[.! ]*$/i.test(
126					text,
127				)),
128	)
129}
130
131export function needsAgendaInterests(
132	history: Turn[],
133	message: string,
134	interests: string[],
135): boolean {
136	if (interests.length) return false
137	if (acceptedAgendaOffer(history, message)) return true
138	// Asking for a personal agenda in chat still needs interests; do not dump
139	// overlapping catalogue sessions or interpret the request as email delivery.
140	if (
141		/\b(?:show|send|put)(?: me)? (?:my|a personal|a personalised|a personalized) agenda\b.{0,45}\b(?:here|chat|screen)\b/i.test(
142			message,
143		) &&
144		!history.some(
145			(turn) =>
146				turn.role === 'assistant' && turn.text.startsWith('Your UNLEASH in '),
147		)
148	)
149		return true
150	const last = [...history].reverse().find((turn) => turn.role === 'assistant')
151	return (
152		/^(?:(?:can|could|would) you |please )?(?:build|make|create|plan|recommend|suggest|personalise|personalize)(?: me)? (?:an?|my|personal|personalised|personalized)(?: personal(?:ised|ized)?)? agenda(?: for (?:me|us))?[?.!\s]*$/i.test(
153			message.trim(),
154		) ||
155		Boolean(
156			last?.text === AGENDA_INTEREST_QUESTION &&
157				/^(?:yes|yes pleas|yes please|yep|yeah|sure)[.!\s]*$/i.test(message.trim()),
158		)
159	)
160}
161
162/** The latest explicit user request wins over earlier topics and page copy. */
163export function isTicketSalesConversation(history: Turn[]): boolean {
164	for (let i = history.length - 1; i >= 0; i--) {
165		const turn = history[i]
166		if (turn.role !== 'user') continue
167		if (requestsTicketBooking(turn.text, history.slice(0, i))) return true
168		if (
169			acceptedSalesOffer(history.slice(0, i), turn.text) &&
170			/(?:ticket|booking|delegate).*enquiry/i.test(
171				history.slice(0, i).findLast((t) => t.role === 'assistant')?.text || '',
172			)
173		)
174			return true
175		if (/\bagenda\b|\bschedule\b/i.test(turn.text)) return false
176		if (/\bsponsor(?:ship|ing)?\b|\bexhibit(?:ing|or)?\b/i.test(turn.text))
177			return false
178		if (
179			/\btickets?\b|\bbadges?\b|\bpasses\b|\bpass\b|\bgroup book(?:ing)?\b|\bregister\b/i.test(
180				turn.text,
181			)
182		)
183			return true
184	}
185	return false
186}
187
188/** Buying requests can refer to a pass chosen in the preceding conversation. */
189export function requestsTicketBooking(
190	message: string,
191	history: Turn[] = [],
192): boolean {
193	if (requestsTicketQuote(message)) return true
194	const text = message
195		.replace(/’/g, "'")
196		.replace(/,?\s+not (?:to )?(?:sponsor|exhibit)\w*\b/gi, '')
197	if (/\b(?:not|don't|do not|cancel)\b/i.test(text)) return false
198	if (
199		/\b(?:help (?:me|us) (?:with )?(?:the )?registration|help (?:me|us) register|go ahead with (?:the )?(?:general admission|diamond|explorer)(?: badge| pass)?)\b/i.test(
200			text,
201		)
202	)
203		return true
204	// Buying logistics and changes to an existing booking are information, not a new enquiry.
205	if (
206		/\b(?:at (?:the )?(?:venue|door|event)|on (?:the )?day|upgrade|already (?:registered|bought|booked)|where (?:can|do|should) (?:i|we)|how (?:can|do) (?:i|we))\b/i.test(
207			text,
208		)
209	)
210		return false
211
212	const context =
213		[...history].reverse().find((turn) => turn.role === 'assistant')?.text || ''
214	const ticketContext =
215		/\b(?:tickets?|badges?|passes|pass|group booking|register)\b/i.test(text) ||
216		(/\b(?:one|two|them|that|it)\b/i.test(text) &&
217			/\b(?:ticket|badge|pass)(?:es|s)?\b/i.test(context))
218	if (!ticketContext) return false
219	// A short follow-up to a displayed pass is a request to get it, not a
220	// general eligibility question such as "Can I get a Diamond badge?".
221	if (
222		/^(?:can|could) (?:i|we) (?:get|buy|book) (?:one|two|it|that|them)[?.!\s]*$/i.test(
223			text.trim(),
224		)
225	)
226		return true
227	return /\b(?:i|we)(?: would|'d)? (?:want|need|like) (?:to )?(?:buy|book|purchase|get|(?:a|one|two|three|four|five|six|seven|eight|nine|ten|\d+) (?:tickets?|badges?|passes))\b|\bhelp (?:me|us) (?:buy|book|get)\b|\b(?:can|could|would) you (?:get|book|buy|arrange) (?:me|us)\b|^(?:please )?(?:book|buy|get|arrange) (?:me|us)\b|\b(?:sales|team).*(?:help|contact|call).*(?:book|tickets)/i.test(
228		text,
229	)
230}
231
232/** Questions and choosing-help remain model turns even during qualification. */
233export function isTicketInformationRequest(
234	message: string,
235	history: Turn[] = [],
236): boolean {
237	const text = message.trim()
238	if (requestsTicketBooking(message, history)) return false
239	if (
240		/\b(?:tell me|hear about|learn about|walk me through)\b.{0,60}\b(?:ticket|badge|pass)(?:es|s)?\b.{0,40}\b(?:options|prices?|pricing|benefits)\b/i.test(
241			message,
242		)
243	)
244		return true
245	if (
246		/^(?:help(?: me)?(?: please)?|please help(?: me)?|not sure|i(?:'m| am)? not sure)[.!? ]*$/i.test(
247			text,
248		)
249	)
250		return true
251	const previousAssistant =
252		[...history].reverse().find((turn) => turn.role === 'assistant')?.text || ''
253	// Short answers accept the choice-help offer; they are not pass names.
254	if (
255		/help (?:you )?(?:choos|pick)|help choosing|which.*(?:choose|pass|badge)|recommend.*(?:pass|badge|admission)/i.test(
256			previousAssistant,
257		) &&
258		(/^(?:help(?: me)?(?: please)?|please help(?: me)?|you (?:choose|pick)|not sure|i(?:'m| am)? not sure|i don['’]?t know)[.!? ]*$/i.test(
259			text,
260		) ||
261			AFFIRMATIVE.test(text))
262	)
263		return true
264
265	return (
266		/^(?:and |but |also )?(?:what|which|how|when|where|why|is|are|does|do|can i|could i)\b|\b(?:help me choose|help us choose|compare|recommend|refundable|included|inclusions)\b/i.test(
267			message.trim(),
268		) ||
269		/\b(?:what|which) (?:are |is )?(?:the )?(?:pass|ticket|badge)(?:es|s)? (?:options|types|prices|pricing|benefits)|\bwhat(?:['’]s| is) included\b|\b(?:do I need|can I apply|can our company get)\b/i.test(
270			message,
271		)
272	)
273}
274
275/** Factual purchase/exhibitor logistics must not become a new lead request. */
276export function isCommercialLogisticsQuestion(message: string): boolean {
277	if (
278		/\b(?:eligible|eligibility|qualif|HR leader|applied|application|stand builder|stand contractor)\w*\b/i.test(
279			message,
280		) &&
281		isTicketInformationRequest(message)
282	)
283		return true
284	if (
285		/\bhow (?:can|do|should) (?:i|we) (?:book|register|buy)\b|\b(?:tickets?|badges?|passes) (?:page|link|url)\b/i.test(
286			message,
287		)
288	)
289		return true
290	if (
291		/\b(?:free|complimentary) (?:tickets?|pass(?:es)?|badges?)\b/i.test(
292			message,
293		) &&
294		!/\b(?:contact|call|enquiry|sales team)\b/i.test(message)
295	)
296		return true
297	if (!isTicketInformationRequest(message)) return false
298	return (
299		(/\b(?:tickets?|pass(?:es)?|badge(?:s)?|register(?:ing|ed|ation)?)\b/i.test(
300			message,
301		) &&
302			/\b(?:at (?:the )?(?:venue|door|event)|on (?:the )?day|upgrade|already (?:registered|bought|booked)|where (?:can|do|should) (?:i|we)|how (?:can|do) (?:i|we))\b/i.test(
303				message,
304			)) ||
305		/\b(?:stand plans?|exhibitor manual|exhibitor portal|stand (?:design|build)|booth (?:design|build))\b/i.test(
306			message,
307		)
308	)
309}
310
311/**
312 * The visitor's message is a plain "yes" to the agent's sales-team offer in
313 * the immediately preceding assistant turn. A message that carries its own
314 * content ("yes, we're Acme and want leads") is not matched — the model can
315 * handle that itself.
316 */
317export function acceptedSalesOffer(history: Turn[], message: string): boolean {
318	const lastAssistant = [...history]
319		.reverse()
320		.find((t) => t.role === 'assistant')
321	if (
322		!lastAssistant ||
323		!(
324			SALES_OFFER.test(lastAssistant.text) ||
325			(/\b(?:sales|sponsorship) team\b/i.test(lastAssistant.text) &&
326				/would you like me to (?:put|connect) you (?:in touch )?with (?:them|the team)(?: directly)?\?/i.test(
327					lastAssistant.text,
328				))
329		)
330	)
331		return false
332	return (
333		AFFIRMATIVE.test(
334			message
335				.trim()
336				.replace(/^yes[, ]+(?:pls|pleas(?:e|1)?)[.! ]*$/i, 'yes please'),
337		) ||
338		/^(?:yes[, ]+)?(?:please )?(?:get in touch|contact me|have (?:the |your )?team contact me)[.! ]*$/i.test(
339			message.trim(),
340		)
341	)
342}
343
344export const AGENDA_BUILD_OFFER =
345	'Would you like me to create a personalised agenda for you?'
346
347export function acceptedAgendaOffer(history: Turn[], message: string): boolean {
348	const last = history.findLast((turn) => turn.role === 'assistant')
349	return Boolean(
350		last &&
351			/would you like (?:me|us) to (?:create|build|recommend|suggest|put together)[^?]*agenda[^?]*\?/i.test(
352				last.text,
353			) &&
354			/^(?:yes(?:[, ]+(?:please|pls))?|sure|please do|go ahead|sounds good)[.!\s]*$/i.test(
355				message.trim(),
356			),
357	)
358}
359
360/** Add one discovery offer after an agenda answer, never during contact collection. */
361export function agendaOfferSuffix(
362	history: Turn[],
363	message: string,
364	answer: string,
365): string {
366	const prose = answer.replace(/\]\([^)]*\)/g, ']')
367	const agendaContext =
368		/\b(?:agenda|sessions?|stages?|keynotes?|topics|what will i learn)\b/i.test(
369			message,
370		) ||
371		(/\b(?:interested|topics?|AI|skills)\b/i.test(message) &&
372			history.slice(-4).some((turn) => /\bagenda\b/i.test(turn.text)))
373	if (
374		!agendaContext ||
375		!answer.trim() ||
376		/\b(?:not|no|cancel)\b/i.test(message) ||
377		/\?|not (?:available|published)|unavailable|haven.t published|past event|support@|what (?:company|name|email)|your (?:personalised|selected) agenda/i.test(
378			prose,
379		) ||
380		history
381			.slice(-6)
382			.some(
383				(turn) =>
384					turn.role === 'assistant' &&
385					(turn.text.includes(AGENDA_BUILD_OFFER) ||
386						turn.text.startsWith('Your UNLEASH in ')),
387			)
388	)
389		return ''
390	return `\n\n${AGENDA_BUILD_OFFER}`
391}
392
393/** Keep email qualification active after short, natural contact questions. */
394export function agendaEmailInProgress(history: Turn[]): boolean {
395	let pending = false
396	for (let i = 0; i < history.length; i++) {
397		const turn = history[i]
398		if (
399			turn.role === 'user' &&
400			acceptedAgendaEmail(history.slice(0, i), turn.text)
401		)
402			pending = true
403		if (
404			turn.role === 'user' &&
405			/\b(?:buy|book|sponsor|exhibit)\b/i.test(turn.text)
406		)
407			pending = false
408		if (
409			turn.role === 'assistant' &&
410			/submitted for email delivery|couldn.t complete your submission|cancelled the contact request/.test(
411				turn.text,
412			)
413		)
414			pending = false
415	}
416	return pending
417}
418
419/** Browsing an agenda is distinct from requesting a personal schedule. */
420export function requestsAgendaPlan(history: Turn[], message: string): boolean {
421	if (acceptedAgendaOffer(history, message)) return true
422	if (
423		history.findLast((turn) => turn.role === 'assistant')?.text ===
424		AGENDA_INTEREST_QUESTION
425	)
426		return true
427	return (
428		/\b(?:build|make|create|plan|recommend|suggest|personalise|personalize)\b.{0,45}\b(?:agenda|schedule|sessions)\b|\b(?:my|personalised|personalized|personal) (?:agenda|schedule)\b/i.test(
429			message,
430		) && !/\b(?:don.t|do not|cancel|no)\b/i.test(message)
431	)
432}
433