import { NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { auth } from '@/auth' import { getSystemConfig } from '@/lib/config' import { reserveAiUsageOrThrow } from '@/lib/ai-quota' import { QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements' import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent' import { getSlidesProvider } from '@/lib/ai/factory' import { generateInteractiveDemoFromContent } from '@/lib/ai/services/interactive-demo-generate.service' export const maxDuration = 180 const requestSchema = z.object({ content: z.string().min(20), selection: z.string().optional().nullable(), lang: z.string().optional(), noteId: z.string().optional(), }) function stripHtml(html: string): string { return html .replace(/<[^>]+>/g, ' ') .replace(/ /g, ' ') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/\s+/g, ' ') .trim() } export async function POST(req: NextRequest) { try { const session = await auth() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } if (!(await hasUserAiConsent())) { return aiConsentForbiddenResponse() } const body = await req.json() const parsed = requestSchema.parse(body) // Keep raw HTML/markdown so formula extractors see $...$ / KaTeX like slides const sourceRaw = parsed.selection?.trim() || parsed.content const sourcePlain = stripHtml(sourceRaw) const wordCount = sourcePlain.split(/\s+/).filter(Boolean).length if (wordCount < 20) { return NextResponse.json( { error: 'Sélectionne au moins ~20 mots de contenu pour générer une démo interactive', }, { status: 400 } ) } try { await reserveAiUsageOrThrow(session.user.id, 'interactive_demo', { lane: 'chat', }) } catch (err) { if (err instanceof QuotaExceededError) { return NextResponse.json(err.toJSON(), { status: 402 }) } if ( err instanceof QuotaServiceUnavailableError || process.env.NODE_ENV === 'production' ) { return NextResponse.json( { error: 'QUOTA_SERVICE_UNAVAILABLE' }, { status: 503 } ) } console.error('[/api/ai/interactive-demo] Quota check error (fail-open):', err) } const config = await getSystemConfig() const lang = parsed.lang || 'fr' // Same admin model as slide decks (AI_PROVIDER_SLIDES / AI_MODEL_SLIDES → chat fallback) const provider = getSlidesProvider(config) const result = await generateInteractiveDemoFromContent({ content: sourceRaw, lang, provider, }) if (!result.ok) { const first = result.issues[0] const detail = first ? `${first.path ? first.path + ': ' : ''}${first.message}` : '' console.error( '[/api/ai/interactive-demo] validation failed after repairs', result.issues.slice(0, 10) ) return NextResponse.json( { error: detail ? `Démo invalide après correction — ${detail}` : 'La génération a produit un JSON invalide après correction', issues: result.issues.slice(0, 10), attempts: result.attempts, }, { status: 422 } ) } return NextResponse.json({ demo: result.demo, attempts: result.attempts, }) } catch (error: unknown) { if (error instanceof z.ZodError) { return NextResponse.json({ error: error.issues }, { status: 400 }) } const message = error instanceof Error ? error.message : 'Erreur génération interactive demo' console.error('[/api/ai/interactive-demo]', error) return NextResponse.json({ error: message }, { status: 500 }) } }