import { NextRequest, NextResponse } from 'next/server'; import { generateObject } from 'ai'; import { z } from 'zod'; import { prompts } from '@/lib/llm/prompts'; import { llmClient } from '@/lib/llm/client'; import { withSafety } from '@/lib/llm/safety'; import { traceLLMCall } from '@/lib/observability'; import { getColdStartRatelimit } from '@/lib/ratelimit'; const TopicSchema = z.object({ topic: z .string() .min(8) .max(300) .describe('A natural-language learning intent phrased as the learner would type it.'), }); const LOCALE_NAMES: Record = { en: 'English', fr: 'French', es: 'Spanish', de: 'German', pt: 'Portuguese', it: 'Italian', nl: 'Dutch', ja: 'Japanese', zh: 'Chinese', ar: 'Arabic', ru: 'Russian', ko: 'Korean', }; // Domain nudge diversifies suggestions across calls — the model otherwise // gravitates to a handful of crowd-pleasers. const DOMAINS = [ 'physics', 'biology', 'economics', 'history', 'computer science', 'chemistry', 'astronomy', 'linguistics', 'mathematics', 'neuroscience', 'music', 'geology', 'everyday objects', 'the human body', 'engineering', 'art history', ]; /** * GET /api/surprise — AI-chosen learning topic ("Spark something"). * * Returns a single fresh learning intent the client then feeds into the normal * /api/intent flow. Rate-limited on the cold-start limiter (it spends a model call). * All LLM access via llmClient.generator (invariant #1); prompt from registry (#2). */ export async function GET(req: NextRequest): Promise { const limiter = getColdStartRatelimit(); if (limiter) { const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'anonymous'; const { success } = await limiter.limit(ip); if (!success) { return NextResponse.json({ error: 'Too many requests' }, { status: 429 }); } } const locale = req.cookies.get('NEXT_LOCALE')?.value ?? 'en'; const localeName = LOCALE_NAMES[locale] ?? 'English'; const domain = DOMAINS[Math.floor(Math.random() * DOMAINS.length)]; const systemPrompt = withSafety(prompts.SUGGEST_TOPIC.template); const userPrompt = `Suggest one learning intent. Lean toward ${domain} this time, but only if a genuinely intriguing question fits. Phrase the topic in ${localeName}.`; try { const start = Date.now(); type TopicResult = { object: { topic: string }; usage: { promptTokens?: number; completionTokens?: number } | undefined }; let genResult!: TopicResult; try { genResult = await generateObject({ model: llmClient.generator, schema: TopicSchema, system: systemPrompt, prompt: userPrompt, temperature: 1, maxTokens: 512, }); } catch (genErr) { const raw = (genErr as Record)?.text; if (typeof raw === 'string') { const { repairTruncatedJson } = await import('@/lib/llm/repair'); const parsed = TopicSchema.parse(JSON.parse(repairTruncatedJson(raw))); genResult = { object: parsed, usage: (genErr as Record)?.usage as TopicResult['usage'] }; } else { throw genErr; } } const { object, usage } = genResult; void traceLLMCall({ name: 'suggest-topic', role: 'generator', model: process.env.LLM_GENERATOR_MODEL ?? 'unknown', input: userPrompt, output: object, latencyMs: Date.now() - start, usage: { promptTokens: usage?.promptTokens, completionTokens: usage?.completionTokens }, metadata: { domain, locale }, }); return NextResponse.json({ topic: object.topic }); } catch (err) { console.error('[GET /api/surprise]', err); return NextResponse.json({ error: 'Failed to suggest a topic' }, { status: 500 }); } }