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>
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user