164 lines
6.0 KiB
TypeScript
164 lines
6.0 KiB
TypeScript
// 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<Uint8Array>({
|
|
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,
|
|
);
|
|
}
|
|
}
|