Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@
// Rate limiting
RATE_LIMIT_MAX: z.coerce.number().default(100),
RATE_LIMIT_WINDOW_MS: z.coerce.number().default(60_000),

// AI service (chainlearn-ai) used for quiz generation
AI_SERVICE_URL: z.string().url().default("http://localhost:8000"),
});

export type Env = z.infer<typeof envSchema>;
Expand Down Expand Up @@ -74,6 +77,6 @@
// Lazy config — loadConfig() only runs on first property access, not at import time
export const config: Env = new Proxy({} as Env, {
get(_, prop) {
return (ensureConfig() as any)[prop];

Check warning on line 80 in src/config/index.ts

View workflow job for this annotation

GitHub Actions / Lint & Typecheck

Unexpected any. Specify a different type
},
});
50 changes: 50 additions & 0 deletions src/modules/quizzes/ai-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { config } from "../../config/index.js";
import { logger } from "../../utils/logger.js";

interface AiQuizQuestion {
prompt: string;
options: string[];
correct_index: number;
}

interface AiQuizResponse {
quiz_id: string;
questions: AiQuizQuestion[];
}

export type AiDifficulty = "beginner" | "intermediate" | "advanced";

export interface GenerateQuizFromAIParams {
userId: string;
courseId: string;
moduleId: string;
difficulty: AiDifficulty;
numQuestions: number;
}

export async function generateQuizFromAI(
params: GenerateQuizFromAIParams
): Promise<AiQuizQuestion[]> {
const response = await fetch(`${config.AI_SERVICE_URL}/generate-quiz`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_id: params.userId,
course_id: params.courseId,
module_id: params.moduleId,
difficulty: params.difficulty,
num_questions: params.numQuestions,
}),
});

if (!response.ok) {
logger.error(
{ status: response.status },
"AI service quiz generation failed"
);
throw new Error(`AI service returned ${response.status}`);
}

const data = (await response.json()) as AiQuizResponse;
return data.questions;
}
41 changes: 33 additions & 8 deletions src/modules/quizzes/quiz.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { NotFoundError, ForbiddenError, ConflictError } from "../../utils/errors
import { withLock } from "../../utils/lock.js";
import { createQuizProof } from "../../stellar/signatures.js";
import { logger } from "../../utils/logger.js";
import { generateQuizFromAI } from "./ai-client.js";
import type {
GenerateQuizBody,
SubmitQuizBody,
Expand All @@ -17,9 +18,10 @@ const PASSING_PERCENTAGE = 70;

export class QuizService {
/**
* Generate a quiz for a given course/module.
* In a real system this would call an AI service; here we return
* a pre-generated quiz or fetch from the database.
* Generate a quiz for a given course/module. Calls the chainlearn-ai
* service for fresh questions and falls back to a fixed placeholder set
* if the service is unreachable. Existing quizzes for the same user +
* module are returned as-is so a refresh doesn't regenerate.
*/
async generateQuiz(
userId: string,
Expand Down Expand Up @@ -64,11 +66,34 @@ export class QuizService {
};
}

// Generate new quiz (placeholder — would call AI service)
const generatedQuestions = this.createPlaceholderQuestions(
data.courseId,
data.moduleId
);
// Generate new quiz via the chainlearn-ai service. Fall back to the
// hardcoded placeholder set if the AI service is unreachable so the
// dashboard never breaks on a transient outage.
let generatedQuestions;
try {
const aiQuestions = await generateQuizFromAI({
userId,
courseId: data.courseId,
moduleId: data.moduleId,
difficulty: data.difficulty ?? "beginner",
numQuestions: data.numQuestions ?? 5,
});
generatedQuestions = aiQuestions.map((q, i) => ({
id: `q${i + 1}`,
text: q.prompt,
options: q.options,
correctIndex: q.correct_index,
}));
} catch (err) {
logger.warn(
{ err },
"AI service unavailable, falling back to placeholder questions"
);
generatedQuestions = this.createPlaceholderQuestions(
data.courseId,
data.moduleId
);
}

const [quiz] = await db
.insert(quizzes)
Expand Down
2 changes: 2 additions & 0 deletions src/modules/quizzes/quiz.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { z } from "zod";
export const generateQuizSchema = z.object({
courseId: z.string().uuid("Invalid course ID"),
moduleId: z.string().min(1, "Module ID is required"),
difficulty: z.enum(["beginner", "intermediate", "advanced"]).optional(),
numQuestions: z.coerce.number().int().min(1).max(20).optional(),
});

export const submitQuizSchema = z.object({
Expand Down
Loading