Skip to content

Commit de076b7

Browse files
changed to cloudflare api workers
Introduce a /api/flashcards/generate endpoint in the Worker that calls the Cloudflare AI binding (Gemma model) to produce flashcard decks. Implements free-tier guards: per-IP rate limiting, daily D1 usage cap, input truncation, and output token limits; increments ai_usage in D1 and includes a prompt builder and JSON-salvage fallback. Update the web tool to remove client-side Gemini key handling and vision/OCR branching, post source text to the new endpoint, and surface truncation / partial-output hints. Add AI binding to wrangler.jsonc and allow WebFetch in local Claude settings.
1 parent 141b553 commit de076b7

4 files changed

Lines changed: 154 additions & 134 deletions

File tree

.claude/settings.local.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
"Bash(node --check /Users/nico/Documents/GitHub/yoshiromaximus.github.io/src/worker.js)",
1717
"Bash(node *)",
1818
"Bash(git diff *)",
19-
"Read(//tmp/**)"
19+
"Read(//tmp/**)",
20+
"WebFetch(domain:developers.cloudflare.com)"
2021
]
2122
}
2223
}

src/worker.js

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ export default {
2424
if (url.pathname.startsWith('/api/run')) {
2525
return handleRun(request, env, url);
2626
}
27+
if (url.pathname.startsWith('/api/flashcards/generate')) {
28+
return handleFlashcardsGenerate(request, env);
29+
}
2730
return env.ASSETS.fetch(request);
2831
},
2932
};
@@ -186,6 +189,129 @@ async function runSubmit(request, env) {
186189
return json({ ok: true, id: result.meta?.last_row_id }, 201);
187190
}
188191

192+
// ─────────────── Flashcard Generation (Workers AI) ───────────────
193+
// Free-tier guards:
194+
// 1. Workers Free plan (no billing relationship — calls past 10k neurons/day fail, never charge).
195+
// 2. Per-IP rate limit via existing RATE_LIMITER (10/min).
196+
// 3. Input truncated to 12k chars (~3k tokens) before sending.
197+
// 4. Output capped at 2048 tokens.
198+
// 5. Global daily call counter in D1 — refuses past FLASHCARDS_DAILY_CAP/day with headroom.
199+
//
200+
// One-time setup:
201+
// npx wrangler d1 execute gambit-leaderboard --remote \
202+
// --command="CREATE TABLE IF NOT EXISTS ai_usage (date TEXT PRIMARY KEY, calls INTEGER NOT NULL DEFAULT 0);"
203+
204+
const FLASHCARDS_MODEL = '@cf/google/gemma-4-26b-a4b-it';
205+
const FLASHCARDS_MAX_INPUT_CHARS = 12000;
206+
const FLASHCARDS_MAX_OUTPUT_TOKENS = 2048;
207+
const FLASHCARDS_DAILY_CAP = 100; // ~83 neurons per worst-case call × 100 = 8300, leaves 1700 neuron headroom
208+
209+
async function handleFlashcardsGenerate(request, env) {
210+
if (request.method === 'OPTIONS') {
211+
return new Response(null, { status: 204, headers: { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'POST', 'access-control-allow-headers': 'content-type' } });
212+
}
213+
if (request.method !== 'POST') return json({ error: 'Method not allowed' }, 405);
214+
if (!env.AI) return json({ error: 'AI binding not configured.' }, 503);
215+
216+
const ip = request.headers.get('cf-connecting-ip') || 'anon';
217+
if (env.RATE_LIMITER) {
218+
const { success } = await env.RATE_LIMITER.limit({ key: 'flashgen:' + ip });
219+
if (!success) return json({ error: 'Too many requests — wait a minute.' }, 429);
220+
}
221+
222+
const today = new Date().toISOString().slice(0, 10);
223+
if (env.DB) {
224+
try {
225+
const row = await env.DB.prepare('SELECT calls FROM ai_usage WHERE date = ?').bind(today).first();
226+
const callsToday = row?.calls || 0;
227+
if (callsToday >= FLASHCARDS_DAILY_CAP) {
228+
return json({ error: `Daily generation limit reached (${FLASHCARDS_DAILY_CAP}/day). Try again tomorrow.` }, 429);
229+
}
230+
} catch (e) {
231+
// Table missing or other D1 hiccup — fail open but log. Worst case is one extra call.
232+
console.warn('ai_usage check failed:', e.message);
233+
}
234+
}
235+
236+
let body;
237+
try { body = await request.json(); } catch { return json({ error: 'Invalid JSON.' }, 400); }
238+
239+
const text = String(body.text || '').trim();
240+
if (!text) return json({ error: 'No source text provided.' }, 400);
241+
const mode = VALID_MODES.includes(body.mode) ? body.mode : 'standard';
242+
const count = Math.min(Math.max(parseInt(body.count, 10) || 30, 1), 60);
243+
const title = String(body.title || '').trim().slice(0, MAX_TITLE);
244+
const focus = String(body.focus || '').trim().slice(0, 500);
245+
246+
const truncated = text.length > FLASHCARDS_MAX_INPUT_CHARS
247+
? text.slice(0, FLASHCARDS_MAX_INPUT_CHARS) + '\n[…truncated]'
248+
: text;
249+
250+
const prompt = buildFlashcardsPrompt(truncated, mode, count, title, focus);
251+
252+
let aiResp;
253+
try {
254+
aiResp = await env.AI.run(FLASHCARDS_MODEL, {
255+
messages: [{ role: 'user', content: prompt }],
256+
max_tokens: FLASHCARDS_MAX_OUTPUT_TOKENS,
257+
temperature: 0.4,
258+
});
259+
} catch (e) {
260+
return json({ error: 'AI call failed: ' + (e.message || 'unknown') }, 502);
261+
}
262+
263+
if (env.DB) {
264+
// Fire-and-forget increment. Table is created out-of-band (see setup comment).
265+
env.DB.prepare(
266+
'INSERT INTO ai_usage (date, calls) VALUES (?, 1) ON CONFLICT(date) DO UPDATE SET calls = calls + 1'
267+
).bind(today).run().catch(() => {});
268+
}
269+
270+
const raw = String(aiResp?.response || '').trim();
271+
return json({ raw, truncated: text.length > FLASHCARDS_MAX_INPUT_CHARS });
272+
}
273+
274+
function buildFlashcardsPrompt(text, mode, count, title, focus) {
275+
const modeRules = {
276+
'standard':
277+
'Each "front" is a clear, specific question. Each "back" is a concise factual answer (one sentence or short phrase). Cover the most testable concepts.',
278+
'fill-blank':
279+
'Each "front" is a single declarative sentence from the source with the single most important term replaced by exactly "___" (three underscores). Each "back" is just the missing word or short phrase (no punctuation). Use only one blank per card. Pick sentences that test core concepts, not trivia, dates, or names of figures unless central.',
280+
'vocab':
281+
'"front" is a single vocabulary term (1–3 words). "back" is a clear one-sentence definition in plain English. Do not include the term inside its own definition.',
282+
'define':
283+
'"front" is a one-sentence definition or description (do NOT mention the term being defined). "back" is the single term it describes. Definition must be unambiguous (only one term could fit).',
284+
'language':
285+
'"front" is a foreign-language word or short phrase. "back" is the English translation. Include common verbs, nouns, and useful phrases. Skip cognates that are obvious.',
286+
'formula':
287+
'"front" is the name of a concept, law, or quantity. "back" is the formula or equation written in plain text (e.g., "F = m * a", "PV = nRT"). Include variable meanings only if essential.',
288+
'dates':
289+
'"front" is a historical event, treaty, war, movement, or turning point (one short phrase). "back" is the year or short date range (e.g., "1776", "1861–1865"). Focus on dates a student would be tested on.',
290+
'quote':
291+
'"front" is a short literary quote, line, or passage from the source (use real text only — do not invent). "back" is "Speaker / work — significance" in one line.',
292+
};
293+
const rule = modeRules[mode] || modeRules['standard'];
294+
return `You are turning study material into flashcards.
295+
296+
MODE: ${mode}
297+
RULES FOR THIS MODE: ${rule}
298+
MAX CARDS: ${count}
299+
${focus ? `FOCUS: ${focus}\n` : ''}
300+
Return ONLY valid JSON, no prose, no markdown code fences. Shape:
301+
{
302+
"title": ${JSON.stringify(title || 'Generated deck')},
303+
"mode": "${mode}",
304+
"cards": [ { "front": "...", "back": "..." } ]
305+
}
306+
307+
Keep cards atomic (one fact each). Skip headers, page numbers, table of contents, and references. Don't invent facts not in the source.
308+
309+
SOURCE MATERIAL:
310+
"""
311+
${text}
312+
"""`;
313+
}
314+
189315
// ─────────────── Notes API ───────────────
190316
// R2 layout: notes/<slug>.md — one markdown file per note.
191317
// Auth: single hardcoded user. env.NOTES_USER + env.NOTES_PASSWORD.

0 commit comments

Comments
 (0)