feat: add QuestionService

Made-with: Cursor
This commit is contained in:
Anton
2026-03-04 14:44:01 +03:00
parent 50d6b34f11
commit 9fbb6431d8

View File

@@ -0,0 +1,197 @@
import { eq, and, sql, asc, inArray } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type * as schema from '../../db/schema/index.js';
import {
questionBank,
questionCacheMeta,
userQuestionLog,
} from '../../db/schema/index.js';
import { internalError, AppError, ERROR_CODES } from '../../utils/errors.js';
import type { Stack, Level, QuestionType } from '../../db/schema/enums.js';
import type {
GeneratedQuestion,
GenerateQuestionsResult,
} from '../llm/llm.service.js';
type Db = NodePgDatabase<typeof schema>;
export type QuestionForTest = {
questionBankId: string;
type: QuestionType;
questionText: string;
options: Array<{ key: string; text: string }> | null;
correctAnswer: string | string[];
explanation: string;
};
export interface LlmServiceInterface {
generateQuestions(input: {
stack: Stack;
level: Level;
count: number;
types?: QuestionType[];
}): Promise<GenerateQuestionsResult>;
}
function shuffle<T>(arr: T[]): T[] {
const result = [...arr];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[result[i], result[j]] = [result[j]!, result[i]!];
}
return result;
}
export class QuestionService {
constructor(
private readonly db: Db,
private readonly llmService: LlmServiceInterface
) {}
/**
* Get questions for a test. Fetches approved questions from question_bank.
* If not enough, generates via LLM, persists to question_bank and question_cache_meta.
*/
async getQuestionsForTest(
userId: string,
stack: Stack,
level: Level,
count: number
): Promise<QuestionForTest[]> {
const approved = await this.db
.select({
id: questionBank.id,
type: questionBank.type,
questionText: questionBank.questionText,
options: questionBank.options,
correctAnswer: questionBank.correctAnswer,
explanation: questionBank.explanation,
})
.from(questionBank)
.where(
and(eq(questionBank.stack, stack), eq(questionBank.level, level), eq(questionBank.status, 'approved'))
)
.orderBy(asc(questionBank.usageCount));
const seenIds = await this.db
.select({ questionBankId: userQuestionLog.questionBankId })
.from(userQuestionLog)
.where(eq(userQuestionLog.userId, userId));
const seenSet = new Set(seenIds.map((r) => r.questionBankId));
const preferred = approved.filter((q) => !seenSet.has(q.id));
const pool = preferred.length >= count ? preferred : approved;
const shuffled = shuffle(pool);
const fromBank: QuestionForTest[] = shuffled.slice(0, count).map((q) => ({
questionBankId: q.id,
type: q.type,
questionText: q.questionText,
options: q.options,
correctAnswer: q.correctAnswer,
explanation: q.explanation,
}));
let result = fromBank;
if (result.length < count) {
const generated = await this.generateAndPersistQuestions(
stack,
level,
count - result.length
);
result = [...result, ...generated];
}
if (result.length < count) {
throw new AppError(
ERROR_CODES.QUESTIONS_UNAVAILABLE,
'Not enough questions available for this stack and level',
422
);
}
if (result.length > 0) {
await this.db.insert(userQuestionLog).values(
result.map((q) => ({
userId,
questionBankId: q.questionBankId,
}))
);
const ids = result.map((q) => q.questionBankId);
await this.db
.update(questionBank)
.set({
usageCount: sql`${questionBank.usageCount} + 1`,
})
.where(inArray(questionBank.id, ids));
}
return result;
}
private async generateAndPersistQuestions(
stack: Stack,
level: Level,
count: number
): Promise<QuestionForTest[]> {
let generated: GeneratedQuestion[];
let meta: GenerateQuestionsResult['meta'];
try {
const result = await this.llmService.generateQuestions({
stack,
level,
count,
});
generated = result.questions;
meta = result.meta;
} catch (err) {
throw internalError(
'Failed to generate questions',
err instanceof Error ? err : undefined
);
}
const inserted: QuestionForTest[] = [];
for (const q of generated) {
const [row] = await this.db
.insert(questionBank)
.values({
stack,
level,
type: q.type as QuestionType,
questionText: q.questionText,
options: q.options ?? null,
correctAnswer: q.correctAnswer,
explanation: q.explanation,
status: 'approved',
source: 'llm_generated',
})
.returning();
if (row) {
await this.db.insert(questionCacheMeta).values({
questionBankId: row.id,
llmModel: meta.llmModel,
promptHash: meta.promptHash,
generationTimeMs: meta.generationTimeMs,
rawResponse: meta.rawResponse,
});
inserted.push({
questionBankId: row.id,
type: row.type,
questionText: row.questionText,
options: row.options,
correctAnswer: row.correctAnswer,
explanation: row.explanation,
});
}
}
return inserted;
}
}