Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion backend/src/routes/questions.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ router.post('/', authorize('teacher'), async (req, res) => {
type,
question,
options,
explanation = '',
timeToAnswer = 30,
points = 100,
status = 'approved',
Expand All @@ -129,7 +130,7 @@ router.post('/', authorize('teacher'), async (req, res) => {
// The frontend renders these as React text nodes, which auto-escape at
// render time, so entity-encoding here is unnecessary and would show
// literally (e.g. ") on the student side.
const sanitizedData = stripObject({ roomId, type, question, options, timeToAnswer, points, status, segmentIndex })
const sanitizedData = stripObject({ roomId, type, question, options, explanation, timeToAnswer, points, status, segmentIndex })

const newQuestion = new Question(sanitizedData)

Expand Down
1 change: 1 addition & 0 deletions backend/src/routes/responses.js
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,7 @@ router.get('/room/:roomId/student/:studentId', async (req, res) => {
maxPoints: q.points,
timeToAnswer: q.timeToAnswer,
answered: !!studentResponse,
explanation: q.explanation,
// Tells the frontend to render this still-live question neutrally: marked answer in blue, or
// a "missed" tag if unanswered — no correct/incorrect until it is revealed.
...(isActive ? { resultPending: true } : {}),
Expand Down
107 changes: 107 additions & 0 deletions backend/src/routes/summary.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import express from 'express'
import { authenticate } from '../middleware/auth.js'
import * as sessionSummaryService from '../services/sessionSummaryService.js'

const router = express.Router()

// Apply authentication to all routes
router.use(authenticate)

// In-memory cooldown tracker so "Regenerate" can't be spammed and hammer the
// AI provider. Keyed by roomId -> timestamp of last generation attempt.
// This is intentionally process-local (no schema/DB changes needed); worst
// case on a server restart is the cooldown resets, which is harmless.
const lastGenerationAttempt = new Map()
const REGENERATE_COOLDOWN_MS = 15000 // 15s between generate calls per room

/**
* POST /api/summary/generate/:roomId
* Generate (or regenerate) a session summary for a room
* Authorization: teacher who owns the room, OR a student who is a member of the room
*/
router.post('/generate/:roomId', async (req, res) => {
try {
const { roomId } = req.params

const Question = (await import('../models/Question.js')).default
const Response = (await import('../models/Response.js')).default
const Transcript = (await import('../models/Transcript.js')).default
const Room = (await import('../models/Room.js')).default
const RoomMember = (await import('../models/RoomMember.js')).default

const room = await Room.findById(roomId)
if (!room) return res.status(404).json({ success: false, error: 'Room not found' })

const isTeacher = room.teacher.toString() === req.user._id.toString()
const isStudentMember = isTeacher ? null : await RoomMember.findOne({ roomId, studentId: req.user._id })

if (!isTeacher && !isStudentMember) {
return res.status(403).json({ success: false, error: 'Not authorized' })
}

// Basic cooldown to prevent regenerate spam from any one room
const lastAttempt = lastGenerationAttempt.get(roomId)
if (lastAttempt && Date.now() - lastAttempt < REGENERATE_COOLDOWN_MS) {
const waitSeconds = Math.ceil((REGENERATE_COOLDOWN_MS - (Date.now() - lastAttempt)) / 1000)
return res.status(429).json({
success: false,
error: `Please wait ${waitSeconds}s before regenerating again`
})
}
lastGenerationAttempt.set(roomId, Date.now())

const allQuestions = await Question.find({ roomId, status: 'approved' }).lean()
const allResponses = await Response.find({ roomId }).lean()
const allTranscripts = await Transcript.find({ roomId }).lean()

const summary = await sessionSummaryService.generateSessionSummary(
room,
allQuestions,
allResponses,
allTranscripts
)

await Room.findByIdAndUpdate(roomId, { summary })

res.json({ success: true, summary })
} catch (error) {
console.error('Error generating session summary:', error)
res.status(500).json({ success: false, error: error.message || 'Failed to generate session summary' })
}
})

/**
* GET /api/summary/:roomId
* Get the session summary for a room
* Authorization: teacher (must own the room) or student (must be a member)
*/
router.get('/:roomId', async (req, res) => {
try {
const { roomId } = req.params
const Room = (await import('../models/Room.js')).default
const RoomMember = (await import('../models/RoomMember.js')).default

const room = await Room.findById(roomId)
if (!room) {
return res.status(404).json({ error: 'Room not found' })
}

// Check access: teacher owns room OR student is a member
const isTeacher = room.teacher.toString() === req.user._id.toString()
const isStudentMember = await RoomMember.findOne({ roomId, studentId: req.user._id })

if (!isTeacher && !isStudentMember) {
return res.status(403).json({ error: 'Not authorized to view this summary' })
}

res.json({
success: true,
summary: room.summary || null
})
} catch (error) {
console.error('Error fetching session summary:', error)
res.status(500).json({ error: 'Failed to fetch summary' })
}
})

export default router
146 changes: 146 additions & 0 deletions backend/src/services/aiProviderService.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// aiProviderService.js
// Pure I/O layer: knows how to call each AI provider with a prompt and
// return raw text. Nothing here knows about questions, quotas, or scoring.

import { config } from '../config.js'

const FETCH_TIMEOUT_MS = 45000
function withTimeout() {
const controller = new AbortController()
const id = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
return { signal: controller.signal, clear: () => clearTimeout(id) }
}

async function generateWithMiniMax(prompt) {
const key = (config.minimaxApiKey || '').trim().replace(/^['"]|['"]$/g, '')
if (!key) throw new Error('MiniMax API key not configured')
console.log(`MiniMax key prefix: "${key.slice(0, 6)}..." (len ${key.length})`)

const t = withTimeout()
const response = await fetch('https://api.minimax.io/v1/text/chatcompletion_v2', {
method: 'POST',
signal: t.signal,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${key}`
},
body: JSON.stringify({
model: 'MiniMax-Text-01',
messages: [{ role: 'user', content: prompt }],
temperature: 0.7,
max_tokens: 2000
})
})
t.clear()
if (!response.ok) {
const errorData = await response.text()
throw new Error(`MiniMax API error: ${response.status} - ${errorData}`)
}
const data = await response.json()
const content = data.choices?.[0]?.message?.content || ''
if (!content) {
console.warn('MiniMax returned 200 with empty content. Full response:', JSON.stringify(data))
}
return content
}

async function generateWithOpenAI(prompt, model = 'gpt-4o-mini') {
const t = withTimeout()
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
signal: t.signal,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.openaiApiKey}`
},
body: JSON.stringify({
model,
messages: [{ role: 'user', content: prompt }],
temperature: 0.7,
max_tokens: 2000
})
})
t.clear()
if (!response.ok) {
const errorData = await response.text()
throw new Error(`OpenAI API error: ${response.status} - ${errorData}`)
}
const data = await response.json()
return data.choices?.[0]?.message?.content || ''
}

async function generateWithAnthropic(prompt, model = 'claude-sonnet-4-20250514') {
const t = withTimeout()
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
signal: t.signal,
headers: {
'Content-Type': 'application/json',
'x-api-key': config.anthropicApiKey,
'anthropic-version': '2023-06-01'
},
body: JSON.stringify({
model,
messages: [{ role: 'user', content: prompt }],
max_tokens: 2000,
temperature: 0.7
})
})
t.clear()
if (!response.ok) {
const errorData = await response.text()
throw new Error(`Anthropic API error: ${response.status} - ${errorData}`)
}
const data = await response.json()
return data.content?.[0]?.text || ''
}

async function generateWithGoogle(prompt, model = 'gemini-2.0-flash') {
const t = withTimeout()
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${config.googleApiKey}`, {
method: 'POST',
signal: t.signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
generationConfig: { temperature: 0.7, maxOutputTokens: 2000 }
})
})
t.clear()
if (!response.ok) {
const errorData = await response.text()
throw new Error(`Google API error: ${response.status} - ${errorData}`)
}
const data = await response.json()
return data.candidates?.[0]?.content?.parts?.[0]?.text || ''
}



const PROVIDER_HANDLERS = {
minimax: { keyName: 'minimaxApiKey', run: generateWithMiniMax },
openai: { keyName: 'openaiApiKey', run: generateWithOpenAI },
anthropic: { keyName: 'anthropicApiKey', run: generateWithAnthropic },
google: { keyName: 'googleApiKey', run: generateWithGoogle }
}

const PROVIDER_LABELS = {
minimax: 'MiniMax',
openai: 'OpenAI',
anthropic: 'Anthropic',
google: 'Google'
}

export async function callProvider(provider, prompt) {
const handler = PROVIDER_HANDLERS[provider]
if (!handler) throw new Error(`Unknown provider: ${provider}`)
if (!config[handler.keyName]) throw new Error(`${PROVIDER_LABELS[provider] || provider} API key not configured`)
try {
return await handler.run(prompt)
} catch (err) {
if (err.name === 'AbortError') throw new Error(`${PROVIDER_LABELS[provider] || provider} timed out after ${FETCH_TIMEOUT_MS / 1000}s`)
throw err
}
}

export { PROVIDER_HANDLERS }
76 changes: 76 additions & 0 deletions backend/src/services/pollSchedulerService.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// pollSchedulerService.js
// Pure decision-logic helpers. No DB/socket calls here — callers pass in
// values they already have and act on what's returned.

const DEFAULT_MIN_TRANSCRIPT_CHARS = 50
const DEFAULT_MIN_INTERVAL_MS = 15 * 1000 // don't allow back-to-back generation calls within 15s

function shouldGenerate({
newTranscriptLength = 0,
lastGenerationTimestamp = null,
isAlreadyGenerating = false,
minTranscriptChars = DEFAULT_MIN_TRANSCRIPT_CHARS,
minIntervalMs = DEFAULT_MIN_INTERVAL_MS
} = {}) {
if (isAlreadyGenerating) return false
if (newTranscriptLength < minTranscriptChars) return false
if (lastGenerationTimestamp) {
const elapsed = Date.now() - lastGenerationTimestamp
if (elapsed < minIntervalMs) return false
}
return true
}

function shouldLaunch({ queueLength = 0, topConfidence = 0, minConfidence = 40 } = {}) {
if (queueLength === 0) return false
return topConfidence >= minConfidence
}

// ---------------------------------------------------------------------------
// Quota allocation — generalized to any set of question types, so it isn't
// locked to TF/MCQ. Works for any typeMix object, e.g.:
// allocateQuota(4, { TF: 50, MCQ: 50 }) -> { TF: 2, MCQ: 2 }
// allocateQuota(5, { TF: 50, MCQ: 50 }) -> { TF: 3, MCQ: 2 }
// allocateQuota(8, { TF: 50, MCQ: 50 }) -> { TF: 4, MCQ: 4 }
// allocateQuota(6, { MCQ: 40, TF: 40, MSQ: 20 })
// Uses the largest-remainder method so quotas always sum to exactly `total`.
// On ties, the type listed earlier in typeMix wins the extra unit — pass TF
// before MCQ in the mix if you want odd totals to favor TF, etc.
// ---------------------------------------------------------------------------
function allocateQuota(total, typeMix = {}) {
const types = Object.keys(typeMix).filter((t) => typeMix[t] > 0)
if (types.length === 0 || total <= 0) return {}

const mixTotal = types.reduce((sum, t) => sum + typeMix[t], 0)
const raw = types.map((t) => (typeMix[t] / mixTotal) * total)
const floors = raw.map(Math.floor)
let remainder = total - floors.reduce((a, b) => a + b, 0)

const fractionOrder = raw
.map((v, i) => ({ i, frac: v - Math.floor(v) }))
.sort((a, b) => b.frac - a.frac)

const quotas = {}
types.forEach((t, i) => { quotas[t] = floors[i] })

for (let k = 0; k < fractionOrder.length && remainder > 0; k++, remainder--) {
quotas[types[fractionOrder[k].i]]++
}

return quotas
}

// How many candidates to request from the AI per type, so the scoring
// engine has real options to pick from instead of just the exact quota.
// candidateCount = quota * multiplier, bounded to [minPerType, capPerType].
function computeCandidateCounts(quotas, { multiplier = 2, minPerType = 3, capPerType = 20 } = {}) {
const counts = {}
for (const type of Object.keys(quotas)) {
counts[type] = quotas[type] > 0
? Math.min(capPerType, Math.max(minPerType, quotas[type] * multiplier))
: 0
}
return counts
}

export { shouldGenerate, shouldLaunch, allocateQuota, computeCandidateCounts }
Loading