Files
Curio/src/app/api/surprise/route.ts
T
arnaudne 5bf6013460 feat: full feature buildout — streaming, i18n, mastery map, admin, jobs
Progressive lesson streaming via onSegment callback (fixes SSE for non-English
users — locale was shadowed in lesson-reader useEffect). Adds: BullMQ workers,
Redis stream buffer, token budget enforcement, Langfuse tracing, golden-eval
runner, Playwright e2e scaffolding, lesson depth/locale/preferences schema,
mastery map UI, admin panel (blueprints/users/reports/quality/misconceptions),
image queries, source citations, view transitions, reading animations, i18n
(next-intl), PDF export, surprise endpoint, and 402 passing unit tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 22:08:14 +02:00

97 lines
3.7 KiB
TypeScript

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<string, string> = {
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<NextResponse> {
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<string, unknown>)?.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<string, unknown>)?.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 });
}
}