-
Notifications
You must be signed in to change notification settings - Fork 0
Saving graded outcomes #18
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 2 commits
b1c8673
e97fc13
e1ae82d
d73a5ab
1ff616f
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 |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| # Future: semantic (embeddings) matching for the grade cache | ||
|
|
||
| Status: **not started**. Written 2026-08-19 as a placeholder so this isn't forgotten once | ||
| there's enough real usage to justify it. | ||
|
|
||
| ## Context | ||
|
|
||
| `/api/grade` already has an exact-match cache (`supabase/migrations/20260819000000_add_grade_cache.sql`, | ||
| `find_cached_grade` RPC, wired into `src/app/api/grade/route.ts`). It matches on | ||
| `(sentence_zh, strictness_used, normalize_answer(user_answer))`, where `normalize_answer` just | ||
| lowercases/trims/collapses whitespace — a cache hit only ever fires for text that's byte-identical | ||
| after that normalization, so it has **zero risk of serving a wrong grade**. It misses true | ||
| paraphrases ("I want to buy this book" vs "I'd like to buy this book"). | ||
|
|
||
| We discussed extending this to fuzzy/character-similarity matching (edit distance, trigram | ||
| similarity, a "70% similar" threshold) and **rejected it**: string-similarity metrics can't tell a | ||
| paraphrase from a meaning-flipping edit. "I don't like apples" vs "I do like apples" score as | ||
| highly similar by edit distance despite being opposite in meaning — the same mechanism that would | ||
| catch "buy" ≈ "purchase" also catches "don't" ≈ "do", so there's no threshold that gets the gain | ||
| without the risk. Not worth the correctness regression for a grading feature. | ||
|
|
||
| Embeddings are the sound version of the same idea: they're trained to place semantically different | ||
| sentences far apart even when textually close (so negation pairs score *less* similar, not more), | ||
| so cosine similarity on embeddings is a meaningfully better signal for "does this mean the same | ||
| thing" than any character-level metric. Still not perfect — don't treat it as risk-free. | ||
|
|
||
| ## Step 0 — measure first, before building anything | ||
|
|
||
| Do **not** start this on a total-row-count trigger — total rows spread across ~1,050 seeded | ||
| sentences × 3 strictness levels tells you almost nothing. What matters is concentration: how many | ||
| attempts land on the *same* `(sentence_zh, strictness_used)` bucket, since that's the granularity | ||
| the cache matches on. `selectStaticSentence` (`src/lib/staticSentences.ts`) narrows to the top-5 | ||
| lowest-accuracy candidates among a user's *unlocked* vocab, and early on that vocab is small (20 | ||
| words at signup), so traffic concentrates onto a small set of easy/common sentences before vocab | ||
| has grown out — expect a Zipfian pileup on a handful of sentences well before the corpus as a | ||
| whole has meaningful volume. | ||
|
|
||
| Run this (static mode only — that's the mode this cache protects financially; AI-mode sentences | ||
| are per-user generated and won't repeat across users at all): | ||
|
|
||
| ```sql | ||
| select sentence_zh, strictness_used, count(*) as attempts, | ||
| count(distinct user_answer_normalized) as distinct_answers | ||
| from sentence_attempts | ||
| group by sentence_zh, strictness_used | ||
| order by attempts desc | ||
| limit 30; | ||
| ``` | ||
|
|
||
| `attempts / distinct_answers` on the top rows is a direct read on the exact-match cache's real hit | ||
| rate where it matters most. Eyeball the `distinct_answers` for a top bucket — if most of them look | ||
| like genuine near-duplicates a human would call "the same answer" (not just typos, which exact-match | ||
| already catches), that's the signal semantic matching would help. As an order-of-magnitude starting | ||
| point: check in once you're around a few hundred total static-mode grade attempts — that's roughly | ||
| when the top few sentences should have accumulated double digits of repeat attempts, enough to read | ||
| something from the ratio instead of noise. If the top buckets' near-duplicate rate is low, skip this | ||
| feature — it isn't worth the added cost/complexity/risk. | ||
|
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 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- PLAN.md (lines 1-100) ---'
cat -n PLAN.md | sed -n '1,100p'
printf '%s\n' '--- relevant symbols and schema references ---'
rg -n -S --hidden \
'sentence_attempts|find_cached_grade|feedback|strictness_used|user_answer_normalized|static.?mode|ai.?mode|embedding' \
-g '!node_modules' -g '!dist' -g '!build' . | sed -n '1,240p'Repository: grilledcheese1/DeckGenie Length of output: 19384 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- sentence_attempts schema ---'
cat -n supabase/schema.sql | sed -n '208,235p'
printf '%s\n' '--- grade-cache migration ---'
cat -n supabase/migrations/20260819000000_add_grade_cache.sql | sed -n '1,90p'
printf '%s\n' '--- grade route ---'
cat -n src/app/api/grade/route.ts | sed -n '1,225p'
printf '%s\n' '--- static and AI mode call sites ---'
rg -n -C 8 -S \
'practice_mode|staticSentences|sentence_zh|/api/grade|api/grade' \
src/app src/lib src/components | sed -n '1,300p'Repository: grilledcheese1/DeckGenie Length of output: 37286 🏁 Script executed: #!/bin/bash
set -e
python3 - <<'PY'
from pathlib import Path
import re
schema = Path("supabase/schema.sql").read_text()
migration = Path("supabase/migrations/20260819000000_add_grade_cache.sql").read_text()
route = Path("src/app/api/grade/route.ts").read_text()
table = re.search(
r'CREATE TABLE IF NOT EXISTS "public"\."sentence_attempts" \((.*?)\n\);',
schema,
re.S,
)
assert table, "sentence_attempts definition not found"
columns = {
re.match(r'\s*"([^"]+)"', line).group(1)
for line in table.group(1).splitlines()
if re.match(r'\s*"([^"]+)"', line)
}
cache_predicates = {
name: name in migration
for name in ("feedback IS NOT NULL", "user_answer_normalized", "strictness_used", "sentence_zh")
}
insert_fields = set(re.findall(r'^\s{4}([a-z_][a-z0-9_]*)\s*:', route, re.M))
print("sentence_attempts base columns:", sorted(columns))
print("has persisted mode discriminator:", any(
"mode" in column or "practice" in column for column in columns
))
print("cache predicates present:", cache_predicates)
print("insert includes feedback:", "feedback" in insert_fields)
print("insert includes mode discriminator:", any(
"mode" in field or "practice" in field for field in insert_fields
))
print("cache lookup occurs for both mode branches:",
"if (practiceMode === 'ai')" in route and ".rpc('find_cached_grade'" in route
)
assert not any("mode" in column or "practice" in column for column in columns)
assert cache_predicates["feedback IS NOT NULL"]
assert "feedback" in insert_fields
assert not any("mode" in field or "practice" in field for field in insert_fields)
# For N recorded, cache-eligible rows and D distinct normalized answers,
# each normalized answer has one miss and later repeats can hit.
n, d = 12, 4
print("sample exact-cache hit rate:", (n - d) / n)
print("sample attempts/distinct_answers:", n / d)
assert (n - d) / n != n / d
PYRepository: grilledcheese1/DeckGenie Length of output: 747 🏁 Script executed: #!/bin/bash
set -e
python3 - <<'PY'
from pathlib import Path
import re
schema = Path("supabase/schema.sql").read_text()
migration = Path("supabase/migrations/20260819000000_add_grade_cache.sql").read_text()
route = Path("src/app/api/grade/route.ts").read_text()
table = re.search(
r'CREATE TABLE IF NOT EXISTS "public"\."sentence_attempts" \((.*?)\n\);',
schema,
re.S,
)
assert table, "sentence_attempts definition not found"
columns = {
re.match(r'\s*"([^"]+)"', line).group(1)
for line in table.group(1).splitlines()
if re.match(r'\s*"([^"]+)"', line)
}
cache_predicates = {
name: name in migration
for name in ("feedback IS NOT NULL", "user_answer_normalized", "strictness_used", "sentence_zh")
}
insert_fields = set(re.findall(r'^\s{4}([a-z_][a-z0-9_]*)\s*:', route, re.M))
print("sentence_attempts base columns:", sorted(columns))
print("has persisted mode discriminator:", any(
"mode" in column or "practice" in column for column in columns
))
print("cache predicates present:", cache_predicates)
print("insert includes feedback:", "feedback" in insert_fields)
print("insert includes mode discriminator:", any(
"mode" in field or "practice" in field for field in insert_fields
))
print("cache lookup occurs for both mode branches:",
"if (practiceMode === 'ai')" in route and ".rpc('find_cached_grade'" in route
)
assert not any("mode" in column or "practice" in column for column in columns)
assert cache_predicates["feedback IS NOT NULL"]
assert "feedback" in insert_fields
assert not any("mode" in field or "practice" in field for field in insert_fields)
n, d = 12, 4
print("sample exact-cache hit rate:", (n - d) / n)
print("sample attempts/distinct_answers:", n / d)
assert (n - d) / n != n / d
PYRepository: grilledcheese1/DeckGenie Length of output: 747 Make Step 0 measure cache-eligible static attempts and label the metric as a proxy.
🤖 Prompt for AI Agents |
||
|
|
||
| ## If the data justifies building it | ||
|
|
||
| 1. **Enable `pgvector`** on the Supabase project (`CREATE EXTENSION IF NOT EXISTS vector;`). | ||
| 2. **Add an embedding column** to `sentence_attempts`, e.g. `user_answer_embedding vector(N)` | ||
| (dimension depends on the embedding model chosen), populated at insert time in | ||
| `src/app/api/grade/route.ts` alongside the existing `insertAttempt` write. | ||
|
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 | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- tracked files ---'
git ls-files | sed -n '1,160p'
printf '%s\n' '--- PLAN.md ---'
cat -n PLAN.md | sed -n '1,120p'
printf '%s\n' '--- relevant symbols and schema references ---'
rg -n -S \
'sentence_attempts|insertAttempt|embedding|grade-cache|cache|strictness|feedback|SECURITY DEFINER|user_answer' \
-g '!node_modules' -g '!dist' -g '!build' . | sed -n '1,260p'Repository: grilledcheese1/DeckGenie Length of output: 32068 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- grade-cache migration ---'
cat -n supabase/migrations/20260819000000_add_grade_cache.sql
printf '%s\n' '--- grade route cache and insert flow ---'
cat -n src/app/api/grade/route.ts | sed -n '1,235p'
printf '%s\n' '--- sentence-attempt schema and mode-related migrations ---'
cat -n supabase/migrations/20260806180151_add_generated_sentences.sql
cat -n supabase/migrations/20260817000000_add_sentence_bank_and_practice_mode.sql
cat -n supabase/schema.sql | sed -n '200,235p'
printf '%s\n' '--- sentence selection and practice request shape ---'
cat -n src/lib/staticSentences.ts | sed -n '1,220p'
cat -n src/hooks/usePractice.ts | sed -n '55,100p'Repository: grilledcheese1/DeckGenie Length of output: 24668 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- AI sentence generation and mode persistence ---'
cat -n src/app/api/generate/route.ts | sed -n '1,220p'
rg -n -S 'practice_mode|sentence_id|generated_sentences|sentence_bank' src/app src/hooks src/types supabase/migrations | sed -n '1,240p'
printf '%s\n' '--- deterministic plan/schema verifier ---'
python3 - <<'PY'
from pathlib import Path
import re
plan = Path("PLAN.md").read_text()
migration = Path("supabase/migrations/20260819000000_add_grade_cache.sql").read_text()
route = Path("src/app/api/grade/route.ts").read_text()
generated = Path("supabase/migrations/20260806180151_add_generated_sentences.sql").read_text()
mode_migration = Path("supabase/migrations/20260817000000_add_sentence_bank_and_practice_mode.sql").read_text()
checks = {
"plan adds an unversioned vector column": bool(re.search(r"user_answer_embedding\s+vector\(N\)", plan)),
"plan explicitly says no backfill": "No backfill needed for old rows" in plan,
"cache lookup has no embedding-model predicate": "embedding_model" not in migration and "embedding_version" not in migration,
"cache lookup key has sentence and strictness and normalized answer": all(x in migration for x in (
"sentence_zh = p_sentence_zh",
"strictness_used = p_strictness",
"user_answer_normalized = public.normalize_answer(p_user_answer)",
)),
"attempt insert has no embedding model or mode field": "embedding_model" not in route and "embedding_version" not in route and "practice_mode" not in route.split("insertAttempt", 1)[-1],
"sentence_attempts has no practice_mode column in current mode migration": "practice_mode" not in generated and "practice_mode" not in mode_migration,
}
for name, result in checks.items():
print(f"{result!s:5} {name}")
PYRepository: grilledcheese1/DeckGenie Length of output: 14306 Store the embedding model version with each vector. A model revision can keep the same dimension but use a different vector space. Filter cache lookups and the vector index by this version. Otherwise, changing models can compare new query vectors with old stored vectors and return incorrect grades. The no-backfill rule does not prevent this because existing populated vectors remain eligible. 🤖 Prompt for AI Agents |
||
| 3. **Call an embedding API** for the incoming answer before the cache lookup (cheap/fast relative | ||
| to a grading call, but it's a real added cost+latency on every grade request — factor that in). | ||
|
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. 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win Resolve the embedding lookup order. Step 3 calls the embedding API before the cache lookup. The open question says to run the exact-match lookup first and embed only after a miss. If the fast path is intended, call Also applies to: 87-88 🤖 Prompt for AI Agents |
||
| 4. **Replace (or supplement) the exact-match RPC** with a similarity query: still filter on | ||
| `sentence_zh = ... AND strictness_used = ...` (those must still match exactly — only the answer | ||
| comparison becomes fuzzy), then order by cosine distance to the new answer's embedding | ||
|
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 Preserve completed-attempt eligibility in the semantic lookup. The existing 🤖 Prompt for AI Agents |
||
| (`<=>` operator with an `ivfflat` or `hnsw` index) and only accept a match above a conservative | ||
| threshold. Start the threshold high (few false positives) and only loosen it based on observed | ||
| accuracy — don't guess a number up front. | ||
| 5. **No backfill needed for old rows** — same pattern as `feedback`: only rows with an embedding | ||
| populated are eligible matches, historical rows just never match until re-graded (fine). | ||
| 6. **Mirror the existing `find_cached_grade` SECURITY DEFINER pattern** (see | ||
| `supabase/migrations/20260806171015_drop_p_user_id_use_auth_uid.sql` for the REVOKE/GRANT | ||
| convention this project requires on every new function) rather than inventing a new access | ||
| pattern. | ||
|
|
||
| ## Open questions to resolve when this is picked up | ||
|
|
||
| - Which embedding provider/model, and what's the actual per-call cost at expected volume — needs | ||
| to stay well below the cost of the Anthropic grading call it's trying to avoid, or the whole | ||
| feature is pointless. | ||
| - What similarity threshold actually holds up — needs eyeballing real (sentence, candidate-match) | ||
| pairs near the cutoff, not a number picked in the abstract. | ||
| - Whether to keep exact-match as a fast-path before falling through to the embedding lookup | ||
| (probably yes — it's free and zero-risk, only pay for an embedding call on an exact-match miss). | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -29,7 +29,19 @@ export async function POST(req: NextRequest) { | |
| if (auth instanceof NextResponse) return auth | ||
| const { user, supabase } = auth | ||
|
|
||
| const rateLimit = await checkRateLimit(user.id, 'generate') | ||
| // practice_mode is read before rate limiting so the budget can be chosen | ||
| // per mode — 'ai' spends the user's own key (no budget protection needed, | ||
| // just a light anti-hammering cap) while 'static' only costs Supabase | ||
| // reads (same light cap, for a different reason). | ||
| const { data: userSettings } = await supabase | ||
| .from('settings') | ||
| .select('practice_mode') | ||
| .eq('user_id', user.id) | ||
| .single() | ||
|
|
||
| const practiceMode = userSettings?.practice_mode ?? 'static' | ||
|
|
||
| const rateLimit = await checkRateLimit(user.id, practiceMode === 'ai' ? 'generate_ai' : 'generate_static') | ||
|
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. 🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift Run rate limiting before the settings lookup.
🤖 Prompt for AI Agents |
||
| if (!rateLimit.success) { | ||
| return NextResponse.json( | ||
| { error: 'Too many requests' }, | ||
|
|
@@ -47,14 +59,6 @@ export async function POST(req: NextRequest) { | |
| 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -122,7 +122,35 @@ export async function POST(req: NextRequest) { | |
|
|
||
| const truncatedAnswer = user_answer.slice(0, 500) | ||
|
|
||
| const prompt = `You are grading a Chinese-to-English translation exercise. | ||
| // Cross-user cache: an identical (sentence, strictness, normalized answer) | ||
| // triple may already have been graded by someone else, especially likely | ||
| // in static mode where a shared, finite sentence_bank means the same wrong | ||
| // answers recur across users. A hit skips the Anthropic call entirely — a | ||
| // lookup failure just falls through to a normal miss, never aborts the | ||
| // request. | ||
| // Project has no generated Supabase Database types, so .rpc() results are | ||
| // untyped — narrow to the shape find_cached_grade actually returns. | ||
| const { data: cached, error: cacheError } = await supabase | ||
| .rpc('find_cached_grade', { | ||
| p_sentence_zh: sentence_zh, | ||
| p_strictness: strictness, | ||
| p_user_answer: truncatedAnswer, | ||
| }) | ||
| .maybeSingle() as { data: { score: number; correct_answer: string; feedback: string } | null; error: { message: string } | null } | ||
|
Comment on lines
+131
to
+140
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 🧩 Analysis chain🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
# Locate existing generated database types and typed Supabase client creation.
fd -HI -t f 'database\.types\.ts' . || true
rg -n -C 2 'createClient<|createServerClient<|createBrowserClient<|SupabaseClient<' src supabaseRepository: grilledcheese1/DeckGenie Length of output: 162 🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '--- candidate files ---'
git ls-files | rg '(^|/)(route\.ts|.*migration.*|.*supabase.*|package\.json|.*types.*)$' | head -200
printf '%s\n' '--- route context ---'
route="$(git ls-files | rg 'src/app/api/grade/route\.ts$' | head -1)"
if [ -n "$route" ]; then
cat -n "$route" | sed -n '100,160p'
fi
printf '%s\n' '--- RPC definition and usages ---'
rg -n -C 8 'find_cached_grade|createClient|createServerClient|SupabaseClient|Database' . \
-g '!node_modules' -g '!dist' -g '!build' | head -300Repository: grilledcheese1/DeckGenie Length of output: 24929 🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '--- Supabase client files ---'
cat -n src/lib/supabase/client.ts
cat -n src/lib/supabase/server.ts
printf '%s\n' '--- package scripts and Supabase dependencies ---'
python3 - <<'PY'
import json
with open('package.json') as f:
p=json.load(f)
print(json.dumps({
"scripts": p.get("scripts", {}),
"dependencies": {k:v for k,v in p.get("dependencies", {}).items() if "supabase" in k.lower()},
"devDependencies": {k:v for k,v in p.get("devDependencies", {}).items() if "supabase" in k.lower()},
}, indent=2))
PY
printf '%s\n' '--- migration definition ---'
cat -n supabase/migrations/20260819000000_add_grade_cache.sql | sed -n '1,95p'
printf '%s\n' '--- Supabase project guidance ---'
cat -n supabase/README.mdRepository: grilledcheese1/DeckGenie Length of output: 7872 🌐 Web query:
💡 Result: To integrate TypeScript types with your Supabase project, you can use the Supabase CLI to generate a types file from your database schema and provide it as a generic to the Supabase client [1][2]. Generating Types The Supabase CLI extracts your database schema and generates a TypeScript definition file (e.g., database.types.ts) [1][2]. You can generate these types using the following command [1]: npx supabase gen types typescript --project-id "$PROJECT_REF" --schema public > database.types.ts For local development, use: npx supabase gen types typescript --local > database.types.ts Using Types with createClient Once you have your types file, import the Database interface and pass it as a generic to the createClient function [1][2]. This enables end-to-end type safety for your database operations [3]: import { createClient } from ' Citations:
Use schema-derived types for Generate 🤖 Prompt for AI Agents |
||
|
|
||
| if (cacheError) console.error('find_cached_grade error:', cacheError.message) | ||
|
|
||
| let parsed: GradeResponse | ||
|
|
||
| if (cached) { | ||
| parsed = { | ||
| correct: false, // recomputed below from score, never trusted from a cached row | ||
| score: cached.score, | ||
| feedback: cached.feedback, | ||
| correct_answer: cached.correct_answer, | ||
| } | ||
| } else { | ||
| const prompt = `You are grading a Chinese-to-English translation exercise. | ||
|
|
||
| Chinese sentence: ${sentence_zh} | ||
| Pinyin: ${sentence_py} | ||
|
|
@@ -138,43 +166,46 @@ Grading mode: ${STRICTNESS[strictness] ?? STRICTNESS[2]} | |
| 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, anthropicClient) | ||
| parsed.correct = parsed.score >= 70 | ||
|
|
||
| // Tracking writes must never block or fail the grade response, but they also | ||
| // can't be truly fire-and-forget — Vercel freezes the function once the | ||
| // response is sent, which was silently dropping these. waitUntil keeps the | ||
| // invocation alive until they finish without making the client wait for them. | ||
| const recordAttempts = vocab_used?.length | ||
| ? Promise.all(vocab_used.map((zh: string) => | ||
| supabase.rpc('record_word_attempt', { | ||
| p_word_zh: zh, | ||
| p_correct: parsed.correct, | ||
| }) | ||
| )).catch(err => console.error('record_word_attempt error:', err)) | ||
| : Promise.resolve() | ||
|
|
||
| const insertAttempt = supabase.from('sentence_attempts').insert({ | ||
| user_id: user.id, | ||
| sentence_zh, | ||
| sentence_py, | ||
| user_answer: body.user_answer, | ||
| correct_answer: parsed.correct_answer, | ||
| score: parsed.score, | ||
| correct: parsed.correct, | ||
| strictness_used: strictness, | ||
| vocab_used: vocab_used ?? [], | ||
| }).then(({ error }) => { | ||
| if (error) console.error('sentence_attempts insert error:', error.message) | ||
| }) | ||
|
|
||
| waitUntil(Promise.allSettled([recordAttempts, insertAttempt])) | ||
|
|
||
| return NextResponse.json(parsed) | ||
| } catch (err) { | ||
| console.error('Grade error:', err) | ||
| const status = err instanceof ClaudeResponseError ? 502 : 500 | ||
| return NextResponse.json({ error: 'Grading failed' }, { status }) | ||
| try { | ||
| parsed = await callClaudeJson(prompt, 200, isGradeResponse, anthropicClient) | ||
| } catch (err) { | ||
| console.error('Grade error:', err) | ||
| const status = err instanceof ClaudeResponseError ? 502 : 500 | ||
| return NextResponse.json({ error: 'Grading failed' }, { status }) | ||
| } | ||
| } | ||
|
|
||
| parsed.correct = parsed.score >= 70 | ||
|
|
||
| // Tracking writes must never block or fail the grade response, but they also | ||
| // can't be truly fire-and-forget — Vercel freezes the function once the | ||
| // response is sent, which was silently dropping these. waitUntil keeps the | ||
| // invocation alive until they finish without making the client wait for them. | ||
| const recordAttempts = vocab_used?.length | ||
| ? Promise.all(vocab_used.map((zh: string) => | ||
| supabase.rpc('record_word_attempt', { | ||
| p_word_zh: zh, | ||
| p_correct: parsed.correct, | ||
| }) | ||
| )).catch(err => console.error('record_word_attempt error:', err)) | ||
| : Promise.resolve() | ||
|
|
||
| const insertAttempt = supabase.from('sentence_attempts').insert({ | ||
| user_id: user.id, | ||
| sentence_zh, | ||
| sentence_py, | ||
| user_answer: body.user_answer, | ||
| correct_answer: parsed.correct_answer, | ||
| score: parsed.score, | ||
| correct: parsed.correct, | ||
| feedback: parsed.feedback, | ||
| strictness_used: strictness, | ||
| vocab_used: vocab_used ?? [], | ||
| }).then(({ error }) => { | ||
| if (error) console.error('sentence_attempts insert error:', error.message) | ||
| }) | ||
|
|
||
| waitUntil(Promise.allSettled([recordAttempts, insertAttempt])) | ||
|
|
||
| return NextResponse.json(parsed) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| -- Turns sentence_attempts into a cross-user grading cache for /api/grade. | ||
| -- Static-mode grading always spends the app's own ANTHROPIC_API_KEY, and the | ||
| -- static sentence_bank is shared across all users, so an exact-match cache on | ||
| -- (sentence_zh, strictness_used, normalized user_answer) can skip a real | ||
| -- Anthropic call whenever the same wrong answer has already been graded | ||
| -- before, by any user. | ||
|
|
||
| -- feedback was never persisted by /api/grade's insert — required to | ||
| -- reconstruct a complete GradeResponse from a cached row. | ||
| ALTER TABLE public.sentence_attempts ADD COLUMN feedback text; | ||
|
|
||
| -- Normalization lives in exactly one place (SQL), reused by the generated | ||
| -- column below and by the lookup RPC, so application code never duplicates | ||
| -- this logic. | ||
| CREATE FUNCTION public.normalize_answer(p text) | ||
| RETURNS text | ||
| LANGUAGE sql | ||
| IMMUTABLE | ||
| PARALLEL SAFE | ||
| AS $$ | ||
| SELECT lower(trim(regexp_replace(p, '\s+', ' ', 'g'))) | ||
| $$; | ||
|
|
||
| -- Indexable, always-consistent normalized form of user_answer. | ||
| ALTER TABLE public.sentence_attempts | ||
| ADD COLUMN user_answer_normalized text | ||
| GENERATED ALWAYS AS (public.normalize_answer(user_answer)) STORED; | ||
|
Comment on lines
+25
to
+27
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. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
# Check whether this repository has existing non-transactional migration support.
rg -n -C 3 'CREATE INDEX CONCURRENTLY|supabase (db push|migration)|BEGIN;|COMMIT;' \
supabase package.json .github 2>/dev/null || trueRepository: grilledcheese1/DeckGenie Length of output: 795 🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '--- migration files ---'
git ls-files 'supabase/migrations/*' | sort
printf '%s\n' '--- target migration ---'
cat -n supabase/migrations/20260819000000_add_grade_cache.sql
printf '%s\n' '--- config and migration documentation ---'
fd -i 'config.toml|README.md|package.json|route.ts' supabase src package.json .github 2>/dev/null | sort
printf '%s\n' '--- relevant references ---'
rg -n -C 4 'sentence_attempts|normalize_answer|CREATE INDEX|GENERATED ALWAYS|db push|migration' supabase src package.json .github 2>/dev/null || trueRepository: grilledcheese1/DeckGenie Length of output: 32248 🌐 Web query:
💡 Result: In PostgreSQL, the locking behavior and table rewrite requirements for these operations are as follows: Adding a Stored Generated Column Adding a stored generated column requires an ACCESS EXCLUSIVE lock on the table [1][2]. This operation forces the entire table and all of its indexes to be rewritten because the generated value must be calculated and stored for every existing row [1][3]. Virtual generated columns do not require a table rewrite [1][2]. Because it requires an ACCESS EXCLUSIVE lock, this operation will block all other concurrent access (reads and writes) to the table until it completes. CREATE INDEX and Locking The standard CREATE INDEX command requires a SHARE lock on the table [4]. This lock mode allows concurrent reads but prevents concurrent writes (inserts, updates, or deletes) to the table for the duration of the index build [5][6]. CREATE INDEX CONCURRENTLY and Locking The CREATE INDEX CONCURRENTLY command is specifically designed to avoid blocking writes [5][6]. Instead of a SHARE lock, it acquires a SHARE UPDATE EXCLUSIVE lock [4][7][8]. This lock mode allows concurrent reads and writes to the table [5][6]. Because CONCURRENTLY performs the operation in multiple transactions, it takes significantly longer than a standard index build and requires more total work [5][6][9]. The process involves two scans of the table and must wait for existing transactions that could potentially modify or use the index to finish [5][9]. It is important to note that the CONCURRENTLY option cannot be executed within a transaction block [5][6]. If you attempt to run it inside a BEGIN...COMMIT block, PostgreSQL will throw an error [5][6]. Citations:
🌐 Web query:
💡 Result: In PostgreSQL, the CREATE INDEX CONCURRENTLY command cannot be executed within a transaction block [1][2]. This is a core database limitation because the command must commit its own internal transactions during different phases of the build process [2]. Historically, the Supabase CLI encountered issues (SQLSTATE 25001) when running migrations containing CONCURRENTLY statements because the CLI implicitly wrapped all migration statements in a single database pipeline/transaction [3][4]. Current versions of the Supabase CLI have been updated to handle this automatically: 1. Detection: The CLI identifies "pipeline-incompatible" statements, including CREATE INDEX CONCURRENTLY, REINDEX CONCURRENTLY, VACUUM, ALTER SYSTEM, and CLUSTER [5][3]. 2. Automatic Handling: When such a statement is detected in a migration file, the CLI will automatically flush the current batch, execute the incompatible statement outside of the transaction, and then resume batching for subsequent statements [3][6]. 3. Integrity: The migration history record (the entry in the supabase_migrations.schema_migrations table) is inserted only after all statements in the migration file have been successfully executed, ensuring the database remains in a consistent state [3]. You do not need to manually configure your migrations to be non-transactional; simply use CREATE INDEX CONCURRENTLY as you would in standard SQL, and the Supabase CLI will manage the execution flow to avoid the transactional restriction [5][6]. Citations:
🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
import re
p = Path("supabase/migrations/20260819000000_add_grade_cache.sql")
sql = p.read_text()
checks = {
"stored generated column": r"ADD\s+COLUMN\s+user_answer_normalized\s+text\s+GENERATED\s+ALWAYS\s+AS\s*\(",
"plain index": r"(?<!CONCURRENTLY\s)CREATE\s+INDEX\s+sentence_attempts_cache_lookup_idx",
"concurrent index": r"CREATE\s+INDEX\s+CONCURRENTLY",
"transaction control": r"\b(?:BEGIN|COMMIT|ROLLBACK)\s*;",
}
for name, pattern in checks.items():
print(f"{name}: {bool(re.search(pattern, sql, re.I | re.S))}")
print("migration statements:")
for statement in [s.strip() for s in sql.split(";") if s.strip()]:
print(statement.splitlines()[0])
PYRepository: grilledcheese1/DeckGenie Length of output: 1268 Avoid blocking The stored generated column takes an Use an expression index on 🧰 Tools🪛 Squawk (2.61.0)[warning] 27-27: Adding a generated column requires a table rewrite with an (adding-field-with-default) 🤖 Prompt for AI AgentsSource: Linters/SAST tools |
||
|
|
||
| -- Cross-user cache lookup. SECURITY DEFINER to read across all users' rows | ||
| -- for this one narrow, controlled purpose — bypasses the | ||
| -- sentence_attempts_select RLS policy (auth.uid() = user_id) deliberately. | ||
| -- Returns only derived grading fields, never user_id or another user's raw | ||
| -- answer text (the caller already knows their own answer — they just | ||
| -- submitted it in this same request). | ||
| CREATE FUNCTION public.find_cached_grade( | ||
| p_sentence_zh text, | ||
| p_strictness smallint, | ||
| p_user_answer text | ||
| ) | ||
| RETURNS TABLE(score smallint, correct_answer text, feedback text) | ||
| LANGUAGE sql | ||
| SECURITY DEFINER | ||
| STABLE | ||
| AS $$ | ||
| SELECT score, correct_answer, feedback | ||
| FROM public.sentence_attempts | ||
| WHERE sentence_zh = p_sentence_zh | ||
| AND strictness_used = p_strictness | ||
| AND user_answer_normalized = public.normalize_answer(p_user_answer) | ||
| AND feedback IS NOT NULL | ||
| ORDER BY attempted_at DESC | ||
| LIMIT 1 | ||
|
Comment on lines
+35
to
+52
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 Include The grading prompt includes both
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
| $$; | ||
|
|
||
| -- Covering partial index matching the RPC's predicates exactly, so the | ||
| -- lookup is index-only (score/correct_answer/feedback via INCLUDE) and the | ||
| -- ORDER BY ... LIMIT 1 needs no separate sort. Partial on feedback IS NOT | ||
| -- NULL keeps the index small (excludes pre-migration rows) and matches the | ||
| -- RPC's own filter so the planner will actually pick it. | ||
| CREATE INDEX sentence_attempts_cache_lookup_idx | ||
| ON public.sentence_attempts (sentence_zh, strictness_used, user_answer_normalized, attempted_at DESC) | ||
| INCLUDE (score, correct_answer, feedback) | ||
| WHERE feedback IS NOT NULL; | ||
|
|
||
| -- This project has `ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA | ||
| -- public GRANT ALL ON FUNCTIONS TO anon` in effect (see | ||
| -- 20260806171015_drop_p_user_id_use_auth_uid.sql), so every new CREATE | ||
| -- FUNCTION is auto-granted to anon unless explicitly revoked — every | ||
| -- existing RPC in this project follows with this same REVOKE/GRANT block. | ||
| REVOKE ALL ON FUNCTION public.normalize_answer(text) FROM PUBLIC; | ||
| REVOKE ALL ON FUNCTION public.normalize_answer(text) FROM anon; | ||
| GRANT EXECUTE ON FUNCTION public.normalize_answer(text) TO authenticated; | ||
| GRANT EXECUTE ON FUNCTION public.normalize_answer(text) TO service_role; | ||
|
|
||
| REVOKE ALL ON FUNCTION public.find_cached_grade(text, smallint, text) FROM PUBLIC; | ||
| REVOKE ALL ON FUNCTION public.find_cached_grade(text, smallint, text) FROM anon; | ||
| GRANT EXECUTE ON FUNCTION public.find_cached_grade(text, smallint, text) TO authenticated; | ||
| GRANT EXECUTE ON FUNCTION public.find_cached_grade(text, smallint, text) TO service_role; | ||
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.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
Repository: grilledcheese1/DeckGenie
Length of output: 22110
🏁 Script executed:
Repository: grilledcheese1/DeckGenie
Length of output: 35954
🏁 Script executed:
Repository: grilledcheese1/DeckGenie
Length of output: 501
🏁 Script executed:
Repository: grilledcheese1/DeckGenie
Length of output: 514
Add grading validation and isolate cache modes before enabling semantic hits.
Define a labeled evaluation set for negation, entities, scope, and valid paraphrases. Run shadow-mode matching, measure false-positive cached grades, and set a rollback threshold. High cosine similarity does not prove grading equivalence.
The current
find_cached_gradecall is unconditional, including AI mode.sentence_attemptsstores no mode or embedding-model version. Bypass the cache for AI mode or add explicit mode and model-version eligibility filters.🤖 Prompt for AI Agents