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