apps/web/src/lib/eventFacts.ts
1export type EventFactTopic =
2 | 'freeTickets'
3 | 'onsiteRegistration'
4 | 'exhibitorStandPlans'
5 | 'volunteering'
6 | 'refunds'
7 | 'ticketTransfers'
8 | 'ticketUpgrades'
9 | 'invoicePayment'
10 | 'floorplan'
11 | 'registration'
12 | 'startupEligibility'
13 | 'attendance'
14 | 'industries'
15 | 'recordingAccess'
16 | 'discounts'
17 | 'hrLeaderEligibility'
18 | 'explorerApplication'
19 | 'startupOptions'
20 | 'parking'
21 | 'startupInclusions'
22 | 'openingHours'
23 | 'sponsorshipOptions'
24 | 'startupAward'
25export type EventFactAnswer = {
26 question: string
27 answer: string
28 source: string
29}
30
31/** Published terms describe purchased recordings, not automatic badge entitlements. */
32export function recordingPolicyReply(
33 pages: Array<{ title: string; url: string; content: string }>,
34): string | undefined {
35 const terms = pages.find((p) => /\/event-terms-and-conditions$/.test(p.url))
36 const paragraph = terms?.content.match(
37 /Show recordings purchased[^.]+\./i,
38 )?.[0]
39 if (!terms || !paragraph) return undefined
40 return `The published terms cover separately purchased recordings:\n\n${paragraph}\n\nThis does not establish that recordings are included with your pass. See [${terms.title}](${terms.url}), or contact [support@unleash.ai](mailto:support@unleash.ai) about your access.`
41}
42
43const questions = {
44 startupAward: /startup award/i,
45 sponsorshipOptions: /what sponsorship packages are available/i,
46 freeTickets: /free tickets?|complimentary (?:tickets?|passes)|reduced.cost/i,
47 onsiteRegistration:
48 /register on.?site|on.?site registration|ticket at (?:the )?(?:venue|door)/i,
49 exhibitorStandPlans: /stand plans?|exhibitor manual|booth plans?/i,
50 volunteering: /volunteer/i,
51 refunds: /refund|cancel|can no longer attend/i,
52 ticketTransfers: /transfer|change (?:the )?name|name change/i,
53 ticketUpgrades:
54 /upgrade[^\n]*(?:ticket|pass)|(?:ticket|pass)[^\n]*upgrade|higher.tier (?:ticket|pass)/i,
55 invoicePayment: /invoice|payment methods?/i,
56 floorplan: /floor.?plan/i,
57 registration:
58 /book (?:my|a) place|register for UNLEASH|how to register|where do I register/i,
59 startupEligibility:
60 /(?:eligible|eligibility|qualif|criteria)[^\n]*start.?up|start.?up[^\n]*(?:eligible|eligibility|qualif|criteria|exhibit|programme|program)/i,
61 attendance: /how many (?:people|attendees|visitors|delegates)/i,
62 industries: /what industries|which industries/i,
63 recordingAccess: /recordings?|on.demand.*(?:access|sessions)/i,
64 discounts: /discount|promotional offers?|early (?:bird|booking)/i,
65 hrLeaderEligibility:
66 /free tickets?|complimentary (?:tickets?|passes)|reduced.cost/i,
67 explorerApplication: /explorer[^\n]*appl|appl[^\n]*explorer/i,
68 startupOptions: /start.?up[^\n]*(?:exhibit|programme|program|pass|badge)/i,
69 parking: /parking/i,
70 startupInclusions: /start.?up/i,
71 openingHours:
72 /opening (?:time|hours)|what time.*(?:start|open)|when.*doors.*open/i,
73} satisfies Record<EventFactTopic, RegExp>
74
75/** Only factual questions are shortcuts; a contact/booking confirmation is not. */
76export function eventFactTopic(
77 message: string,
78 history: Array<{ role: string; text: string }> = [],
79): EventFactTopic | undefined {
80 if (/\bstart.?up award\b/i.test(message)) return 'startupAward'
81 const startupContext =
82 /\bstart.?up\b/i.test(message) ||
83 (!/\b(?:general (?:attendee|admission)|diamond|explorer|agenda|hotel|articles?)\b/i.test(
84 message,
85 ) &&
86 history.slice(-6).some((t) => /\bstart.?up\b/i.test(t.text)))
87 if (
88 startupContext &&
89 /\b(?:criteria|requirements|qualif|eligible|eligibility|good fit|approval|company qualifies|company so you|company so I|our company so|told you a bit more)\w*\b/i.test(
90 message,
91 )
92 )
93 return 'startupEligibility'
94 if (
95 startupContext &&
96 /\b(?:cost|price|pricing|register|registration|apply|application|badge|pass)\w*\b/i.test(
97 message,
98 ) &&
99 !/\b(?:general (?:attendee|admission)|diamond|explorer|sponsor)\b/i.test(
100 message,
101 )
102 )
103 return 'startupOptions'
104 if (
105 /\b(?:what time (?:does|will) (?:the )?event start|opening (?:time|hours)|when (?:do|will) (?:the )?doors open)\b/i.test(
106 message,
107 )
108 )
109 return 'openingHours'
110 if (
111 (/\bstart.?up\b/i.test(message) ||
112 history
113 .slice(-6)
114 .some((t) => t.role === 'user' && /\bstart.?up\b/i.test(t.text))) &&
115 /\b(?:includes?|included|inclusions|benefits|perks|what (?:do|would|will) (?:I|we) get)\b/i.test(
116 message,
117 )
118 )
119 return 'startupInclusions'
120 if (/\bparking\b/i.test(message) && !/\bexhibit\w*\b/i.test(message))
121 return 'parking'
122 if (
123 /\b(?:applied|application status|status of my application)\b/i.test(
124 message,
125 ) &&
126 /\bexplorer\b/i.test(message)
127 )
128 return 'explorerApplication'
129 if (
130 /\b(?:HR leader|HR professionals?)\b/i.test(message) &&
131 /\b(?:eligible|eligibility|can I get|qualif)\w*\b/i.test(message)
132 )
133 return 'hrLeaderEligibility'
134 if (
135 /\b(?:start.?up|pre.seed|Series A)\b/i.test(message) &&
136 /\b(?:lower.cost|cheaper|cost|options?|good fit|learning more)\b/i.test(
137 message,
138 )
139 )
140 return 'startupOptions'
141 if (
142 /\b(?:how to apply|how (?:do|can|should) (?:I|we) apply|how to register|how (?:do|can) (?:I|we) register)\b/i.test(
143 message,
144 ) &&
145 history
146 .slice(-6)
147 .some((t) => t.role === 'user' && /\bstart.?up\b/i.test(t.text))
148 )
149 return 'startupOptions'
150 if (/\b(?:discount|early bird|promotional offer)\w*\b/i.test(message))
151 return 'discounts'
152 if (/\b(?:recordings?|recorded sessions?)\b/i.test(message))
153 return 'recordingAccess'
154 if (/\b(?:benefits|perks|what(?:['’]s| is) included)\b/i.test(message))
155 return undefined
156 if (/\b(?:what|which) industries\b/i.test(message)) return 'industries'
157 if (/\bhow many (?:people|attendees|visitors|delegates)\b/i.test(message))
158 return 'attendance'
159 if (/\bfloor.?plan\b/i.test(message)) return 'floorplan'
160 if (
161 /\b(?:start.?up|Series A|pre.seed)\b/i.test(message) &&
162 /\b(?:qualif|eligible|eligibility|considered|criteria|pass|badge)\w*\b/i.test(
163 message,
164 )
165 )
166 return 'startupEligibility'
167 if (
168 /\bhow (?:can|do|should) (?:i|we) (?:book|register|buy)\b|\bwhere (?:can|do|should) (?:i|we) (?:register|buy (?:a |my )?ticket)\b/i.test(
169 message,
170 )
171 )
172 return 'registration'
173 if (/\b(?:refund|cancellation)\b|\bcan no longer attend\b/i.test(message))
174 return 'refunds'
175 if (
176 /\btransfer\b.{0,30}\b(?:tickets?|passes?|badges?)\b|\b(?:ticket|badge|pass)\b.{0,30}\btransfer\b|\bname change\b|\b(?:colleague|someone else)\b.{0,25}\b(?:instead of me|use my (?:ticket|pass))\b/i.test(
177 message,
178 )
179 )
180 return 'ticketTransfers'
181 if (/\bupgrade\b|\bhigher.tier (?:ticket|pass)\b/i.test(message))
182 return 'ticketUpgrades'
183 if (/\b(?:pay|payment)\b.{0,25}\binvoice\b|\binvoice payment\b/i.test(message))
184 return 'invoicePayment'
185 if (
186 /\b(?:stand|booth) (?:plans?|builders?|contractors?)\b|exhibitor manual/i.test(
187 message,
188 )
189 )
190 return 'exhibitorStandPlans'
191 if (
192 /\b(?:free|complimentary) (?:tickets?|passes|badges?)\b/i.test(message) &&
193 !/\b(?:contact|enquiry|sales team)\b/i.test(message)
194 )
195 return 'freeTickets'
196 if (
197 /\b(?:tickets?|register|registration|badge|pass)\b/i.test(message) &&
198 /\bon.?site\b|at (?:the )?(?:venue|door)/i.test(message)
199 )
200 return 'onsiteRegistration'
201 if (/\bvolunteer(?:ing|s)?\b/i.test(message)) return 'volunteering'
202 if (
203 /\b(?:roles?|teams?|duties)\b/i.test(message) &&
204 /\b(?:what|which|kind|tell|describe)\b/i.test(message) &&
205 history
206 .slice(-6)
207 .some((t) => t.role === 'user' && /\bvolunteer/i.test(t.text))
208 )
209 return 'volunteering'
210 return undefined
211}
212
213/** The Award is a competition; its application must not be replaced by badge purchase. */
214export function startupAwardReply(page: {
215 title: string
216 url: string
217 content: string
218}): string {
219 const description = page.content
220 .match(
221 /UNLEASH Startup Award is[\s\S]*?(?=\nUnlock|\nWin|\nHear From|$)/i,
222 )?.[0]
223 .trim()
224 const apply = page.content.match(/\[Apply Now\]\(<([^<>]+)>\)/i)?.[1]
225 return `${description || 'I can’t confirm the current Award details from the published information.'}\n\n${apply ? `Use the separate [Startup Award application](${apply}) to see the current application process and requirements.` : `See [${page.title}](${page.url}) for the application route.`} This is separate from buying a Startup Networking Badge; applying does not guarantee selection or a prize. For Award questions, contact [customersuccess@unleash.ai](mailto:customersuccess@unleash.ai).`
226}
227
228/** Answer the particular startup follow-up without repeating the whole brochure. */
229export function startupFactReply(
230 topic: EventFactTopic,
231 page: { title: string; url: string; content: string },
232 message: string,
233 history: Array<{ role: string; text: string }>,
234 exhibitingAnswer?: string,
235): string {
236 const registration = page.content.match(
237 /\[Get Your Badge\]\(<([^<>]+)>\)/i,
238 )?.[1]
239 const link = `See [${page.title}](${page.url}).`
240 const contact =
241 'For programme questions, contact [customersuccess@unleash.ai](mailto:customersuccess@unleash.ai).'
242 const register = registration
243 ? `Register through [Get your Startup Networking Badge](${registration}).`
244 : link
245 const previouslyAnswered = history.some(
246 (t) =>
247 t.role === 'assistant' &&
248 /Startup Networking (?:Package|Badge)/i.test(t.text),
249 )
250 if (
251 previouslyAnswered &&
252 /\b(?:told you|tell you|share|provide)\b.{0,45}\b(?:our|my|the) company\b/i.test(
253 message,
254 )
255 )
256 return `You can share what your company does, and I can compare it with the published programme audience: HR technology startups and scaleups. I can’t determine approval or unpublished eligibility thresholds. ${contact}`
257 if (/\b(?:cost|price|pricing|lower.cost|cheaper|discount)\w*\b/i.test(message))
258 return `I can’t confirm a currently published Startup Networking Badge price or a discount for your company. The standard delegate badge prices don’t establish the startup package price. ${register}\n\n${contact}`
259 if (
260 previouslyAnswered &&
261 /\b(?:register|registration|apply|application|approval)\w*\b/i.test(message)
262 )
263 return `${register} Applying for the Startup Award is a separate option, not the Networking Badge registration. The FAQ about reviewed applications concerns applying to exhibit within the Startup Programme; it does not establish an Award application requirement for the Networking Badge. I can’t confirm individual approval here. ${contact}`
264 const eligibility =
265 topic === 'startupEligibility' ||
266 /\b(?:criteria|requirements|qualif|eligible|eligibility|good fit|age|years? old|funding|Series A|pre.seed)\w*\b/i.test(
267 message,
268 )
269 const audience =
270 'The Startup Networking Package is for HR technology startups and scaleups shaping the future of work.'
271 const unknown =
272 'I can’t confirm an age, size or funding threshold from the current published information, or guarantee approval.'
273 if (previouslyAnswered && eligibility)
274 return `${/\b(?:recruitment|HR tech|HR technology)\b/i.test(message) ? 'Your description is relevant to the programme’s HR technology audience. ' : `${audience} `}${unknown}\n\n${link} ${contact}`
275 const inclusions = startupPackageInclusions(page)
276 return `${audience}${eligibility ? `\n\n${unknown}` : ''}${exhibitingAnswer && /\bexhibit\w*\b/i.test(message) ? `\n\nFor applying to exhibit within the Startup Programme: ${exhibitingAnswer.replace(/^Yes\.\s*/i, '')}` : ''}${inclusions ? `\n\n${inclusions}` : ''}\n\n${link} ${contact}\n\n${register} Applying for the Startup Award is a separate option, not the Networking Badge registration.`
277}
278
279/** Copy the published package's benefit block; benefits never stand in for eligibility criteria. */
280export function startupPackageInclusions(page: {
281 content: string
282}): string | undefined {
283 const block = page.content
284 .match(
285 /What the Startup Networking Badge includes:\s*\n([\s\S]*?)(?=\nBuilt around|$)/i,
286 )?.[1]
287 .trim()
288 if (!block) return undefined
289 return `The Startup Networking Badge includes:\n\n${block
290 .split('\n')
291 .filter(Boolean)
292 .map((line) => `- ${line}`)
293 .join('\n')}`
294}
295
296/** Keep Q/A aliases together and scope tagged uploads to the current event. */
297export function selectEventFactAnswers(
298 documents: Array<{ source: string; text: string }>,
299 event: 'paris' | 'miami',
300 topic: EventFactTopic,
301): EventFactAnswer[] {
302 const selected = new Map<string, EventFactAnswer>()
303 for (const document of documents) {
304 for (const match of document.text.matchAll(
305 /(?:^|\n)((?:Q:[^\n]*\n)+)A:\s*([\s\S]*?)(?=\nQ:|\n\[|$)/g,
306 )) {
307 const header =
308 document.text
309 .slice(0, match.index)
310 .match(/\[([^\]\n]+)\]/g)
311 ?.at(-1) ?? document.source
312 const paris = /UNLEASH (?:in )?Paris|UNLEASH World/i.test(header)
313 const miami = /UNLEASH (?:in )?Miami|UNLEASH America/i.test(header)
314 if (
315 (event === 'paris' && miami && !paris) ||
316 (event === 'miami' && paris && !miami)
317 )
318 continue
319 const question = match[1].replace(/^Q:\s*/gm, '').trim()
320 if (topic === 'parking' && /audience:\s*exhibitor_sponsor/i.test(header))
321 continue
322 const aliases = question.split('\n')
323 if (!aliases.some((alias) => questions[topic].test(alias))) continue
324 if (
325 (topic === 'startupEligibility' || topic === 'startupOptions') &&
326 !aliases.some((alias) =>
327 /\b(?:eligible|eligibility|qualif|criteria)\w*\b.*\bstart.?up|\bstart.?up\w*\b.*\b(?:eligible|eligibility|qualif|criteria)\w*\b|^Can start.?ups exhibit\b/i.test(
328 alias,
329 ),
330 )
331 )
332 continue
333 const answer = match[2].trim()
334 if (!answer) continue
335 selected.set(answer, { question, answer, source: document.source })
336 }
337 }
338 return [...selected.values()]
339}
340
341/** Department policy is app-owned even when an older FAQ names another team. */
342export function eventFactReply(
343 topic: EventFactTopic,
344 answers: EventFactAnswer[],
345 message = '',
346): string | undefined {
347 const email =
348 topic === 'exhibitorStandPlans'
349 ? 'customersuccess@unleash.ai'
350 : 'support@unleash.ai'
351 const relevant =
352 topic === 'discounts' && /\bearly (?:bird|booking)\b/i.test(message)
353 ? answers.filter((answer) =>
354 /early (?:bird|booking)|promotional/i.test(answer.question),
355 )
356 : answers
357 const text = relevant
358 .map((a) =>
359 a.answer
360 .replace(/(?:customersuccess|support)@unleash\.ai/gi, email)
361 .replace(
362 /Customer Success team/gi,
363 topic === 'exhibitorStandPlans' ? 'Customer Success team' : 'Support team',
364 ),
365 )
366 .join('\n\n')
367 if (topic === 'exhibitorStandPlans')
368 return `${text || 'Please use your exhibitor manual for stand-plan submission instructions.'}\n\nFor help, contact [${email}](mailto:${email}).`
369 if (
370 text &&
371 /\bcontact\b/i.test(text) &&
372 !/support@unleash\.ai|customersuccess@unleash\.ai/i.test(text)
373 )
374 return `${text}\n\nFor help, contact [${email}](mailto:${email}).`
375 return text || undefined
376}
377
378/** Published catalogue is the authority for internal destinations, including FAQ links. */
379export function publishedFactLinks(
380 text: string,
381 publishedPaths: string[],
382): string {
383 const paths = new Set(publishedPaths)
384 const allowed = (href: string) => {
385 try {
386 const url = new URL(href, 'https://www.unleash.ai')
387 return (
388 !/(?:^|\.)unleash\.ai$/i.test(url.hostname) ||
389 paths.has(url.pathname.replace(/\/$/, ''))
390 )
391 } catch {
392 return false
393 }
394 }
395 return text
396 .replace(
397 /\[([^\]]+)\]\((https?:\/\/[^\s)]+|\/[^\s)]+)\)/g,
398 (_match, label: string, href: string) =>
399 allowed(href) ? `[${label}](${href})` : label,
400 )
401 .replace(/(https?:\/\/[^\s)]+)/g, (href: string) =>
402 allowed(href) ? href : '',
403 )
404 .replace(/\s*\(\)/g, '')
405}
406