5bf6013460
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>
97 lines
3.7 KiB
TypeScript
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 });
|
|
}
|
|
}
|