apps/web/src/routes/api/ai/profile.ts
1import { createGateway } from '@ai-sdk/gateway'
2import { propagateAttributes } from '@langfuse/tracing'
3import type {
4 ProfileExtractRequestPayload,
5 UserProfile,
6 UserProfileStage,
7} from '@local/ai'
8import {
9 DECISION_PILLARS,
10 parseDecisionPillars,
11} from '@local/config/decision-pillars'
12import type { APIEvent } from '@solidjs/start/server'
13import { waitUntil } from '@vercel/functions'
14import { generateObject, jsonSchema } from 'ai'
15import { checkAiGuards } from '~/lib/aiGuards'
16import { getAiPrompts, resolveAiModel } from '~/lib/aiPrompts'
17import { flushAiTelemetry, initAiTelemetry } from '~/lib/aiTelemetry'
18import { contactAnswer } from '~/lib/handoffContact'
19import {
20 parseUserProfile,
21 retainTicketBooking,
22 topicalInterests,
23} from '~/lib/userProfile'
24
25/**
26 * Profile extraction: a cheap fast model reads the latest visitor message and returns
27 * an updated visitor profile (name, email, role, company, interests, stage).
28 * Called before the chat request so the next question uses the latest facts.
29 */
30
31const STAGE_ORDER: UserProfileStage[] = [
32 'anonymous',
33 'engaged',
34 'identified',
35 'qualified',
36]
37
38const PROFILE_SCHEMA = jsonSchema<{
39 decisionPillars: string[]
40 ticketQuantity: number | null
41 ticketPass: string | null
42 ticketEvent: 'paris' | 'miami' | null
43 name: string | null
44 email: string | null
45 role: string | null
46 company: string | null
47 interests: string[]
48 stage: UserProfileStage
49}>({
50 type: 'object',
51 properties: {
52 decisionPillars: {
53 type: 'array',
54 items: { type: 'string', enum: DECISION_PILLARS.map((pillar) => pillar.id) },
55 },
56 ticketQuantity: { type: ['integer', 'null'], minimum: 1, maximum: 10000 },
57 ticketPass: { type: ['string', 'null'] },
58 ticketEvent: { type: ['string', 'null'], enum: ['paris', 'miami', null] },
59 name: { type: ['string', 'null'] },
60 email: { type: ['string', 'null'] },
61 role: { type: ['string', 'null'] },
62 company: { type: ['string', 'null'] },
63 interests: { type: 'array', items: { type: 'string' } },
64 stage: {
65 type: 'string',
66 enum: ['anonymous', 'engaged', 'identified', 'qualified'],
67 },
68 },
69 required: [
70 'decisionPillars',
71 'name',
72 'email',
73 'role',
74 'company',
75 'interests',
76 'stage',
77 'ticketQuantity',
78 'ticketPass',
79 'ticketEvent',
80 ],
81 additionalProperties: false,
82})
83
84function getProfileModel(sanityModel?: string) {
85 return resolveAiModel('profile', sanityModel).id
86}
87
88/** Stage may only move forward; a flaky extraction must never demote a lead. */
89function maxStage(
90 a: UserProfileStage | undefined,
91 b: UserProfileStage | undefined,
92): UserProfileStage {
93 const ai = STAGE_ORDER.indexOf(a ?? 'anonymous')
94 const bi = STAGE_ORDER.indexOf(b ?? 'anonymous')
95 return STAGE_ORDER[Math.max(ai, bi, 0)]
96}
97
98export async function POST({ request }: APIEvent) {
99 initAiTelemetry()
100 let body: Partial<ProfileExtractRequestPayload>
101 try {
102 body = (await request.json()) as Partial<ProfileExtractRequestPayload>
103 } catch {
104 return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
105 }
106
107 if (!body || typeof body !== 'object' || Array.isArray(body))
108 return Response.json({ error: 'Invalid request body' }, { status: 400 })
109 const message = typeof body.message === 'string' ? body.message.trim() : ''
110 if (!message) {
111 return Response.json({ error: 'Missing message' }, { status: 400 })
112 }
113 if (message.length > 4000) {
114 return Response.json({ error: 'Message is too long' }, { status: 400 })
115 }
116 const reply =
117 typeof body.reply === 'string' ? body.reply.trim().slice(0, 4000) : ''
118 // Client input is untrusted: cap lengths before it reaches the prompt.
119 const current: UserProfile = contactAnswer(
120 parseUserProfile(body.profile) ?? {},
121 reply,
122 message,
123 )
124 if (checkAiGuards(request, '', `profile:${message}`).kind !== 'ok')
125 return Response.json(
126 { error: 'Please wait before trying again.' },
127 { status: 429 },
128 )
129
130 try {
131 const prompts = await getAiPrompts()
132 const apiKey = process.env.AI_GATEWAY_API_KEY || process.env.AI_GATEWAY
133 const gateway = createGateway(apiKey ? { apiKey } : {})
134
135 const chatId = typeof body.chat_id === 'string' ? body.chat_id.trim() : ''
136 const { object } = await propagateAttributes(
137 {
138 traceName: 'profile-extract',
139 sessionId: chatId || undefined,
140 tags: ['profile'],
141 },
142 () =>
143 generateObject({
144 model: gateway(getProfileModel(prompts.models.profile)),
145 schema: PROFILE_SCHEMA,
146 system: `${prompts.profilePrompt}\nExtract only facts explicitly supplied by the visitor about themselves. The previous assistant question only disambiguates short answers (e.g. a company and role); never treat assistant suggestions or example names as visitor facts. Preserve known fields when the current message adds nothing. Apply explicit corrections immediately. Retain existing interests unless the visitor changes them. Interests are learning topics, never transactional tasks such as tickets, badges, bookings, registration, or emailing an agenda. Contact details never imply consent. Never derive name or company from an email address. Store explicitly selected agenda decision pillars separately in decisionPillars, using these canonical IDs and labels: ${JSON.stringify(DECISION_PILLARS)}. A number refers to a pillar only when answering the numbered pillar question. Never treat the offered choices or a bare yes as selected pillars. Retain prior selections unless the visitor changes them. Keep free-text interests separately.`,
147 prompt: [
148 `Page context: ${typeof body.location === 'string' ? body.location.slice(0, 500) : ''}. Save explicit booking quantity and requested pass separately from interests. Scope booking details to the explicitly named event or event page. If the visitor switches ticket event or starts a new booking, clear old booking details not supplied for that booking; preserve personal/company facts. A correction replaces the affected booking field.`,
149 `Current profile: ${JSON.stringify(current)}`,
150 `Visitor message: ${message.slice(0, 4000)}`,
151 reply ? `Previous assistant question (context only): ${reply}` : '',
152 'Return the updated profile.',
153 ]
154 .filter(Boolean)
155 .join('\n\n'),
156 maxOutputTokens: 500,
157 abortSignal: request.signal,
158 telemetry: { functionId: 'profile-extract' },
159 }),
160 )
161 const finishFlush = flushAiTelemetry()
162 try {
163 waitUntil(finishFlush)
164 } catch {
165 void finishFlush
166 }
167
168 // The model proposes; the server disposes: never let extraction erase a
169 // previously known value, and never let the stage move backward.
170 const profile: UserProfile = {
171 decisionPillars: parseDecisionPillars(object.decisionPillars),
172 ticketQuantity: object.ticketQuantity ?? undefined,
173 ticketPass: object.ticketPass?.trim() || undefined,
174 ticketEvent: object.ticketEvent ?? undefined,
175 name: object.name?.trim() || current.name,
176 email: object.email?.trim().toLowerCase() || current.email,
177 role: object.role?.trim() || current.role,
178 company: object.company?.trim() || current.company,
179 interests: object.interests?.length
180 ? topicalInterests(object.interests.filter(Boolean).slice(0, 20))
181 : (current.interests ?? []),
182 stage: maxStage(current.stage, object.stage),
183 }
184
185 return Response.json({
186 profile: retainTicketBooking(
187 profile,
188 current,
189 message,
190 typeof body.location === 'string' ? body.location : '',
191 reply,
192 ),
193 })
194 } catch (error) {
195 console.error('[api/ai/profile] extraction failed', error)
196 // Extraction is best-effort: on failure, echo the current profile back.
197 return Response.json({ profile: current })
198 }
199}
200