Public Access
feat: implement AI usage tracking and quota management with daily usage records
This commit is contained in:
1 parent
1a37209f72
commit
54a0ede92a
17 files changed
+485
-46
No files matched your search
@@ -26,6 +26,7 @@ export const config = {
|
||||
env: env('NODE_ENV', 'development'),
|
||||
port: envInt('PORT', 3001),
|
||||
jwtSecret: env('JWT_SECRET'),
|
||||
jwtExpiresIn: env('JWT_EXPIRES_IN', '7d'),
|
||||
dbPath: env('DB_PATH', './db/brainwave.db'),
|
||||
corsOrigin: env('CORS_ORIGIN', 'http://localhost:5173'),
|
||||
bodyLimit: env('BODY_LIMIT', '100kb'),
|
||||
|
||||
@@ -49,8 +49,18 @@ export function runMigrations(db: Database.Database): void {
|
||||
updated_at INTEGER DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_usage_daily (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
period_date TEXT NOT NULL,
|
||||
tokens_used INTEGER NOT NULL DEFAULT 0,
|
||||
requests_used INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER DEFAULT (unixepoch()),
|
||||
PRIMARY KEY (user_id, period_date)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_saved_exams_user ON saved_exams(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_exam_results_user ON exam_results(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_usage_daily_user ON ai_usage_daily(user_id);
|
||||
`);
|
||||
|
||||
// ── Add share_token and is_public columns if missing ──
|
||||
@@ -149,6 +159,16 @@ export function runMigrations(db: Database.Database): void {
|
||||
smtp_from: "BrainWave <noreply@brainwave.local>",
|
||||
smtp_secure: "auto",
|
||||
app_url: "http://localhost:5173",
|
||||
ai_provider: "",
|
||||
ai_model: "gpt-4o-mini",
|
||||
ai_api_key: "",
|
||||
ai_base_url: "",
|
||||
ai_model_hint: "",
|
||||
ai_model_generate: "",
|
||||
ai_model_grading: "",
|
||||
ai_model_tutor: "",
|
||||
ai_model_study_coach: "",
|
||||
ai_daily_token_limit: "0",
|
||||
};
|
||||
const upsert = db.prepare(`
|
||||
INSERT INTO site_settings (key, value) VALUES (?, ?)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { getDb } from '../db/database.js'
|
||||
|
||||
export interface AiDailyUsage {
|
||||
tokensUsed: number
|
||||
requestsUsed: number
|
||||
}
|
||||
|
||||
function normalizeCount(value: number): number {
|
||||
if (!Number.isFinite(value) || value <= 0) return 0
|
||||
return Math.floor(value)
|
||||
}
|
||||
|
||||
export function getDailyUsage(userId: number, periodDate: string): AiDailyUsage {
|
||||
const db = getDb()
|
||||
const row = db.prepare(`
|
||||
SELECT tokens_used, requests_used
|
||||
FROM ai_usage_daily
|
||||
WHERE user_id = ? AND period_date = ?
|
||||
`).get(userId, periodDate) as { tokens_used: number; requests_used: number } | undefined
|
||||
|
||||
return {
|
||||
tokensUsed: row?.tokens_used ?? 0,
|
||||
requestsUsed: row?.requests_used ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function incrementDailyUsage(
|
||||
userId: number,
|
||||
periodDate: string,
|
||||
tokensUsed: number,
|
||||
requestsUsed = 1,
|
||||
): void {
|
||||
const db = getDb()
|
||||
const tokens = normalizeCount(tokensUsed)
|
||||
const requests = normalizeCount(requestsUsed)
|
||||
if (tokens === 0 && requests === 0) return
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO ai_usage_daily (user_id, period_date, tokens_used, requests_used)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, period_date) DO UPDATE SET
|
||||
tokens_used = tokens_used + excluded.tokens_used,
|
||||
requests_used = requests_used + excluded.requests_used,
|
||||
updated_at = unixepoch()
|
||||
`).run(userId, periodDate, tokens, requests)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
import { getAIClient, getAIModel } from './aiClient.js'
|
||||
import { getAIClient, getAIModelForTask } from './aiClient.js'
|
||||
import { AiUsageResult, estimateTokensFromMessages, estimateTokensFromText, estimateTotalTokens, resolveUsageTokens } from '../utils/aiTokens.js'
|
||||
|
||||
export type QuestionType =
|
||||
| 'multiple-choice'
|
||||
@@ -141,14 +142,14 @@ function shapeHintForType(type: QuestionType): string {
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateQuestions(req: GenerateQuestionsRequest): Promise<GeneratedQuestion[]> {
|
||||
export async function generateQuestions(req: GenerateQuestionsRequest): Promise<AiUsageResult<GeneratedQuestion[]>> {
|
||||
const client = getAIClient()
|
||||
if (!client) {
|
||||
throw new Error('AI is not configured. Please set provider and API key in admin settings.')
|
||||
}
|
||||
|
||||
const count = Math.max(1, Math.min(10, Math.floor(req.count)))
|
||||
const model = getAIModel()
|
||||
const model = getAIModelForTask('question_generation')
|
||||
const shape = shapeHintForType(req.type)
|
||||
|
||||
const prompt = `You are an exam author. Generate ${count} high-quality "${req.type}" exam question(s) about the following topic.
|
||||
@@ -164,6 +165,9 @@ Respond ONLY with a JSON object of the form:
|
||||
|
||||
Each question object must have "type": "${req.type}" and the fields listed above. Do not include "id" — that will be assigned later. Do not include any text outside the JSON object.`
|
||||
|
||||
const promptTokens = estimateTokensFromText(prompt)
|
||||
const estimatedTokens = estimateTotalTokens(promptTokens, 1800)
|
||||
|
||||
const completion = await client.chat.completions.create({
|
||||
model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
@@ -172,6 +176,8 @@ Each question object must have "type": "${req.type}" and the fields listed above
|
||||
max_completion_tokens: 1800,
|
||||
})
|
||||
|
||||
const usageTokens = resolveUsageTokens(completion.usage, estimatedTokens)
|
||||
|
||||
const raw = completion.choices[0]?.message?.content
|
||||
if (!raw) {
|
||||
throw new Error('Empty response from AI provider')
|
||||
@@ -200,7 +206,7 @@ Each question object must have "type": "${req.type}" and the fields listed above
|
||||
if (validated.length === 0) {
|
||||
throw new Error('AI returned no valid questions for the requested type')
|
||||
}
|
||||
return validated
|
||||
return { data: validated, usageTokens }
|
||||
}
|
||||
|
||||
export interface HintRequest {
|
||||
@@ -219,32 +225,39 @@ function stripAnswerFields(question: Record<string, unknown>): Record<string, un
|
||||
return clone
|
||||
}
|
||||
|
||||
export async function getHint(req: HintRequest): Promise<{ hint: string }> {
|
||||
export async function getHint(req: HintRequest): Promise<AiUsageResult<{ hint: string }>> {
|
||||
const client = getAIClient()
|
||||
if (!client) {
|
||||
throw new Error('AI is not configured. Please set provider and API key in admin settings.')
|
||||
}
|
||||
|
||||
const safeQuestion = stripAnswerFields(req.question)
|
||||
const model = getAIModel()
|
||||
const model = getAIModelForTask('hint')
|
||||
|
||||
const messages = [
|
||||
{
|
||||
role: 'system',
|
||||
content: `You are a tutor helping a student during an exam. Give exactly one short hint (1-2 sentences) in ${req.language} that nudges the student toward the right approach. Never reveal the answer, never restate the answer in different words, and never give the answer to a multiple-choice option. Focus on the concept or strategy. Respond ONLY with a JSON object: { "hint": string }.`,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `Question:\n${JSON.stringify(safeQuestion)}`,
|
||||
},
|
||||
]
|
||||
|
||||
const promptTokens = estimateTokensFromMessages(messages)
|
||||
const estimatedTokens = estimateTotalTokens(promptTokens, 160)
|
||||
|
||||
const completion = await client.chat.completions.create({
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: `You are a tutor helping a student during an exam. Give exactly one short hint (1-2 sentences) in ${req.language} that nudges the student toward the right approach. Never reveal the answer, never restate the answer in different words, and never give the answer to a multiple-choice option. Focus on the concept or strategy. Respond ONLY with a JSON object: { "hint": string }.`,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `Question:\n${JSON.stringify(safeQuestion)}`,
|
||||
},
|
||||
],
|
||||
messages,
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.4,
|
||||
max_completion_tokens: 160,
|
||||
})
|
||||
|
||||
const usageTokens = resolveUsageTokens(completion.usage, estimatedTokens)
|
||||
|
||||
const raw = completion.choices[0]?.message?.content
|
||||
if (!raw) {
|
||||
throw new Error('Empty response from AI provider')
|
||||
@@ -260,7 +273,7 @@ export async function getHint(req: HintRequest): Promise<{ hint: string }> {
|
||||
if (!hint) {
|
||||
throw new Error('AI did not return a hint')
|
||||
}
|
||||
return { hint }
|
||||
return { data: { hint }, usageTokens }
|
||||
}
|
||||
|
||||
export interface TutorChatMessage {
|
||||
@@ -282,13 +295,13 @@ export interface TutorChatRequest {
|
||||
language: string
|
||||
}
|
||||
|
||||
export async function tutorChat(req: TutorChatRequest): Promise<{ reply: string }> {
|
||||
export async function tutorChat(req: TutorChatRequest): Promise<AiUsageResult<{ reply: string }>> {
|
||||
const client = getAIClient()
|
||||
if (!client) {
|
||||
throw new Error('AI is not configured. Please set provider and API key in admin settings.')
|
||||
}
|
||||
|
||||
const model = getAIModel()
|
||||
const model = getAIModelForTask('tutor_chat')
|
||||
const recent = req.messages.slice(-10)
|
||||
|
||||
const systemPrompt = `You are a friendly, concise tutor reviewing a completed exam attempt with a student. Reply in ${req.language}. Keep answers short (under 200 words). When the student references a question by number, use the exam's question order. You may explain mistakes, give similar practice problems, or clarify rubric criteria. You may reference the student's actual answers and the AI feedback already produced. Never invent score numbers — use only what's in the exam summary.
|
||||
@@ -302,19 +315,25 @@ Exam (JSON): ${JSON.stringify(req.exam).slice(0, 8000)}
|
||||
|
||||
Student's submission state (answers, flags, etc., JSON): ${JSON.stringify(req.state).slice(0, 4000)}`
|
||||
|
||||
const messages = [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...recent.map((m) => ({ role: m.role, content: m.content })),
|
||||
]
|
||||
const promptTokens = estimateTokensFromMessages(messages)
|
||||
const estimatedTokens = estimateTotalTokens(promptTokens, 600)
|
||||
|
||||
const completion = await client.chat.completions.create({
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...recent.map((m) => ({ role: m.role, content: m.content })),
|
||||
],
|
||||
messages,
|
||||
temperature: 0.5,
|
||||
max_completion_tokens: 600,
|
||||
})
|
||||
|
||||
const usageTokens = resolveUsageTokens(completion.usage, estimatedTokens)
|
||||
|
||||
const reply = completion.choices[0]?.message?.content?.trim()
|
||||
if (!reply) {
|
||||
throw new Error('Empty response from AI provider')
|
||||
}
|
||||
return { reply }
|
||||
return { data: { reply }, usageTokens }
|
||||
}
|
||||
@@ -7,6 +7,8 @@ export interface AIClientOverride {
|
||||
baseURL?: string
|
||||
}
|
||||
|
||||
export type AiTask = 'hint' | 'question_generation' | 'grading' | 'tutor_chat' | 'study_coach'
|
||||
|
||||
export function getAIClient(override?: AIClientOverride): OpenAI | null {
|
||||
const provider = override?.provider ?? getSetting('ai_provider')
|
||||
const apiKey = override?.apiKey ?? getSetting('ai_api_key')
|
||||
@@ -20,3 +22,24 @@ export function getAIClient(override?: AIClientOverride): OpenAI | null {
|
||||
export function getAIModel(override?: string): string {
|
||||
return override || getSetting('ai_model') || 'gpt-4o-mini'
|
||||
}
|
||||
|
||||
const TASK_MODEL_KEYS: Record<AiTask, string> = {
|
||||
hint: 'ai_model_hint',
|
||||
question_generation: 'ai_model_generate',
|
||||
grading: 'ai_model_grading',
|
||||
tutor_chat: 'ai_model_tutor',
|
||||
study_coach: 'ai_model_study_coach',
|
||||
}
|
||||
|
||||
function normalizeModel(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.trim()
|
||||
return trimmed ? trimmed : undefined
|
||||
}
|
||||
|
||||
export function getAIModelForTask(task: AiTask, override?: string): string {
|
||||
const explicit = normalizeModel(override)
|
||||
if (explicit) return explicit
|
||||
|
||||
const taskModel = normalizeModel(getSetting(TASK_MODEL_KEYS[task]))
|
||||
return taskModel || getAIModel()
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getAIClient, getAIModel } from './aiClient.js'
|
||||
import { getAIClient, getAIModelForTask } from './aiClient.js'
|
||||
import { AiUsageResult, estimateTokensFromText, estimateTotalTokens, resolveUsageTokens } from '../utils/aiTokens.js'
|
||||
|
||||
export interface GradingResult {
|
||||
suggestedGrade: number
|
||||
@@ -133,14 +134,16 @@ Use the human-readable text labels in the arrays.`
|
||||
export async function gradeQuestionWithAI(
|
||||
req: GradeRequest,
|
||||
override?: { provider?: string; apiKey?: string; baseURL?: string; model?: string },
|
||||
): Promise<GradingResult> {
|
||||
): Promise<AiUsageResult<GradingResult>> {
|
||||
const client = getAIClient(override)
|
||||
if (!client) {
|
||||
throw new Error('AI grading is not configured. Please set provider and API key in admin settings.')
|
||||
}
|
||||
|
||||
const model = getAIModel(override?.model)
|
||||
const model = getAIModelForTask('grading', override?.model)
|
||||
const prompt = buildPrompt(req)
|
||||
const promptTokens = estimateTokensFromText(prompt)
|
||||
const estimatedTokens = estimateTotalTokens(promptTokens, 800)
|
||||
|
||||
const completion = await client.chat.completions.create({
|
||||
model,
|
||||
@@ -150,6 +153,8 @@ export async function gradeQuestionWithAI(
|
||||
max_completion_tokens: 800,
|
||||
})
|
||||
|
||||
const usageTokens = resolveUsageTokens(completion.usage, estimatedTokens)
|
||||
|
||||
const raw = completion.choices[0]?.message?.content
|
||||
if (!raw) {
|
||||
throw new Error('Empty response from AI provider')
|
||||
@@ -168,8 +173,11 @@ export async function gradeQuestionWithAI(
|
||||
: 0
|
||||
|
||||
return {
|
||||
data: {
|
||||
suggestedGrade,
|
||||
feedback: typeof obj.feedback === 'string' ? obj.feedback : '',
|
||||
detail: obj.detail,
|
||||
},
|
||||
usageTokens,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getAIClient, getAIModel } from './aiClient.js'
|
||||
import { getAIClient, getAIModelForTask } from './aiClient.js'
|
||||
import { AiUsageResult, estimateTokensFromText, estimateTotalTokens, resolveUsageTokens } from '../utils/aiTokens.js'
|
||||
|
||||
export interface WeakArea {
|
||||
category: string
|
||||
@@ -71,7 +72,8 @@ export async function analyzeResults(
|
||||
exam_json: string
|
||||
}>,
|
||||
language = 'en',
|
||||
): Promise<StudyCoachResult> {
|
||||
options?: { allowAi?: boolean },
|
||||
): Promise<AiUsageResult<StudyCoachResult>> {
|
||||
const streakDays = calculateStreak(results)
|
||||
|
||||
// Build per-category stats from exam_json
|
||||
@@ -116,11 +118,11 @@ export async function analyzeResults(
|
||||
.slice(0, 5)
|
||||
|
||||
const client = getAIClient()
|
||||
if (!client || weakAreas.length === 0) {
|
||||
return { weakAreas, studyTips: [], streakDays }
|
||||
if (!client || weakAreas.length === 0 || options?.allowAi === false) {
|
||||
return { data: { weakAreas, studyTips: [], streakDays }, usageTokens: 0 }
|
||||
}
|
||||
|
||||
const model = getAIModel()
|
||||
const model = getAIModelForTask('study_coach')
|
||||
const prompt = `You are a study coach. A student has taken several practice exams. Based on their performance per category, suggest 2-3 concise study tips to help them improve in their weakest areas. Respond ONLY with a JSON object containing:
|
||||
- "studyTips": string[] (2-3 short tips in ${language})
|
||||
|
||||
@@ -129,6 +131,9 @@ Do not include any text outside the JSON object.
|
||||
Weak areas (score %, attempts):
|
||||
${weakAreas.map((w) => `- ${w.category}: ${w.avgScore}% over ${w.attempts} attempt(s)`).join('\n')}`
|
||||
|
||||
const promptTokens = estimateTokensFromText(prompt)
|
||||
const estimatedTokens = estimateTotalTokens(promptTokens, 400)
|
||||
|
||||
try {
|
||||
const completion = await client.chat.completions.create({
|
||||
model,
|
||||
@@ -138,9 +143,11 @@ ${weakAreas.map((w) => `- ${w.category}: ${w.avgScore}% over ${w.attempts} attem
|
||||
max_completion_tokens: 400,
|
||||
})
|
||||
|
||||
const usageTokens = resolveUsageTokens(completion.usage, estimatedTokens)
|
||||
|
||||
const raw = completion.choices[0]?.message?.content
|
||||
if (!raw) {
|
||||
return { weakAreas, studyTips: [], streakDays }
|
||||
return { data: { weakAreas, studyTips: [], streakDays }, usageTokens: 0 }
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>
|
||||
@@ -148,8 +155,8 @@ ${weakAreas.map((w) => `- ${w.category}: ${w.avgScore}% over ${w.attempts} attem
|
||||
? parsed.studyTips.filter((t): t is string => typeof t === 'string')
|
||||
: []
|
||||
|
||||
return { weakAreas, studyTips: tips, streakDays }
|
||||
return { data: { weakAreas, studyTips: tips, streakDays }, usageTokens }
|
||||
} catch {
|
||||
return { weakAreas, studyTips: [], streakDays }
|
||||
return { data: { weakAreas, studyTips: [], streakDays }, usageTokens: 0 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { getSetting } from '../repositories/settingsRepo.js'
|
||||
import { getDailyUsage, incrementDailyUsage } from '../repositories/aiUsageRepo.js'
|
||||
|
||||
export interface AiQuotaStatus {
|
||||
allowed: boolean
|
||||
limit: number | null
|
||||
usedTokens: number
|
||||
periodDate: string
|
||||
}
|
||||
|
||||
export function getUtcDateKey(date = new Date()): string {
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export function getDailyTokenLimit(): number | null {
|
||||
const raw = getSetting('ai_daily_token_limit') ?? ''
|
||||
const parsed = Number(raw)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return null
|
||||
return Math.floor(parsed)
|
||||
}
|
||||
|
||||
export function checkDailyAiQuota(userId: number): AiQuotaStatus {
|
||||
const periodDate = getUtcDateKey()
|
||||
const limit = getDailyTokenLimit()
|
||||
if (!limit) {
|
||||
return { allowed: true, limit: null, usedTokens: 0, periodDate }
|
||||
}
|
||||
|
||||
const usage = getDailyUsage(userId, periodDate)
|
||||
return {
|
||||
allowed: usage.tokensUsed < limit,
|
||||
limit,
|
||||
usedTokens: usage.tokensUsed,
|
||||
periodDate,
|
||||
}
|
||||
}
|
||||
|
||||
export function recordDailyAiUsage(userId: number, periodDate: string, tokens: number): void {
|
||||
incrementDailyUsage(userId, periodDate, tokens, 1)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export interface AiUsageResult<T> {
|
||||
data: T
|
||||
usageTokens: number
|
||||
}
|
||||
|
||||
export function estimateTokensFromText(text: string): number {
|
||||
if (!text) return 0
|
||||
return Math.max(1, Math.ceil(text.length / 4))
|
||||
}
|
||||
|
||||
export function estimateTokensFromMessages(messages: Array<{ content: string }>): number {
|
||||
return messages.reduce((sum, msg) => sum + estimateTokensFromText(msg.content), 0)
|
||||
}
|
||||
|
||||
// Conservative estimate when providers omit usage details.
|
||||
export function estimateTotalTokens(promptTokens: number, maxCompletionTokens: number): number {
|
||||
const completionEstimate = Math.max(0, Math.round(maxCompletionTokens * 0.6))
|
||||
return Math.max(1, promptTokens + completionEstimate)
|
||||
}
|
||||
|
||||
export function resolveUsageTokens(
|
||||
usage: { total_tokens?: number } | undefined,
|
||||
estimate: number,
|
||||
): number {
|
||||
if (usage && typeof usage.total_tokens === 'number' && Number.isFinite(usage.total_tokens)) {
|
||||
return Math.max(0, Math.round(usage.total_tokens))
|
||||
}
|
||||
return estimate
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export interface JwtPayload {
|
||||
}
|
||||
|
||||
export function signToken(payload: JwtPayload): string {
|
||||
return jwt.sign(payload, config.jwtSecret, { expiresIn: '24h' })
|
||||
return jwt.sign(payload, config.jwtSecret, { expiresIn: config.jwtExpiresIn })
|
||||
}
|
||||
|
||||
export function verifyToken(token: string): JwtPayload {
|
||||
|
||||
@@ -96,7 +96,7 @@ function AppInner() {
|
||||
|
||||
// Bridge: MyResults stores review data in localStorage; load it on /review/:slug
|
||||
useEffect(() => {
|
||||
if (pathname.startsWith("/review/") && !exam) {
|
||||
if (pathname.startsWith("/review/")) {
|
||||
const reviewExam = localStorage.getItem('brainwave.review.exam')
|
||||
const reviewState = localStorage.getItem('brainwave.review.state')
|
||||
const reviewResultIdRaw = localStorage.getItem('brainwave.review.resultId')
|
||||
@@ -118,7 +118,7 @@ function AppInner() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [pathname, exam]);
|
||||
}, [pathname]);
|
||||
|
||||
const setExamStateStrict = useCallback<React.Dispatch<React.SetStateAction<ExamState>>>(
|
||||
(nextState) => {
|
||||
|
||||
@@ -56,3 +56,7 @@ export function resetPassword(data: { token: string; newPassword: string }): Pro
|
||||
export function deleteAccount(): Promise<{ success: boolean }> {
|
||||
return apiFetch('/api/auth/me', { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function logout(): Promise<void> {
|
||||
return apiFetch('/api/auth/logout', { method: 'POST' })
|
||||
}
|
||||
@@ -158,7 +158,7 @@ export default function MyResults({ onLoadExam }: Props) {
|
||||
{retakingId === r.id ? t('common.loading') : t('results.retakeExam')}
|
||||
</button>
|
||||
<button className="btn btn-sm" onClick={() => void reviewResult(r)}>
|
||||
{t('common.edit')}
|
||||
{t('results.review')}
|
||||
</button>
|
||||
{deleteConfirm === r.id ? (
|
||||
<div className="workspace-delete-confirm">
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function Overview({ examCount, resultCount, recentResults, onLoad
|
||||
useEffect(() => {
|
||||
resultApi.getScoreTrends()
|
||||
.then((data) => setTrends(data))
|
||||
.catch(() => {/* ignore */})
|
||||
.catch(() => {/* ignore */ })
|
||||
.finally(() => setTrendsLoading(false))
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ interface AuthContextValue {
|
||||
setUser: (user: User | null) => void
|
||||
login: (email: string, password: string) => Promise<void>
|
||||
register: (email: string, password: string, displayName: string) => Promise<string>
|
||||
logout: () => void
|
||||
logout: () => Promise<void>
|
||||
refreshUser: () => Promise<void>
|
||||
}
|
||||
|
||||
@@ -87,7 +87,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
return 'Registration successful! Please check your email to verify your account.'
|
||||
}, [])
|
||||
|
||||
const logout = useCallback(() => {
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await authApi.logout()
|
||||
} catch {
|
||||
// ignore errors — we still want to clear local state
|
||||
}
|
||||
clearToken()
|
||||
setUser(null)
|
||||
}, [])
|
||||
|
||||
@@ -16,8 +16,25 @@ export function clearToken(): void {
|
||||
localStorage.removeItem('brainwave.auth.token')
|
||||
}
|
||||
|
||||
function isTokenExpired(token: string): boolean {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1] ?? ''))
|
||||
const exp = payload.exp
|
||||
if (typeof exp !== 'number') return false
|
||||
return Date.now() >= exp * 1000
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
return !!getToken()
|
||||
const token = getToken()
|
||||
if (!token) return false
|
||||
if (isTokenExpired(token)) {
|
||||
clearToken()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export async function apiFetch<T = unknown>(
|
||||
|
||||
@@ -219,15 +219,216 @@ QUESTION TYPES
|
||||
"answer": ["e1", "e2", "e3", "e4"]
|
||||
}
|
||||
|
||||
9. FILL-IN-THE-BLANKS ("fill-in-the-blanks")
|
||||
Student completes blanks embedded in a text passage.
|
||||
|
||||
Required fields:
|
||||
"textSegments": ["Before blank", " between blanks", " after blank"] — Array of text strings to interleave with blanks. Must be blanks.length + 1.
|
||||
"blanks": [
|
||||
{ "id": "b1", "acceptedAnswers": ["correct word", "alt word"] },
|
||||
...
|
||||
]
|
||||
|
||||
Optional per-blank fields:
|
||||
"acceptedForms": ["alt1"] — Additional accepted forms
|
||||
"caseSensitive": true|false (default: false)
|
||||
"ignorePunctuation": true|false (default: false)
|
||||
"ignoreAccents": true|false (default: false)
|
||||
|
||||
Example:
|
||||
{
|
||||
"id": "q9",
|
||||
"type": "fill-in-the-blanks",
|
||||
"prompt": "Fill in the missing words.",
|
||||
"textSegments": ["The capital of France is ", ", and the capital of Japan is ", "."],
|
||||
"blanks": [
|
||||
{ "id": "b1", "acceptedAnswers": ["Paris"], "caseSensitive": false },
|
||||
{ "id": "b2", "acceptedAnswers": ["Tokyo"], "caseSensitive": false }
|
||||
]
|
||||
}
|
||||
|
||||
10. TRUE / FALSE ("true-false")
|
||||
Student answers a statement with true or false.
|
||||
|
||||
Required fields:
|
||||
"answer": true or false
|
||||
|
||||
Example:
|
||||
{
|
||||
"id": "q10",
|
||||
"type": "true-false",
|
||||
"prompt": "The Earth is the third planet from the Sun.",
|
||||
"answer": true
|
||||
}
|
||||
|
||||
11. CODE SUBMISSION ("code-submission")
|
||||
Student writes code in a specific programming language.
|
||||
|
||||
Required fields:
|
||||
"codeConfig": { "language": "python" } — Programming language identifier
|
||||
|
||||
Optional fields:
|
||||
"codeConfig": { "language": "python", "starterCode": "def hello():\\n pass" } — Initial code shown in editor
|
||||
"rubric": ["criterion 1", "criterion 2"] — Criteria for grading
|
||||
|
||||
Example:
|
||||
{
|
||||
"id": "q11",
|
||||
"type": "code-submission",
|
||||
"prompt": "Write a function that returns the factorial of n.",
|
||||
"points": 5,
|
||||
"codeConfig": { "language": "python", "starterCode": "def factorial(n):\\n pass" },
|
||||
"rubric": [
|
||||
"Handles base case (0! = 1)",
|
||||
"Uses recursion or iteration correctly",
|
||||
"Returns correct result for positive integers"
|
||||
]
|
||||
}
|
||||
|
||||
12. DROPDOWN COMPLETION ("dropdown-completion")
|
||||
Student selects from dropdown menus embedded in a text passage.
|
||||
|
||||
Required fields:
|
||||
"textSegments": ["Before dropdown", " between dropdowns", " after dropdown"] — Must be dropdowns.length + 1.
|
||||
"dropdowns": [
|
||||
{
|
||||
"id": "d1",
|
||||
"options": [{ "id": "a", "text": "Option A" }, { "id": "b", "text": "Option B" }],
|
||||
"correctOptionId": "a"
|
||||
}
|
||||
]
|
||||
|
||||
Example:
|
||||
{
|
||||
"id": "q12",
|
||||
"type": "dropdown-completion",
|
||||
"prompt": "Choose the correct words.",
|
||||
"textSegments": ["Water boils at ", " degrees Celsius at sea level, and freezes at ", "."],
|
||||
"dropdowns": [
|
||||
{
|
||||
"id": "d1",
|
||||
"options": [
|
||||
{ "id": "a", "text": "100" },
|
||||
{ "id": "b", "text": "90" },
|
||||
{ "id": "c", "text": "110" }
|
||||
],
|
||||
"correctOptionId": "a"
|
||||
},
|
||||
{
|
||||
"id": "d2",
|
||||
"options": [
|
||||
{ "id": "a", "text": "0" },
|
||||
{ "id": "b", "text": "-10" },
|
||||
{ "id": "c", "text": "10" }
|
||||
],
|
||||
"correctOptionId": "a"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
13. NUMERIC RANGE ("numeric-range")
|
||||
Student enters a numeric answer within an acceptable tolerance.
|
||||
|
||||
Required fields:
|
||||
"numericConfig": { "answer": 42, "tolerance": 0.5 } — Correct value and allowed deviation
|
||||
|
||||
Optional fields:
|
||||
"numericConfig": { "answer": 42, "tolerance": 0.5, "min": 0, "max": 100 } — Bounds for validation
|
||||
|
||||
Example:
|
||||
{
|
||||
"id": "q13",
|
||||
"type": "numeric-range",
|
||||
"prompt": "What is the approximate speed of sound in air at 20°C (in m/s)?",
|
||||
"points": 2,
|
||||
"numericConfig": { "answer": 343, "tolerance": 5, "min": 300, "max": 400 }
|
||||
}
|
||||
|
||||
14. DICTATION ("dictation")
|
||||
Student transcribes audio they listen to.
|
||||
|
||||
Required fields:
|
||||
"audio": { "src": "https://example.com/audio.mp3" } — URL to the audio file
|
||||
"answer": "string" — Correct transcription
|
||||
|
||||
Optional fields:
|
||||
"acceptedForms": ["alt transcription 1"] — Other accepted forms
|
||||
"caseSensitive": true|false (default: false)
|
||||
"ignorePunctuation": true|false (default: false)
|
||||
"ignoreAccents": true|false (default: false)
|
||||
|
||||
Example:
|
||||
{
|
||||
"id": "q14",
|
||||
"type": "dictation",
|
||||
"prompt": "Listen to the audio and type what you hear.",
|
||||
"audio": { "src": "https://example.com/dictation.mp3" },
|
||||
"answer": "The quick brown fox jumps over the lazy dog.",
|
||||
"caseSensitive": false,
|
||||
"ignorePunctuation": true
|
||||
}
|
||||
|
||||
15. GRAMMAR CORRECTION ("grammar-correction")
|
||||
Student corrects errors in a provided sentence.
|
||||
|
||||
Required fields:
|
||||
"textToCorrect": "string" — The sentence containing errors
|
||||
"answer": "string" — The corrected sentence
|
||||
|
||||
Optional fields:
|
||||
"acceptedForms": ["alt correction 1"] — Other accepted corrected forms
|
||||
"caseSensitive": true|false (default: false)
|
||||
"ignorePunctuation": true|false (default: false)
|
||||
|
||||
Example:
|
||||
{
|
||||
"id": "q15",
|
||||
"type": "grammar-correction",
|
||||
"prompt": "Correct the grammar in the following sentence.",
|
||||
"textToCorrect": "She don't like apples.",
|
||||
"answer": "She doesn't like apples.",
|
||||
"caseSensitive": false
|
||||
}
|
||||
|
||||
16. FLASHCARD ("flashcard")
|
||||
Self-study card with front and back. Not graded — student self-assesses.
|
||||
|
||||
Required fields:
|
||||
"answer": "string" — The back of the card (translation, definition, etc.)
|
||||
|
||||
Optional fields:
|
||||
"explanation": "string" — Extra info shown after reveal
|
||||
|
||||
Example:
|
||||
{
|
||||
"id": "q16",
|
||||
"type": "flashcard",
|
||||
"prompt": "Bonjour",
|
||||
"answer": "Hello (French)",
|
||||
"explanation": "Informal greeting used throughout the day."
|
||||
}
|
||||
|
||||
────────────────────────────────────
|
||||
LANGUAGE GUIDELINES
|
||||
────────────────────────────────────
|
||||
When writing user-facing text (prompts, choices, explanations, rubric items, hints):
|
||||
- Write ALL user-facing text in the language the user is asking for (their native/request language).
|
||||
- Do NOT default to the subject language for explanations, prompts, or rubric items.
|
||||
|
||||
Examples:
|
||||
- If the user asks for a "French vocabulary test" in English, write prompts and explanations in ENGLISH. Only the target vocabulary (the word being tested) should be in French.
|
||||
- If the user asks for a "biology exam" in Dutch, write everything in DUTCH, even if the source material is in English.
|
||||
- Explanations should always be in the user's request language so they can understand why an answer is correct or incorrect.
|
||||
|
||||
────────────────────────────────────
|
||||
LATEX SUPPORT
|
||||
────────────────────────────────────
|
||||
Use LaTeX math in prompts, choices, and explanations:
|
||||
- Inline math: $x^2 + y^2 = z^2$
|
||||
- Display math: $$\\\\int_0^1 x^2 \\\\, dx = \\\\frac{1}{3}$$
|
||||
- Display math: $$\\int_0^1 x^2 \\, dx = \\frac{1}{3}$$
|
||||
|
||||
Remember to double-escape backslashes in JSON strings:
|
||||
"prompt": "Evaluate $\\\\frac{d}{dx} x^2$"
|
||||
"prompt": "Evaluate $\\frac{d}{dx} x^2$"
|
||||
|
||||
────────────────────────────────────
|
||||
MEDIA ATTACHMENTS
|
||||
@@ -304,10 +505,29 @@ When asking your LLM to create an exam, include:
|
||||
4. Which question types to use
|
||||
5. Desired difficulty level
|
||||
6. Whether to include a time limit
|
||||
7. The language you want all user-facing text written in
|
||||
|
||||
Example prompt: "Using the attached format guide, create a 10-question exam on
|
||||
World War II. Use a mix of multiple-choice, short-answer, and ordering questions.
|
||||
Set a 20-minute time limit. Include explanations for each answer."
|
||||
Set a 20-minute time limit. Write everything in English and include explanations
|
||||
for each answer."
|
||||
|
||||
────────────────────────────────────
|
||||
CLARIFYING QUESTIONS
|
||||
────────────────────────────────────
|
||||
Before generating the exam JSON, you MAY ask 1–3 short clarifying questions if
|
||||
anything is ambiguous or would significantly improve the quality of the output.
|
||||
Only ask questions when the user's request is genuinely unclear — do not delay
|
||||
trivial requests.
|
||||
|
||||
Good clarifying questions include:
|
||||
- "What is the target difficulty level (beginner, intermediate, advanced)?"
|
||||
- "Who is the intended audience (age group, class level, professional field)?"
|
||||
- "Should explanations be in [user's language] even if the subject is a foreign language?"
|
||||
- "Do you want a mix of question types, or focus on one type?"
|
||||
- "Should I include a time limit or passing score?"
|
||||
|
||||
Once the user answers, generate the full exam JSON in a single response.
|
||||
`
|
||||
|
||||
const blob = new Blob([content], { type: 'text/plain' })
|
||||
|
||||
Reference in new issue
Block a user