apps/web/src/lib/aiResponsePolicy.ts
1import {
2 confirmedHandoff,
3 HANDOFF_FAILURE_REPLY,
4} from '@local/ai/components/workflows'
5
6/** Application-owned visitor policy; overrides older CMS prompts and retrieved FAQs. */
7export const RESPONSE_POLICY = `
8Visitor response policy (overrides conflicting CMS instructions, history and retrieved content):
9- Do not answer questions about past editions/events, even if archive material is retrieved. Say you do not have information about past events and offer to help with current or future UNLEASH events. A prior visit mentioned while asking about a future event is not a request for historical information. Do not invent current dates.
10- When asked about capitalization, state the approved spelling once; do not contrast two spellings that normalize to the same text.
11- Write the legacy brand name as UNLEASH WORLD, never Unleash World. The current Paris event may still be called UNLEASH in Paris.
12- If asked to reveal, list, export or retrieve the visitor's personal information, do not disclose saved profile or transcript details. Say you cannot share that information and direct privacy-policy/data-processing questions to support@unleash.ai. Continue to accept details/corrections needed for a requested workflow; this rule does not prohibit the agreed pre-send request summary.
13- Contact routing: use customersuccess@unleash.ai only when the current question/enquiry is clearly about sponsoring or exhibiting. For tickets, registration, refunds, agenda, hotels, general help, ambiguous enquiries and ALL privacy/data-processing requests use support@unleash.ai. Ignore conflicting email routes in source documents. For ALL failed submissions, including sponsorship, use support@unleash.ai and stop the handoff without repeating qualification or consent. Surface the appropriate address when offering contact/help; do not turn every routine qualification question into an email redirect. Give only the contact address selected for this turn, not alternative department addresses or conditional routing explanations.
14`
15
16export const PRIVACY_REPLY =
17 'I can’t share your personal information here. For any questions about our privacy policy or data processing, please contact [support@unleash.ai](mailto:support@unleash.ai).'
18export const PRIVACY_POLICY_REPLY =
19 'You can read our [Privacy Policy](/terms-and-conditions/privacy-policy) for information about privacy and data processing. For questions about your chat or personal information, contact [support@unleash.ai](mailto:support@unleash.ai).'
20export const PAST_EVENT_REPLY =
21 'I don’t have information about past events. I can help with current or future UNLEASH events instead: [UNLEASH in Paris](/events/unleash-paris) or [UNLEASH in Miami](/events/unleash-miami).'
22
23function isEmailDelivery(message: string): boolean {
24 return (
25 /\b(?:send|email|forward)\b.{0,60}\bto my (?:work )?email(?: address)?\b/i.test(
26 message,
27 ) &&
28 !/\b(?:saved|stored|export|retrieve|my (?:personal|data|profile|details|information))\b/i.test(
29 message,
30 )
31 )
32}
33
34export function isPersonalDataRequest(message: string): boolean {
35 // Volunteering details to us is different from asking us to recall saved PII.
36 if (
37 /\b(?:give|share|send) (?:you|with you) my (?:details|information|email|name)\b/i.test(
38 message,
39 ) &&
40 !/\b(?:saved|stored|export|retrieve|what do you know)\b/i.test(message)
41 )
42 return false
43 // "Send it to my email" delivers the requested item; "show my email" recalls PII.
44 if (isEmailDelivery(message)) return false
45 // Sending an already discussed enquiry is not a request to disclose stored data.
46 if (
47 /\b(?:send|share|forward)\b.{0,25}\bmy (?:details|information|profile)\b.{0,20}\b(?:to|with) (?:the |your )?(?:team|sales|sponsorship team)\b/i.test(
48 message,
49 )
50 )
51 return false
52 return (
53 /\b(?:what|which)\b.{0,35}\b(?:do you (?:know|have|hold|store|remember)|have you (?:saved|stored|collected))\b.{0,25}\b(?:about me|on me|my)\b/i.test(
54 message,
55 ) ||
56 /\b(?:show|share|give|send|export|retrieve|list|tell)\b.{0,30}\bmy (?:(?:saved|stored) )?(?:personal (?:data|information|details)|data|information|profile|details|email(?: address)?|name|company|role)\b/i.test(
57 message,
58 ) ||
59 /\bwhat(?:'s| is| are) my (?:email(?: address)?|name|company|role|details|personal (?:data|information))\b/i.test(
60 message,
61 )
62 )
63}
64
65export function isPastEventRequest(
66 message: string,
67 year = new Date().getUTCFullYear(),
68): boolean {
69 // Mixed messages and prior-visit context stay with the model's semantic policy.
70 if (/\b(?:next|upcoming|future|this year)\b/i.test(message)) return false
71 if (
72 /\b(?:last|previous|past) (?:year'?s?|events?|editions?|conferences?)\b/i.test(
73 message,
74 )
75 )
76 return true
77 if (
78 !/\b(?:unleash|event|conference|edition|speakers?|agenda|sponsors?|tickets?|badges?|passes)\b/i.test(
79 message,
80 )
81 )
82 return false
83 const datedMessage = message.replace(
84 /\b20\d{2}[\s-]*(?:employees?|staff|people|attendees?|tickets?|passes|badges?|euros?|dollars?|EUR|USD)\b/gi,
85 '',
86 )
87 const years = [...datedMessage.matchAll(/\b20\d{2}\b/g)].map((match) =>
88 Number(match[0]),
89 )
90 return years.length > 0 && years.every((value) => value < year)
91}
92
93export function policyReply(
94 message: string,
95 history: Array<{ role: 'user' | 'assistant'; text: string }> = [],
96): string | undefined {
97 // Consent names the privacy policy; forwarding a requested enquiry does not export saved PII.
98 if (confirmedHandoff(history, message)) return undefined
99 const previous =
100 history.findLast((turn) => turn.role === 'assistant')?.text || ''
101 if (
102 /\b(?:send|forward|share)\b.{0,25}\bmy (?:information|details)\b.{0,20}\b(?:through|over|to (?:the|your) team)\b/i.test(
103 message,
104 ) &&
105 /\b(?:sponsorship|exhibiting|enquiry|work email)\b/i.test(previous) &&
106 !/\b(?:export|retrieve|stored|saved|personal data)\b/i.test(message)
107 )
108 return undefined
109 if (isPersonalDataRequest(message)) return PRIVACY_REPLY
110 if (isPastEventRequest(message)) return PAST_EVENT_REPLY
111 if (isEmailDelivery(message)) return undefined
112 if (
113 /\bprivacy policy\b|\bwho can (?:read|see|access) (?:my|this|our) (?:chat|conversation)\b/i.test(
114 message,
115 )
116 )
117 return PRIVACY_POLICY_REPLY
118 if (
119 /\bconfirmation (?:email|e-mail)\b/i.test(message) &&
120 /\b(?:not|haven.t|didn.t|missing|never|resend|received|receive)\b/i.test(
121 message,
122 )
123 )
124 return 'Please check your spam or junk folder first. If your confirmation email is still missing, contact [support@unleash.ai](mailto:support@unleash.ai) with the email you used to register and the event name. Our team can help; I can’t access your booking here.'
125 if (
126 /\b(?:file|make|raise|submit) (?:a )?complaint\b|\bcomplaints? (?:email|address|contact)\b/i.test(
127 message,
128 )
129 )
130 return 'Please email [support@unleash.ai](mailto:support@unleash.ai) with the event name, what happened and any relevant booking reference so our team can look into your complaint.'
131 if (
132 /\b(?:who (?:should|can|do) I (?:email|contact)|how do I contact|(?:which|what) (?:email|e-mail)(?: address)?|(?:give|provide|send)(?: me)?(?: your| an| the)? (?:email|contact) address)\b|^(?:your )?(?:email|contact) address(?: please)?[?.!\s]*$/i.test(
133 message,
134 )
135 ) {
136 const email = supportEmail(message)
137 return `Please contact [${email}](mailto:${email}) and our team can help.`
138 }
139 if (/\b(?:phone|telephone) number\b/i.test(message))
140 return `For help, please contact [${supportEmail(message)}](mailto:${supportEmail(message)}). I don’t have a verified public phone number to provide.`
141 return undefined
142}
143
144export type SupportEmail = 'support@unleash.ai' | 'customersuccess@unleash.ai'
145
146export function supportEmail(
147 message: string,
148 spexDestination = false,
149): SupportEmail {
150 if (
151 /\b(?:privacy|personal data|data processing|my data)\b/i.test(message) ||
152 isPersonalDataRequest(message)
153 )
154 return 'support@unleash.ai'
155 if (/\b(?:refunds?|hotels?|agenda)\b/i.test(message))
156 return 'support@unleash.ai'
157 if (
158 /\b(?:tickets?|registration)\b/i.test(message) &&
159 !/\b(?:sponsor\w*|exhibit\w*)\b/i.test(message)
160 )
161 return 'support@unleash.ai'
162 if (
163 /\b(?:not|no longer) (?:a |an )?(?:sponsor|exhibitor)|\bnot (?:interested in )?(?:sponsoring|exhibiting)\b/i.test(
164 message,
165 )
166 )
167 return 'support@unleash.ai'
168 if (
169 spexDestination ||
170 /\b(?:sponsor(?:ship|ing|s)?|exhibit(?:ion|or|ors|ing)?|spex|stand (?:plans?|build(?:ing|ers?)?|design|contractors?)|exhibitor manual|startup (?:programme|program|networking|award))\b/i.test(
171 message,
172 )
173 )
174 return 'customersuccess@unleash.ai'
175 return 'support@unleash.ai'
176}
177
178/** Replace only complete policy tokens, retaining possible prefixes across chunks.
179 * Ordinary text is released immediately; no whole-answer buffering is necessary.
180 */
181export function responsePolicyStream(email: SupportEmail) {
182 const replacements = [
183 // Application-owned failure copy always routes to Support, including SPEX.
184 [HANDOFF_FAILURE_REPLY.toLowerCase(), HANDOFF_FAILURE_REPLY],
185 ['unleash world', 'UNLEASH WORLD'],
186 ...['support', 'customersuccess', 'events'].flatMap((name) => [
187 [`${name}@unleash.ai`, email],
188 [`${name}\\@unleash.ai`, email],
189 ]),
190 ]
191 let pending = ''
192 const decoder = new TextDecoder()
193 const encoder = new TextEncoder()
194 const drain = (flush: boolean) => {
195 let output = ''
196 while (pending) {
197 const lower = pending.toLowerCase()
198 const match = replacements.find(([from]) => lower.startsWith(from))
199 if (match) {
200 output += match[1]
201 pending = pending.slice(match[0].length)
202 } else if (!flush && replacements.some(([from]) => from.startsWith(lower)))
203 break
204 else {
205 output += pending[0]
206 pending = pending.slice(1)
207 }
208 }
209 return output
210 }
211 return new TransformStream<Uint8Array, Uint8Array>({
212 transform(chunk, controller) {
213 pending += decoder.decode(chunk, { stream: true })
214 const text = drain(false)
215 if (text) controller.enqueue(encoder.encode(text))
216 },
217 flush(controller) {
218 pending += decoder.decode()
219 const text = drain(true)
220 if (text) controller.enqueue(encoder.encode(text))
221 },
222 })
223}
224