apps/web/src/lib/ticketAnswer.ts

1import type { UserProfile } from '@local/ai'
2import { requestsTicketQuote } from './aiFlows'
3import type { BadgePrice } from './livePricing.server'
4
5type Turn = { role: 'user' | 'assistant'; text: string }
6export type TicketAnswerKind = 'price' | 'comparison' | 'other'
7
8/** Conservative baseline: ambiguous requests remain with the concierge. */
9export function ticketAnswerKind(
10	message: string,
11	history: Turn[] = [],
12): TicketAnswerKind {
13	if (requestsTicketQuote(message)) return 'other'
14	// Startup-package pricing is distinct from the standard delegate catalogue.
15	if (/\bstart.?up\b/i.test(message)) return 'other'
16	const context = `${message} ${history
17		.slice(-6)
18		.map((t) => t.text)
19		.join(' ')}`
20	if (!/\b(?:ticket|badge|pass|admission|diamond|explorer)s?\b/i.test(context))
21		return 'other'
22	const lastAssistant =
23		history.findLast((turn) => turn.role === 'assistant')?.text || ''
24	if (
25		/\bwhich pass would you like\b/i.test(lastAssistant) &&
26		/^(?:(?:one|two|three|four|five|\d+) tickets?)(?: please)?[.!\s]*$/i.test(
27			message.trim(),
28		)
29	)
30		return 'comparison'
31	// A passing reference to price or an upgrade must not swallow the benefits question.
32	if (
33		/\b(?:benefits|perks|what(?:['’]s| is) included|what.{0,20}includes?)\b/i.test(
34			message,
35		)
36	)
37		return 'other'
38	if (
39		/\b(?:refund|invoice|cancel|transfer|discount|group|free|sponsor(?:ship|ing)?|exhibit(?:ing|ion)?|parking|hotels?|accommodation|agenda|articles?|venue)\b/i.test(
40			message,
41		)
42	)
43		return 'other'
44	if (
45		/\b(?:buy|book|purchase|send|submit)\b/i.test(message) &&
46		!/\b(?:cost|price|much)\b/i.test(message)
47	)
48		return 'other'
49	if (
50		/\b(?:cost|price|prices|pricing|how much|VAT|set me back)\b/i.test(message)
51	)
52		return 'price'
53	if (
54		/\b(?:explorer|diamond|general (?:admission|attendee))\b.*\b\d[\d,.]*\b.*(?:right|still|correct|\?)|\b(?:is|isn['’]t)\b.*\b(?:explorer|diamond)\b.*\d/i.test(
55			message,
56		)
57	)
58		return 'price'
59	if (
60		/\b(?:compare|comparison|difference|differences|recommend|choose|choosing|which is right|torn between|which (?:badge|pass) is right)\b|^(?:help(?: me)?(?: please)?|please help|not sure)[.!? ]*$/i.test(
61			message,
62		)
63	)
64		return 'comparison'
65	return 'other'
66}
67
68/** Money and application status are copied from a verified record, never generated. */
69export function ticketFactsReply(
70	event: 'paris' | 'miami',
71	kind: TicketAnswerKind,
72	message: string,
73	history: Turn[] = [],
74	records: BadgePrice[] = [],
75	profile?: Pick<
76		UserProfile,
77		'role' | 'company' | 'ticketQuantity' | 'ticketQuantityRange'
78	>,
79): string | undefined {
80	if (kind === 'other') return
81	const slug = `/events/unleash-${event}`
82	if (!records.length)
83		return `I don’t have a currently verified price for UNLEASH in ${event === 'paris' ? 'Paris' : 'Miami'}. Please check the [event page](${slug}) for the current ticket options; I won’t estimate a price.`
84	const mentioned = (text: string) =>
85		records.filter((p) =>
86			new RegExp(
87				p.id === 'general' ? 'general (?:admission|attendee)' : `\\b${p.id}\\b`,
88				'i',
89			).test(text),
90		)
91	let selected = mentioned(message)
92	if (!selected.length && kind === 'price') {
93		const previous = [...history].reverse().find((t) => t.role === 'assistant')
94		const candidates = mentioned(previous?.text || '')
95		if (candidates.length === 1) selected = candidates
96	}
97	if (!selected.length) selected = records
98	const rows = selected.map(
99		(p) =>
100			`- [${p.badge}](${p.url}): **${p.displayPrice}**${p.amount === null ? '' : ' per pass'}${kind === 'comparison' ? ` — ${p.summary}` : ''}`,
101	)
102	const prior =
103		history.at(-1)?.role === 'user' && history.at(-1)?.text === message
104			? history.slice(0, -1)
105			: history
106	const repeatedHelp =
107		kind === 'comparison' &&
108		/^(?:help(?: me)?(?: please)?|please help|not sure)[.!? ]*$/i.test(message) &&
109		(prior.some(
110			(t) =>
111				t.role === 'user' &&
112				/^(?:help(?: me)?(?: please)?|please help|not sure)[.!? ]*$/i.test(t.text),
113		) ||
114			/What matters most to you: the main conference, company visits, or Day 1 leadership sessions\?/i.test(
115				prior.findLast((t) => t.role === 'assistant')?.text || '',
116			))
117	if (repeatedHelp) {
118		if (
119			/This is a recommendation, not a selected or reserved pass/.test(
120				prior.findLast((t) => t.role === 'assistant')?.text || '',
121			)
122		) {
123			const general = records.find((p) => p.id === 'general')
124			const diamond = records.find((p) => p.id === 'diamond')
125			if (general && diamond)
126				return `The key decision is Day 1: [${general.badge}](${general.url}) covers the main conference on Days 2–3; [${diamond.badge}](${diamond.url}) adds Day 1 Summits & Salons. Separate invitation-only programmes are not guaranteed.\n\nDo you want Day 1 leadership sessions included?`
127		}
128		const context = history
129			.filter((t) => t.role === 'user')
130			.map((t) => t.text)
131			.join(' ')
132		const id = /\b(?:day 1|leadership sessions?|summits?)\b/i.test(context)
133			? 'diamond'
134			: /\b(?:company visits?|expeditions?)\b/i.test(context)
135				? 'explorer'
136				: 'general'
137		const recommended = records.find((p) => p.id === id)
138		if (recommended)
139			return `${profile?.role ? `With your role as ${profile.role}, ` : ''}I’d start with [${recommended.badge}](${recommended.url}): ${recommended.summary} **${recommended.displayPrice}**. This is a recommendation, not a selected or reserved pass.\n\nWould that fit your plans?`
140	}
141	return (
142		(profile?.ticketQuantity || profile?.ticketQuantityRange
143			? `${profile.ticketQuantityRange || profile.ticketQuantity} ${profile.ticketQuantity === 1 ? 'ticket' : 'tickets'} noted.\n\n`
144			: '') +
145		rows.join('\n') +
146		(kind === 'comparison'
147			? '\n\nWhat matters most to you: the main conference, company visits, or Day 1 leadership sessions?'
148			: '\n\nThese are the verified published ticket terms; an enquiry does not reserve or purchase a pass.')
149	)
150}
151