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