-
Notifications
You must be signed in to change notification settings - Fork 0
Add static-mode sentence generation and grading #16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<GenerateResponse, 'sentence_id'> | ||
|
|
@@ -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 }) | ||
| } | ||
|
Comment on lines
+80
to
+82
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Return a non-200 status for This branch returns an Use a status the client already treats as a failure, and handle the code in the practice UI. Status 409 states that the request was valid but the corpus cannot satisfy it. 🐛 Proposed fix for the response contract if (!staticSentence) {
- return NextResponse.json({ error: 'not_enough_static_content' }, { status: 200 })
+ return NextResponse.json({ error: 'not_enough_static_content' }, { status: 409 })
}Run the following script to find the client callers that must handle this response: #!/bin/bash
# Description: Locate client-side callers of /api/generate and how they branch on the response.
set -euo pipefail
rg -n -g '!node_modules' -C 8 "api/generate"
# Check whether any caller already handles the static-content code.
rg -n -g '!node_modules' -C 4 'not_enough_static_content'🤖 Prompt for AI Agents |
||
|
|
||
| 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') | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| import type { createClient } from '@/lib/supabase/server' | ||
| import type { CorpusSentence } from '@/types' | ||
|
|
||
| type SupabaseServerClient = Awaited<ReturnType<typeof createClient>> | ||
|
|
||
| export type StaticSentenceRow = Pick<CorpusSentence, 'id' | 'sentence_zh' | 'sentence_py' | 'vocab_used'> | ||
|
|
||
| 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<StaticSentenceRow | null> { | ||
| 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 | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: grilledcheese1/DeckGenie
Length of output: 21531
🏁 Script executed:
Repository: grilledcheese1/DeckGenie
Length of output: 14512
🌐 Web query:
@supabase/postgrest-js 2.105.4 .in implementation quote values filter💡 Result:
In
@supabase/postgrest-js, the.in filter method automatically manages the quoting of values to ensure compatibility with PostgREST's URL grammar [1][2][3]. When you use.in(column, values), the library performs the following logic: 1. Identification of Reserved Characters: It checks if a value contains PostgREST reserved characters (specifically commas,, parentheses (, or)), which require the value to be quoted in the generated URL [1][2][4]. 2. Automatic Quoting: If any reserved character is detected, the value is wrapped in double quotes (e.g., "value") [1][2][4]. 3. Escaping: To prevent parsing errors, the library automatically escapes double quotes (") and backslashes () that exist within these values [1][2]. It uses the backslash convention (\"for a literal double quote and\\for a literal backslash) [2]. For example, a filter like.in("username", ["a"b,c"]) is correctly serialized to include escaped characters, ensuring the PostgREST API can parse it as a single value containing both a comma and a double quote [1][2]. Important Considerations: - Simple values that do not contain reserved characters are emitted as-is without extra quotes [1][2]. - This automatic handling is built into the.in and.notIn methods; you do not need to manually escape or quote your input values [2][3]. - If you use the lower-level.filter method (the "escape hatch"), the library does not perform this automatic quoting or escaping, and you must follow PostgREST syntax manually [5][6][3]. Top results: [1], [2], [4]Citations:
🏁 Script executed:
Repository: grilledcheese1/DeckGenie
Length of output: 12633
🏁 Script executed:
Repository: grilledcheese1/DeckGenie
Length of output: 1608
🏁 Script executed:
Repository: grilledcheese1/DeckGenie
Length of output: 2899
🏁 Script executed:
Repository: grilledcheese1/DeckGenie
Length of output: 484
Validate and bound
recentat the request boundary.A
nullelement throws. Non-stringzhandpyvalues are coerced into the PostgREST filter orvarietyBlock. Each string is also unbounded. Normalizerecentonce after parsing, retain entries with stringzhandpyvalues, and cap both strings before use.🤖 Prompt for AI Agents