// Learner Q&A — POST /api/qa // // Single-agent teaching assistant for a published courseware (design doc §4.4). // 1. resolves the latest published record, // 2. loads the knowledge pack from the frozen bundle (cached, zero-LLM), // 3. retrieves relevant chunks (local term scoring), // 4. runs the single agent with an optional web_search tool, // 5. streams SSE events: text deltas, tool events, done. // // Rate limited per client IP (sliding window) and metered through the shared // LLM usage pipeline (streamLLM records usage with source 'qa-assistant'). import { type NextRequest } from 'next/server'; import { apiError, API_ERROR_CODES } from '@/lib/server/api-response'; import { createFileCoursewareRepo, COURSEWARES_DIR } from '@/lib/courseware-repo/store'; import { resolveModel } from '@/lib/server/resolve-model'; import { createLogger } from '@/lib/logger'; import { createSlidingWindowLimiter, clientIp } from '@/lib/qa/rate-limit'; import { loadCoursewareKnowledge, retrieveChunks } from '@/lib/qa/knowledge'; import { runQaAgent, type QaTurn } from '@/lib/qa/agent'; import { resolveClassroomWebSearchConfig } from '@/lib/server/web-search-config'; import { searchWeb } from '@/lib/web-search'; const log = createLogger('QA API'); export const maxDuration = 120; const QA_MAX_REQUESTS_PER_MIN = Number(process.env.QA_RATE_LIMIT_PER_MIN ?? 15); const qaLimiter = createSlidingWindowLimiter({ windowMs: 60_000, max: QA_MAX_REQUESTS_PER_MIN, }); interface QaRequestBody { coursewareId?: string; messages?: QaTurn[]; userProfile?: string; model?: string; webSearch?: boolean; } const encoder = new TextEncoder(); export async function POST(request: NextRequest) { let coursewareId = ''; try { const body = (await request.json()) as QaRequestBody; coursewareId = String(body.coursewareId ?? ''); const messages = Array.isArray(body.messages) ? body.messages : []; if (!coursewareId || !/^[a-zA-Z0-9_-]+$/.test(coursewareId)) { return apiError(API_ERROR_CODES.MISSING_REQUIRED_FIELD, 400, 'Missing required field: coursewareId'); } const userMessages = messages.filter((m) => m.role === 'user' && typeof m.content === 'string'); if (userMessages.length === 0 || !userMessages[userMessages.length - 1].content.trim()) { return apiError(API_ERROR_CODES.MISSING_REQUIRED_FIELD, 400, 'A user message is required'); } const ip = clientIp(request); const limit = qaLimiter.check(`${ip}:${coursewareId}`); if (!limit.allowed) { return apiError( API_ERROR_CODES.RATE_LIMITED, 429, `QA rate limit exceeded; retry in ${Math.ceil(limit.retryAfterMs / 1000)}s`, ); } // 1. Resolve the latest published version. const repo = createFileCoursewareRepo(COURSEWARES_DIR); const record = await repo.getLatestRecord(coursewareId, { status: 'published' }); if (!record) { return apiError(API_ERROR_CODES.INVALID_REQUEST, 404, 'Courseware not found'); } // 2. Load the knowledge pack (cached) — bundles without a knowledge pack // (pre-P3 publishes) are re-publishable; reject with a clear message. const knowledge = await loadCoursewareKnowledge(coursewareId, record.version); if (!knowledge) { return apiError( API_ERROR_CODES.INVALID_REQUEST, 409, 'This courseware version has no knowledge pack; please re-publish it', ); } // 3. Retrieve relevant chunks for the last user message. const lastUserMessage = userMessages[userMessages.length - 1].content; const chunks = retrieveChunks(knowledge.knowledge, lastUserMessage, 3); // 4. Resolve the model (routable via MODEL_ROUTES stage 'qa-assistant'). const resolved = await resolveModel({ stage: 'qa-assistant', modelString: typeof body.model === 'string' ? body.model : undefined, }); // 5. Optional web_search tool from server configuration. const searchEnabled = body.webSearch !== false; const searchConfig = searchEnabled ? resolveClassroomWebSearchConfig({}) : undefined; // 6. Stream the agent run as SSE. const stream = new ReadableStream({ async start(controller) { const writer = controller.enqueue.bind(controller); try { for await (const event of runQaAgent( { model: resolved.model, courseware: knowledge, chunks, ...(typeof body.userProfile === 'string' && body.userProfile.trim() ? { userProfile: body.userProfile } : {}), ...(searchConfig ? { webSearch: { maxTurns: 2, execute: (query) => searchWeb({ ...searchConfig, query, maxResults: 5 }), }, } : {}), thinkingConfig: resolved.thinkingConfig, }, messages, )) { await writer(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); } await writer(encoder.encode(`data: ${JSON.stringify({ type: 'streamEnd' })}\n\n`)); } catch (error) { log.error('QA agent run failed:', error); await writer( encoder.encode( `data: ${JSON.stringify({ type: 'error', error: error instanceof Error ? error.message : String(error), })}\n\n`, ), ); } finally { controller.close(); } }, }); return new Response(stream, { headers: { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive', 'X-Accel-Buffering': 'no', }, }); } catch (error) { log.error(`QA failed [coursewareId=${coursewareId}]:`, error); return apiError( API_ERROR_CODES.INTERNAL_ERROR, 500, 'QA request failed', error instanceof Error ? error.message : undefined, ); } }