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