Files
openmaic/OpenMAIC/app/api/qa/route.ts
2026-08-16 14:58:47 +08:00

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,
);
}
}