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