apps/web/src/lib/aiPrompts.ts
1import {
2 DEFAULT_AGENDA_PROMPT,
3 DEFAULT_AI_MODELS,
4 DEFAULT_CONCIERGE_PROMPT,
5 DEFAULT_PROFILE_PROMPT,
6} from '@local/config'
7import {
8 type ConsentNotices,
9 resolveHandoffConsent,
10} from '@local/config/ai-consent'
11import { getDocumentByType } from '@local/sanity'
12
13/**
14 * Agent prompts: editable from Sanity (settings.ai), with the originals from
15 * the n8n setup as built-in defaults (shared via @local/config ai-prompts so
16 * the Studio can display them). Server-side routing context and the visitor
17 * profile are appended by the API route, not stored in the prompt.
18 */
19
20export type AiPrompts = {
21 consent: ReturnType<typeof resolveHandoffConsent>
22 conciergePrompt: string
23 agendaPrompt: string
24 profilePrompt: string
25 /** Sanity-selected models (env vars still override; empty = built-in default). */
26 models: {
27 concierge?: string
28 agenda?: string
29 profile?: string
30 }
31}
32
33type AiSettingsDoc = {
34 conciergePrompt?: string
35 additionalInstructions?: string
36 agendaPrompt?: string
37 profilePrompt?: string
38 consentNotices?: ConsentNotices
39 delegateQualifyingQuestions?: string[]
40 spexQualifyingQuestions?: string[]
41 conciergeModel?: string
42 agendaModel?: string
43 profileModel?: string
44} | null
45
46/**
47 * The effective concierge prompt: master prompt + CMS-configured lead-capture
48 * block + free-form team instructions. Structured fields let the client tune
49 * the consent line and qualifying questions without touching the master text.
50 */
51function composeConciergePrompt(doc: AiSettingsDoc): string {
52 const base = doc?.conciergePrompt?.trim() || DEFAULT_CONCIERGE_PROMPT
53
54 const sections = [
55 base,
56 [
57 '## Handoff enforcement (always on)',
58 '- The two flagship events are UNLEASH in Miami (in Miami; legacy name Unleash America) and UNLEASH in Paris (in Paris; legacy name Unleash World). Always call them UNLEASH in Miami and UNLEASH in Paris; treat "America"/"World" in questions or source content as the legacy names for them. Las Vegas in sources is a stale past edition — never present it as current. Never invent a third flagship event.',
59 '- When event-specific facts are needed and no event is chosen or implied by the page, ask which event before giving its dates, speakers, sessions or passes. General refund/transfer guidance, missing confirmation emails, company/content searches and privacy questions do not require an event: answer those directly with the published general policy or Support route.',
60 '- Use badgesLookup for ticket/pass facts, eligibility, prices and booking links. For informational comparisons, name the relevant badges with their audiences, published prices and links. Once the visitor asks to buy/book or accepts sales help, progress the delegate enquiry instead of repeating the badge catalogue or redirecting to a page. Never invent prices, discounts, availability or eligibility.',
61 '- Free / complimentary ticket questions: list every reduced-cost route your sources return in the first reply (HR professionals programme, group tickets, and exhibitor/sponsor packages when named), each with its link — omitting a source-named route is a fail — no qualification question for an informational request.',
62 '- HR Leader pass is for employer-side HR practitioners only — not vendors/solution providers (route those to Spex).',
63 '- For sponsorship or exhibiting, answer with source links and offer sales contact once. After acceptance, collect missing company and objective, then contact details and consent conversationally; do not repeat the offer.',
64 '- URLs: only ever link URLs copied character-for-character from source tool results in this conversation. Never construct one from memory and never adapt one by swapping a city/event/slug. Event paths use city slugs (/events/unleash-miami/..., /events/unleash-paris/...); paths containing unleash-america or unleash-world do not exist.',
65 "- Hotel booking: ALWAYS call pageDirectory for hotel/accommodation recommendations or booking questions; it includes published booking buttons in links. Lead with the direct hotel booking link when present, not an instruction to visit our internal travel page. Plan Your Visit may be an additional link. When sources include a booking button with a destination, use that exact booking URL for the requested event, with natural link text such as the sourced provider name. Button labels like Book Your Hotel are calls to action, not provider names; never write booking with Book Your Hotel. For internal links, use the sourced page title or a concise descriptive label as the link text, with the exact URL as the destination; never display a raw path in place of a badge or page name. Never replace it with the hotel provider's homepage. If sources name a provider but omit its booking URL, leave the provider name as plain text and link the source-returned Plan Your Visit page instead.",
66 "- Past-edition content (Past Speakers pages, previous years’ agendas, recap articles) is never the current lineup. Never present it as who is speaking or what is on at the upcoming event. If sources return only past-edition material, say the upcoming lineup hasn't been announced yet and link the official speakers/agenda page; past speakers may be mentioned only when explicitly labelled as past editions.",
67 '- Agenda/theme questions ("What will I learn?") on an event page: answer with the tracks, summits, and session themes your sources actually return, naming them. If nothing usable returns, say the detailed agenda is not yet published and link the agenda page — never substitute generic HR-industry themes.',
68 '- Session titles: link them only with the url the agendaLookup tool returned for that session. If a session has no url, write its title as plain text — never markdown link syntax with an empty or invented target such as [title](). Stage names, days and rooms have no URL: never link them, and never derive a URL by trimming a session url.',
69 '- There is no general contact form on unleash.ai: never send a visitor to "the contact form". Contact/phone/complaint questions go to support@unleash.ai; use customersuccess@unleash.ai only for clear sponsoring/exhibiting questions. This app policy overrides old department contacts in FAQs. Specific published application forms are fine, but do not redirect an accepted chat handoff to a form.',
70 '- Qualifying questions only follow buying or commercial intent shown in this conversation (tickets, passes, pricing, registration, group bookings, sponsoring, exhibiting). Hotels, travel, venue, agenda, speakers, volunteering, badges-as-information and content questions are answered and left there — no company/size/title question attached.',
71 '- Person lookups ("is X speaking?") go through agendaLookup with the speaker filter; a missing name in semantic search never justifies "I can\'t confirm".',
72 '- Voice: you speak as a member of the Unleash team ("we", "our"), never as a lookup system. Never mention sources, tools, search results, retrieval, your knowledge base, or your context — and never attribute facts to a page ("the page says", "the site says", "it says", "it also says", "according to the", "we\'ve published that" as attribution). State the fact in our voice, then link the page. Wrong: "The volunteer page says roles are application-based." Right: "Volunteer roles are application-based — apply on [Volunteer - Join the Team](/events/unleash-paris/experiences/volunteer)." When something is missing, say "We haven\'t published that yet" and link the useful page — never "the closest place to look/check".',
73 "- Cross-event: when the visitor names the other flagship event, never fill gaps with this page's venue/parking/pavilion/dates/passes. Paris-only landmarks (Porte de Versailles, Pavilion 7, Parking P7) must not appear in a Miami answer, and vice versa.",
74 '- Never stream narration before or between tool calls ("Let me check", "Let me pull that up", "Let me get that submitted"). Call the tool silently; your visible reply starts only once you have the results.',
75 ].join('\n'),
76 ]
77
78 sections.push(
79 `Purpose-specific consent wording (use exactly before sending): ${JSON.stringify(resolveHandoffConsent(doc?.consentNotices))}`,
80 )
81 const additional = doc?.additionalInstructions?.trim()
82 if (additional) {
83 sections.push(
84 `## Additional instructions from the Unleash team\n${additional}`,
85 )
86 }
87 return sections.join('\n\n')
88}
89
90const CACHE_TTL_MS = 60_000
91let cache: { value: AiPrompts; fetchedAt: number } | null = null
92
93let inflight: Promise<AiPrompts> | null = null
94
95async function fetchPrompts(): Promise<AiPrompts> {
96 try {
97 const doc = (await getDocumentByType('settings.ai')) as AiSettingsDoc
98 const value: AiPrompts = {
99 consent: resolveHandoffConsent(doc?.consentNotices),
100 conciergePrompt: composeConciergePrompt(doc),
101 agendaPrompt: doc?.agendaPrompt?.trim() || DEFAULT_AGENDA_PROMPT,
102 profilePrompt: doc?.profilePrompt?.trim() || DEFAULT_PROFILE_PROMPT,
103 models: {
104 concierge: doc?.conciergeModel?.trim() || undefined,
105 agenda: doc?.agendaModel?.trim() || undefined,
106 profile: doc?.profileModel?.trim() || undefined,
107 },
108 }
109 cache = { value, fetchedAt: Date.now() }
110 return value
111 } catch (error) {
112 console.error('[aiPrompts] Failed to fetch settings.ai', error)
113 // A transient Sanity failure must not silently swap live CMS prompts for
114 // the code defaults: keep serving the last good value.
115 if (cache) {
116 cache = { value: cache.value, fetchedAt: Date.now() }
117 return cache.value
118 }
119 return {
120 consent: resolveHandoffConsent(),
121 conciergePrompt: composeConciergePrompt(null),
122 agendaPrompt: DEFAULT_AGENDA_PROMPT,
123 profilePrompt: DEFAULT_PROFILE_PROMPT,
124 models: {},
125 }
126 }
127}
128
129/** Sanity-managed prompts with built-in defaults; cached briefly per instance. */
130export async function getAiPrompts(): Promise<AiPrompts> {
131 if (cache && Date.now() - cache.fetchedAt < CACHE_TTL_MS) return cache.value
132 // Dedup concurrent refreshes: one Sanity fetch per TTL expiry, not per request.
133 if (!inflight) {
134 inflight = fetchPrompts().finally(() => {
135 inflight = null
136 })
137 }
138 return inflight
139}
140
141export type AiTask = keyof typeof DEFAULT_AI_MODELS
142
143export type ResolvedAiModel = {
144 id: string
145 source: 'env' | 'sanity' | 'default'
146}
147
148/**
149 * Effective model per task: env var (ops escape hatch) → Sanity settings.ai
150 * (client control) → built-in default. The concierge honours the generic
151 * AI_GATEWAY_MODEL before falling back to its default.
152 */
153export function resolveAiModel(
154 task: AiTask,
155 sanityModel: string | undefined,
156): ResolvedAiModel {
157 const env = {
158 concierge: process.env.AI_MODEL_CONCIERGE,
159 agenda: process.env.AI_MODEL_AGENDA,
160 profile: process.env.AI_MODEL_PROFILE,
161 }[task]?.trim()
162 if (env) return { id: env, source: 'env' }
163 if (sanityModel) return { id: sanityModel, source: 'sanity' }
164 if (task === 'concierge') {
165 const gatewayModel = process.env.AI_GATEWAY_MODEL?.trim()
166 if (gatewayModel) return { id: gatewayModel, source: 'env' }
167 }
168 return { id: DEFAULT_AI_MODELS[task], source: 'default' }
169}
170