diff --git a/src/app/api/generate/route.ts b/src/app/api/generate/route.ts index 96571c8..3612688 100644 --- a/src/app/api/generate/route.ts +++ b/src/app/api/generate/route.ts @@ -1,7 +1,9 @@ import { NextRequest, NextResponse } from 'next/server' +import Anthropic from '@anthropic-ai/sdk' import { requireUser } from '@/lib/api/auth' import { checkRateLimit } from '@/lib/api/ratelimit' import { callClaudeJson, ClaudeResponseError } from '@/lib/llm' +import { selectStaticSentence } from '@/lib/staticSentences' import { GenerateResponse } from '@/types' type ClaudeSentence = Omit @@ -15,6 +17,13 @@ function isClaudeSentence(value: unknown): value is ClaudeSentence { && v.vocab_used.every(w => typeof w === 'string') } +// recent entries are echoed back by the client from prior responses — the +// declared request body type is a compile-time assertion only, not runtime +// validation of req.json()'s actual (any-typed) result. Every entry ends up +// either in a Supabase filter or interpolated into the LLM prompt, so +// malformed/oversized values must be dropped and bounded here. +const MAX_RECENT_FIELD_LENGTH = 200 + export async function POST(req: NextRequest) { const auth = await requireUser() if (auth instanceof NextResponse) return auth @@ -30,7 +39,63 @@ export async function POST(req: NextRequest) { let body: { recent?: Array<{ zh: string; py: string }> } = {} try { body = await req.json() } catch { /* no body */ } - const recent = Array.isArray(body?.recent) ? body.recent.slice(0, 5) : [] + const rawRecent = Array.isArray(body?.recent) ? body.recent.slice(0, 5) : [] + const recent = rawRecent + .filter((r): r is { zh: string; py: string } => typeof r?.zh === 'string' && typeof r?.py === 'string') + .map(r => ({ + zh: r.zh.slice(0, MAX_RECENT_FIELD_LENGTH), + py: r.py.slice(0, MAX_RECENT_FIELD_LENGTH), + })) + + const { data: userSettings } = await supabase + .from('settings') + .select('practice_mode') + .eq('user_id', user.id) + .single() + + const practiceMode = userSettings?.practice_mode ?? 'static' + + if (practiceMode === 'static') { + // The client's variety-tracking sends recently-served {zh, py} pairs, not + // sentence_bank ids — resolve them to ids here so selectStaticSentence can + // exclude by id without the client needing to know about static-mode IDs. + let recentIds: string[] = [] + const recentZh = recent.map(r => r.zh).filter(Boolean) + if (recentZh.length > 0) { + const { data: recentRows } = await supabase + .from('sentence_bank') + .select('id') + .in('sentence_zh', recentZh) + recentIds = (recentRows ?? []).map((r: { id: string }) => r.id) + } + + let staticSentence + try { + staticSentence = await selectStaticSentence(supabase, user.id, { recentIds }) + } catch (err) { + console.error('selectStaticSentence error:', err) + return NextResponse.json({ error: 'Generation failed' }, { status: 500 }) + } + + if (!staticSentence) { + return NextResponse.json({ error: 'not_enough_static_content' }, { status: 409 }) + } + + const response: GenerateResponse = { + sentence_id: staticSentence.id, + sentence_zh: staticSentence.sentence_zh, + sentence_py: staticSentence.sentence_py, + vocab_used: staticSentence.vocab_used, + } + return NextResponse.json(response) + } + + // practiceMode === 'ai' + const apiKeyHeader = req.headers.get('X-Anthropic-Key') + if (!apiKeyHeader) { + return NextResponse.json({ error: 'Missing Anthropic API key' }, { status: 400 }) + } + const anthropicClient = new Anthropic({ apiKey: apiKeyHeader }) const { data: vocab } = await supabase .from('vocab_list') @@ -70,7 +135,7 @@ Respond with ONLY valid JSON, no markdown: {"sentence_zh":"...","sentence_py":"...","vocab_used":["zh_word1","zh_word2"]}` try { - const claudeSentence = await callClaudeJson(prompt, 256, isClaudeSentence) + const claudeSentence = await callClaudeJson(prompt, 256, isClaudeSentence, anthropicClient) const { data: inserted, error: insertError } = await supabase .from('generated_sentences') diff --git a/src/app/api/grade/route.ts b/src/app/api/grade/route.ts index cc74f83..ff51f85 100644 --- a/src/app/api/grade/route.ts +++ b/src/app/api/grade/route.ts @@ -1,10 +1,16 @@ import { NextRequest, NextResponse } from 'next/server' import { waitUntil } from '@vercel/functions' +import Anthropic from '@anthropic-ai/sdk' import { requireUser } from '@/lib/api/auth' import { checkRateLimit } from '@/lib/api/ratelimit' import { callClaudeJson, ClaudeResponseError } from '@/lib/llm' import { GradeRequest, GradeResponse } from '@/types' +// Applies regardless of source table — sentence text is always server-issued, +// never client-supplied, but a defensive cap keeps a bad/oversized row from +// blowing up the grading prompt either way. +const MAX_SENTENCE_LENGTH = 200 + function isGradeResponse(value: unknown): value is GradeResponse { if (!value || typeof value !== 'object') return false const v = value as Record @@ -25,6 +31,10 @@ export async function POST(req: NextRequest) { if (auth instanceof NextResponse) return auth const { user, supabase } = auth + // Rate limiting and input-length caps apply before any mode branching, so + // they cover both modes identically — static-mode grading still spends the + // app's own Anthropic credits per call, so it needs the same protection + // 'ai' mode always had. const rateLimit = await checkRateLimit(user.id, 'grade') if (!rateLimit.success) { return NextResponse.json( @@ -44,18 +54,72 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Input too large' }, { status: 400 }) } - const { data: sentenceRow, error: sentenceError } = await supabase - .from('generated_sentences') - .select('sentence_zh, sentence_py, vocab_used') - .eq('id', sentence_id) + const { data: userSettings } = await supabase + .from('settings') + .select('practice_mode') .eq('user_id', user.id) .single() + const practiceMode = userSettings?.practice_mode ?? 'static' + + let anthropicClient: Anthropic + if (practiceMode === 'ai') { + const apiKeyHeader = req.headers.get('X-Anthropic-Key') + if (!apiKeyHeader) { + return NextResponse.json({ error: 'Missing Anthropic API key' }, { status: 400 }) + } + anthropicClient = new Anthropic({ apiKey: apiKeyHeader }) + } else { + anthropicClient = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! }) + } + + // Sentence text is always loaded from a server-issued row, never trusted + // from the client body — which table depends on which mode produced it. + // practice_mode is re-read fresh above, but sentence_id reflects whatever + // mode was active when the sentence was generated — the two can disagree + // if the user switches modes (via the settings slide-in) between fetching + // a sentence and submitting an answer — so fall back to the other table + // before giving up. + type SentenceRow = { sentence_zh: string; sentence_py: string; vocab_used: string[] } | null + + const lookupStatic = () => + supabase + .from('sentence_bank') + .select('sentence_zh, sentence_py, vocab_used') + .eq('id', sentence_id) + .single() + + const lookupAi = () => + supabase + .from('generated_sentences') + .select('sentence_zh, sentence_py, vocab_used') + .eq('id', sentence_id) + .eq('user_id', user.id) + .single() + + let sentenceRow: SentenceRow = null + let sentenceError: unknown = null + + const primary = await (practiceMode === 'static' ? lookupStatic() : lookupAi()) + sentenceRow = primary.data + sentenceError = primary.error + + if (!sentenceRow) { + const fallback = await (practiceMode === 'static' ? lookupAi() : lookupStatic()) + sentenceRow = fallback.data + sentenceError = fallback.error + } + if (sentenceError || !sentenceRow) { return NextResponse.json({ error: 'Sentence not found' }, { status: 404 }) } const { sentence_zh, sentence_py, vocab_used } = sentenceRow + + if (sentence_zh.length > MAX_SENTENCE_LENGTH || sentence_py.length > MAX_SENTENCE_LENGTH) { + return NextResponse.json({ error: 'Sentence data invalid' }, { status: 500 }) + } + const truncatedAnswer = user_answer.slice(0, 500) const prompt = `You are grading a Chinese-to-English translation exercise. @@ -75,7 +139,7 @@ Respond with ONLY valid JSON, no markdown: {"correct":true or false,"score":0-100,"feedback":"one concise sentence","correct_answer":"the most natural English translation"}` try { - const parsed = await callClaudeJson(prompt, 200, isGradeResponse) + const parsed = await callClaudeJson(prompt, 200, isGradeResponse, anthropicClient) parsed.correct = parsed.score >= 70 // Tracking writes must never block or fail the grade response, but they also diff --git a/src/hooks/usePractice.ts b/src/hooks/usePractice.ts index 254d764..bc9e5eb 100644 --- a/src/hooks/usePractice.ts +++ b/src/hooks/usePractice.ts @@ -35,6 +35,9 @@ export function usePractice(strictness: number = 2, practiceMode: Settings['prac }) if (!res.ok) { const body = await res.json().catch(() => null) + if (body?.error === 'not_enough_static_content') { + throw new Error('Not enough matching sentences yet — unlock more vocabulary to keep practicing.') + } throw new Error(body?.error || 'Failed to generate a sentence.') } const data: GenerateResponse = await res.json() diff --git a/src/lib/llm.ts b/src/lib/llm.ts index 541ee2b..618214d 100644 --- a/src/lib/llm.ts +++ b/src/lib/llm.ts @@ -14,11 +14,12 @@ export class ClaudeResponseError extends Error { export async function callClaudeJson( prompt: string, maxTokens: number, - validate: (value: unknown) => value is T + validate: (value: unknown) => value is T, + client: Anthropic = anthropic ): Promise { let message try { - message = await anthropic.messages.create({ + message = await client.messages.create({ model: MODEL, max_tokens: maxTokens, messages: [{ role: 'user', content: prompt }], diff --git a/src/lib/staticSentences.ts b/src/lib/staticSentences.ts new file mode 100644 index 0000000..0299beb --- /dev/null +++ b/src/lib/staticSentences.ts @@ -0,0 +1,87 @@ +import type { createClient } from '@/lib/supabase/server' +import type { CorpusSentence } from '@/types' + +type SupabaseServerClient = Awaited> + +export type StaticSentenceRow = Pick + +interface SelectStaticSentenceOpts { + recentIds: string[] +} + +// Number of top (lowest-accuracy) candidates to randomize among, so static +// mode doesn't always serve the single "weakest word" sentence on repeat. +const TOP_POOL_SIZE = 5 + +// Matches this project's Supabase `max_rows` cap (supabase/config.toml) — a +// single response is silently truncated at this many rows, so the candidate +// query must paginate or it will quietly lose rows once the corpus (already +// 726 rows, ~1050 once fully seeded) exceeds this for a given hsk_level. +const PAGE_SIZE = 1000 + +export async function selectStaticSentence( + supabase: SupabaseServerClient, + userId: string, + opts: SelectStaticSentenceOpts +): Promise { + const [{ data: settings }, { data: vocab, error: vocabError }] = await Promise.all([ + // Settings errors/missing rows fall back to the default HSK level below — + // that's an intentional degraded-but-working path, not a failure to propagate. + supabase.from('settings').select('starting_hsk').eq('user_id', userId).single(), + supabase.from('vocab_list').select('word_zh, times_seen, times_correct').eq('user_id', userId), + ]) + + // A query failure must not be reported as "not enough content" — that tells + // the caller to fall back to a legitimate empty state when the real problem + // is the DB call itself. + if (vocabError) throw new Error(`vocab_list query failed: ${vocabError.message}`) + if (!vocab || vocab.length === 0) return null + + const maxHsk = settings?.starting_hsk ?? 2 + const ownedWords = new Set(vocab.map((w: { word_zh: string }) => w.word_zh)) + const accuracyByWord = new Map( + vocab.map((w: { word_zh: string; times_seen: number; times_correct: number }) => + [w.word_zh, w.times_seen > 0 ? w.times_correct / w.times_seen : 0.5] + ) + ) + + const candidates: StaticSentenceRow[] = [] + for (let from = 0; ; from += PAGE_SIZE) { + const { data: page, error: pageError } = await supabase + .from('sentence_bank') + .select('id, sentence_zh, sentence_py, vocab_used') + .lte('hsk_level', maxHsk) + .range(from, from + PAGE_SIZE - 1) + if (pageError) throw new Error(`sentence_bank query failed: ${pageError.message}`) + if (!page || page.length === 0) break + candidates.push(...(page as StaticSentenceRow[])) + if (page.length < PAGE_SIZE) break + } + + if (candidates.length === 0) return null + + const recentSet = new Set(opts.recentIds) + + const eligible = candidates.filter(row => { + if (recentSet.has(row.id)) return false + // A sentence can't be served if it uses a word the user hasn't unlocked. + return row.vocab_used.every(zh => ownedWords.has(zh)) + }) + + if (eligible.length === 0) return null + + // Mirror generate/route.ts's accuracy-sort: prefer sentences whose words the + // user is weakest on (lower average accuracy = needs more practice), then + // pick with light randomization among the top candidates. + const scored = eligible.map(row => { + const accs = row.vocab_used.map(zh => accuracyByWord.get(zh) ?? 0.5) + const avgAccuracy = accs.length > 0 ? accs.reduce((a, b) => a + b, 0) / accs.length : 0.5 + return { row, avgAccuracy } + }) + scored.sort((a, b) => a.avgAccuracy - b.avgAccuracy) + + const topPool = scored.slice(0, Math.min(TOP_POOL_SIZE, scored.length)) + const picked = topPool[Math.floor(Math.random() * topPool.length)] + + return picked.row +}