From 9fbb6431d850f29ca8e7527d183bf8caa8c4b23f Mon Sep 17 00:00:00 2001 From: Anton Date: Wed, 4 Mar 2026 14:44:01 +0300 Subject: [PATCH] feat: add QuestionService Made-with: Cursor --- src/services/questions/question.service.ts | 197 +++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 src/services/questions/question.service.ts diff --git a/src/services/questions/question.service.ts b/src/services/questions/question.service.ts new file mode 100644 index 0000000..9e93e39 --- /dev/null +++ b/src/services/questions/question.service.ts @@ -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; + +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; +} + +function shuffle(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 { + 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 { + 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; + } +}