packages/ai/components/workflows.ts

1import { isHandoffQuestion } from '../../config/ai-consent'
2import type { AiEvent, ChatMessage } from './types'
3
4export const HANDOFF_FAILURE_REPLY =
5	'We couldn’t complete your submission in chat. Please contact [support@unleash.ai](mailto:support@unleash.ai) and our team can help.'
6
7export function parseAiEvent(value: unknown): AiEvent | undefined {
8	return value === 'paris' || value === 'miami' ? value : undefined
9}
10
11export function eventFromPath(path: string): AiEvent | undefined {
12	if (/^\/events\/unleash-(paris|world)(?:[/?-]|$)/i.test(path)) return 'paris'
13	if (/^\/events\/unleash-(miami|america|vegas)(?:[/?-]|$)/i.test(path))
14		return 'miami'
15	return undefined
16}
17
18export function eventFromMessage(message: string): AiEvent | undefined {
19	const paris = /\b(pariss?|unleash world)\b/i.test(message)
20	const miami = /\b(miamii?|unleash america)\b/i.test(message)
21	return paris === miami ? undefined : paris ? 'paris' : 'miami'
22}
23
24/** The page supplies the default; an explicit conversational clarification can override it. */
25export function resolveConversationEvent(
26	path: string,
27	message: string,
28	history: ChatMessage[],
29	contextEvent?: unknown,
30): AiEvent | undefined {
31	return (
32		eventFromMessage(message) ||
33		parseAiEvent(contextEvent) ||
34		eventFromPath(path) ||
35		[...history]
36			.reverse()
37			.filter((turn) => turn.role === 'user')
38			.map((turn) => eventFromMessage(turn.text))
39			.find(Boolean)
40	)
41}
42
43/** Recognize a new consent grant; sessionConsent retains the resulting decision. */
44export function confirmedHandoff(
45	history: ChatMessage[],
46	message: string,
47): boolean {
48	const last = [...history].reverse().find((turn) => turn.role === 'assistant')
49	if (
50		/\b(?:not|no|never|cancel|withdraw|revoke|if|unless|later|tomorrow)\b|don['’]t/i.test(
51			message,
52		)
53	)
54		return false
55	if (!last || !isHandoffQuestion(last.text)) return false
56	// Accept ordinary confirmation wording, but never arbitrary text after "yes".
57	const text = message
58		.trim()
59		.replace(/’/g, "'")
60		.replace(/\s+with my (?:explicit )?consent[.!\s]*$/i, '')
61		.replace(
62			/^(?:that|this)(?: all)? (?:looks|is) (?:good|perfect|correct|right)[,!.\s]+(?=(?:yes[,!\s]+)?(?:please |pls )?(?:go ahead|send|submit|forward|email)\b)/i,
63			'',
64		)
65		.replace(
66			/[.!]+\s*I confirm I(?:'ve| have) read and agreed to the privacy policy and terms[.!\s]*$/i,
67			'',
68		)
69		.replace(
70			/^(?:yes[,!\s]+)?(?:(?:that|this)(?: all)? (?:looks|is) |that(?:'s| is) )(?:correct|right)(?: to me)?[.!]+\s*(?=\S)/i,
71			'',
72		)
73		.replace(
74			/^(?:(?:yes|sure)[,!\s]+)?I (?:consent|agree|confirm)[.!]+\s*(?=\S)/i,
75			'',
76		)
77		.replace(
78			/[.!]+\s*(?:That|This)(?: all)? (?:looks|is) (?:correct|right)(?: to me)?[.!\s]*$/i,
79			'',
80		)
81		.replace(
82			/[.!]+\s*(?:(?:I(?: am|'m) )?[Ll]ooking forward to (?:hearing from (?:them|you|the team)(?: about (?:next steps|it|the (?:Diamond|Explorer|General (?:Admission|Attendee)) (?:Badge|pass|tickets?)))?|the event|it)|Thanks(?: for your help)?|Thank you(?: for your help)?)[.! ]*$/i,
83			'',
84		)
85	return (
86		/^(?:(?:yes|yeah|yep|sure|absolutely)(?:[,! .\s—-]+(?:absolutely|please|pls|that(?:'s| is) all correct))*(?:[,! .\s—-]+(?:please |pls )?(?:go ahead(?: and)?(?: send)?|send)(?: it| that| this| the request| the agenda)?(?: through)?(?: to my (?:work )?email(?: address)?)?)?|send (?:it|that|this|the request|the agenda)(?: through)?|i agree|i consent|go ahead(?: and send(?: it| that| this)?)?|please (?:do|send(?: it| that| this)?))(?:[.! ,]+(?:thanks(?: for [a-z ]+)?|thank you(?: for [a-z ]+)?|i'm looking forward to (?:the event|it)))?[.!\s]*$/i.test(
87			text,
88		) ||
89		/^(?:(?:yes|yeah|yep|sure|absolutely)[,!.\s—-]+)?(?:I confirm[,!.\s—-]+)?(?:(?:please|pls)[,! .\s—-]+)?(?:go ahead and )?(?:send|email|submit|forward) (?:it|that|this|(?:my|the|this|that|your) (?:(?:ticket|group quote|sponsorship|exhibiting) )?(?:request|agenda|enquiry|inquiry))(?: through| over)?(?: (?:for|to) (?:me|us|(?:the|our|your) (?:UNLEASH |sponsorship )?team))?(?: now)?(?:[,!.\s]+(?:please|thanks|thank you))?[.!\s]*$/i.test(
90			text,
91		)
92	)
93}
94
95/** Contact withdrawal is explicit; short cancellations are scoped to an active handoff. */
96export function isHandoffCancellation(
97	history: ChatMessage[],
98	message: string,
99): boolean {
100	const text = message.replace(/’/g, "'").trim()
101	if (
102		/\b(withdraw|revoke)\b.*\bconsent\b|\b(do not|don't|stop) (?:want (?:anyone|you|the team) to )?(?:contact(?:ing)?|email(?:ing)?)(?: me| anything)?\b/i.test(
103			text,
104		)
105	)
106		return true
107	const previous =
108		[...history].reverse().find((turn) => turn.role === 'assistant')?.text || ''
109	const active =
110		isHandoffQuestion(previous) ||
111		/what company are you with\?|what is your job role\?|what name should i use\?|what work email address should i use\?|please use your work email address|sales team.*get in touch|(?:company|job role|name|work email address).*for (?:this enquiry|your selected agenda email)|company.*achieve.*sponsor|company.*role|(?:company|role|how many).*(?:tickets?|passes|booking)|(?:tickets?|passes|booking).*(?:company|role|how many)|who should.*contact|name.*(?:work )?email|shall i send (?:this request|this sponsorship or exhibiting enquiry)|email.*agenda|agenda.*email/i.test(
112			previous,
113		)
114	return (
115		active &&
116		/^(?:actually[, ]+)?(?:cancel(?: (?:that|this|the request|the enquiry|the email))?|never mind|nevermind|no(?: thanks)?|not (?:now|interested))[.!\s]*$/i.test(
117			text,
118		)
119	)
120}
121
122/** Earlier sales acceptance must not restart a handoff that was subsequently cancelled. */
123export function historyAfterHandoffCancellation(
124	history: ChatMessage[],
125): ChatMessage[] {
126	let start = 0
127	for (let i = 0; i < history.length; i++) {
128		const turn = history[i]
129		if (turn.role === 'assistant' && turn.text.includes(HANDOFF_FAILURE_REPLY)) {
130			start = i + 1
131			continue
132		}
133		if (
134			turn.role === 'user' &&
135			isHandoffCancellation(history.slice(0, i), turn.text)
136		)
137			start = i + 1
138	}
139	return history.slice(start)
140}
141
142/** Keep a consent decision across corrections and follow-ups, until it is withdrawn. */
143export function sessionConsent(
144	history: ChatMessage[],
145	message: string,
146	previous = false,
147): boolean {
148	let accepted = previous
149	const turns = [...history, { role: 'user' as const, text: message }]
150	let assistant = ''
151	for (const turn of turns) {
152		if (turn.role === 'assistant') {
153			assistant = turn.text
154			continue
155		}
156		if (
157			isHandoffCancellation([{ role: 'assistant', text: assistant }], turn.text)
158		)
159			accepted = false
160		else if (
161			confirmedHandoff([{ role: 'assistant', text: assistant }], turn.text)
162		)
163			accepted = true
164		else if (
165			isHandoffQuestion(assistant) &&
166			/^(no|no thanks|not now|not yet)[.!\s]*$/i.test(turn.text.trim())
167		)
168			accepted = false
169	}
170	return accepted
171}
172
173/** Markdown escaping is presentation, not part of an email address. */
174export function normalizeEmailEscapes(text: string): string {
175	return text.replace(/\\@/g, '@')
176}
177
178export function emailFromMessage(text: string): string | undefined {
179	return normalizeEmailEscapes(text).match(
180		/[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?\.[a-z]{2,}\b/i,
181	)?.[0]
182}
183