github-actions[bot] commited on
Commit
5b42e02
·
1 Parent(s): ea7dc28

Sync from GitHub 57f0387cdc87b7c8d249dd7a19ba746a788b5ec4

Browse files
src/artifacts/quiz_generator.py CHANGED
@@ -5,35 +5,85 @@ from __future__ import annotations
5
 
6
  import json
7
  import os
 
8
  from datetime import datetime
9
  from pathlib import Path
10
  from typing import Any, Dict, List, Optional
11
 
12
  from dotenv import load_dotenv
13
  from openai import OpenAI
 
14
 
15
  from src.ingestion.vectorstore import ChromaAdapter
16
 
17
  load_dotenv()
18
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
  class QuizGenerator:
21
- def __init__(self, api_key: Optional[str] = None, model: Optional[str] = None):
 
 
 
 
 
22
  """
23
  Initialize quiz generator.
24
 
25
  Args:
26
  api_key: OpenAI API key (defaults to OPENAI_API_KEY from .env)
27
  model: LLM model to use (defaults to LLM_MODEL from .env)
 
28
  """
29
- self.api_key = api_key or os.getenv("OPENAI_API_KEY")
30
- self.model = model or os.getenv("LLM_MODEL", "gpt-4o-mini")
31
- self.client = OpenAI(api_key=self.api_key)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
 
33
  # Default settings from .env
34
  self.default_num_questions = int(os.getenv("DEFAULT_QUIZ_QUESTIONS", "5"))
35
  self.default_difficulty = os.getenv("DEFAULT_QUIZ_DIFFICULTY", "medium")
36
 
 
 
 
 
 
 
 
 
 
 
 
37
  def generate_quiz(
38
  self,
39
  user_id: str,
@@ -73,16 +123,32 @@ class QuizGenerator:
73
  # 2. Generate quiz using LLM
74
  print("🤖 Generating questions with LLM...")
75
  quiz_data = self._generate_with_llm(context, num_questions, difficulty)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
 
77
  # 3. Format and return
78
  return {
79
- "questions": quiz_data.get("questions", []),
80
  "metadata": {
81
  "notebook_id": notebook_id,
82
  "num_questions": num_questions,
83
  "difficulty": difficulty,
84
  "topic_focus": topic_focus,
85
- "model": self.model,
 
86
  "generated_at": datetime.utcnow().isoformat(),
87
  },
88
  }
@@ -147,25 +213,135 @@ class QuizGenerator:
147
  prompt = self._build_quiz_prompt(context, num_questions, difficulty)
148
 
149
  try:
150
- response = self.client.chat.completions.create(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  model=self.model,
152
  messages=[
153
- {
154
- "role": "system",
155
- "content": "You are an expert quiz generator. Create clear, educational, and well-structured quiz questions.",
156
- },
157
  {"role": "user", "content": prompt},
158
  ],
159
  temperature=0.7,
160
  response_format={"type": "json_object"},
161
  )
 
162
 
163
- quiz_data = json.loads(response.choices[0].message.content)
164
- return quiz_data
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
 
166
- except Exception as e:
167
- print(f"❌ Error generating quiz: {e}")
168
- return {"questions": []}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
 
170
  def _build_quiz_prompt(self, context: str, num_questions: int, difficulty: str) -> str:
171
  """Build the quiz generation prompt."""
 
5
 
6
  import json
7
  import os
8
+ import re
9
  from datetime import datetime
10
  from pathlib import Path
11
  from typing import Any, Dict, List, Optional
12
 
13
  from dotenv import load_dotenv
14
  from openai import OpenAI
15
+ import requests
16
 
17
  from src.ingestion.vectorstore import ChromaAdapter
18
 
19
  load_dotenv()
20
 
21
+ SUPPORTED_QUIZ_LLM_PROVIDERS = {"openai", "groq", "ollama"}
22
+ DEFAULT_QUIZ_MODELS = {
23
+ "openai": "gpt-4o-mini",
24
+ "groq": "llama-3.1-8b-instant",
25
+ "ollama": "qwen2.5:3b",
26
+ }
27
+ QUIZ_SYSTEM_PROMPT = (
28
+ "You are an expert quiz generator. Create clear, educational, and well-structured "
29
+ "multiple-choice questions. Return valid JSON only with a top-level 'questions' array."
30
+ )
31
+
32
 
33
  class QuizGenerator:
34
+ def __init__(
35
+ self,
36
+ api_key: Optional[str] = None,
37
+ model: Optional[str] = None,
38
+ llm_provider: Optional[str] = None,
39
+ ):
40
  """
41
  Initialize quiz generator.
42
 
43
  Args:
44
  api_key: OpenAI API key (defaults to OPENAI_API_KEY from .env)
45
  model: LLM model to use (defaults to LLM_MODEL from .env)
46
+ llm_provider: Quiz LLM provider (openai, groq, ollama)
47
  """
48
+ self.llm_provider = (llm_provider or os.getenv("QUIZ_LLM_PROVIDER", "openai")).strip().lower()
49
+ if self.llm_provider not in SUPPORTED_QUIZ_LLM_PROVIDERS:
50
+ raise ValueError(
51
+ f"Unsupported QUIZ_LLM_PROVIDER='{self.llm_provider}'. "
52
+ f"Choose from: {sorted(SUPPORTED_QUIZ_LLM_PROVIDERS)}"
53
+ )
54
+ self.model = self._resolve_model_name(model)
55
+ self._openai_client: OpenAI | None = None
56
+ self._groq_client = None
57
+ self._ollama_base_url = os.getenv("OLLAMA_BASE_URL", "http://127.0.0.1:11434").rstrip("/")
58
+
59
+ if self.llm_provider == "openai":
60
+ self.api_key = api_key or os.getenv("OPENAI_API_KEY")
61
+ self._openai_client = OpenAI(api_key=self.api_key)
62
+ elif self.llm_provider == "groq":
63
+ from groq import Groq
64
+
65
+ groq_api_key = os.getenv("GROQ_API_KEY")
66
+ if not groq_api_key:
67
+ raise ValueError("GROQ_API_KEY is required when QUIZ_LLM_PROVIDER=groq")
68
+ self._groq_client = Groq(api_key=groq_api_key)
69
+ else:
70
+ self.api_key = None
71
 
72
  # Default settings from .env
73
  self.default_num_questions = int(os.getenv("DEFAULT_QUIZ_QUESTIONS", "5"))
74
  self.default_difficulty = os.getenv("DEFAULT_QUIZ_DIFFICULTY", "medium")
75
 
76
+ def _resolve_model_name(self, explicit_model: Optional[str]) -> str:
77
+ if explicit_model and explicit_model.strip():
78
+ return explicit_model.strip()
79
+ configured = os.getenv("QUIZ_LLM_MODEL", "").strip()
80
+ if configured:
81
+ return configured
82
+ legacy = os.getenv("LLM_MODEL", "").strip()
83
+ if legacy:
84
+ return legacy
85
+ return DEFAULT_QUIZ_MODELS.get(self.llm_provider, "gpt-4o-mini")
86
+
87
  def generate_quiz(
88
  self,
89
  user_id: str,
 
123
  # 2. Generate quiz using LLM
124
  print("🤖 Generating questions with LLM...")
125
  quiz_data = self._generate_with_llm(context, num_questions, difficulty)
126
+ questions = quiz_data.get("questions", []) if isinstance(quiz_data, dict) else []
127
+ if not questions:
128
+ return {
129
+ "error": "Failed to generate quiz questions from notebook context.",
130
+ "questions": [],
131
+ "metadata": {
132
+ "notebook_id": notebook_id,
133
+ "num_questions": num_questions,
134
+ "difficulty": difficulty,
135
+ "topic_focus": topic_focus,
136
+ "llm_provider": self.llm_provider,
137
+ "llm_model": self.model,
138
+ "generated_at": datetime.utcnow().isoformat(),
139
+ },
140
+ }
141
 
142
  # 3. Format and return
143
  return {
144
+ "questions": questions,
145
  "metadata": {
146
  "notebook_id": notebook_id,
147
  "num_questions": num_questions,
148
  "difficulty": difficulty,
149
  "topic_focus": topic_focus,
150
+ "llm_provider": self.llm_provider,
151
+ "llm_model": self.model,
152
  "generated_at": datetime.utcnow().isoformat(),
153
  },
154
  }
 
213
  prompt = self._build_quiz_prompt(context, num_questions, difficulty)
214
 
215
  try:
216
+ raw_response = self._generate_quiz_json(prompt)
217
+ payload = self._extract_json_object(raw_response)
218
+ questions = payload.get("questions") if isinstance(payload, dict) else None
219
+ if not isinstance(questions, list):
220
+ return {"questions": []}
221
+ return {
222
+ "questions": self._normalize_questions(
223
+ questions=questions,
224
+ expected_count=num_questions,
225
+ difficulty=difficulty,
226
+ )
227
+ }
228
+
229
+ except Exception as e:
230
+ print(f"❌ Error generating quiz: {e}")
231
+ return {"questions": []}
232
+
233
+ def _generate_quiz_json(self, prompt: str) -> str:
234
+ if self.llm_provider == "openai":
235
+ assert self._openai_client is not None
236
+ response = self._openai_client.chat.completions.create(
237
  model=self.model,
238
  messages=[
239
+ {"role": "system", "content": QUIZ_SYSTEM_PROMPT},
 
 
 
240
  {"role": "user", "content": prompt},
241
  ],
242
  temperature=0.7,
243
  response_format={"type": "json_object"},
244
  )
245
+ return str(response.choices[0].message.content or "")
246
 
247
+ if self.llm_provider == "groq":
248
+ assert self._groq_client is not None
249
+ response = self._groq_client.chat.completions.create(
250
+ model=self.model,
251
+ messages=[
252
+ {"role": "system", "content": QUIZ_SYSTEM_PROMPT},
253
+ {"role": "user", "content": prompt},
254
+ ],
255
+ temperature=0.7,
256
+ )
257
+ return str(response.choices[0].message.content or "")
258
+
259
+ payload = {
260
+ "model": self.model,
261
+ "system": QUIZ_SYSTEM_PROMPT,
262
+ "prompt": prompt,
263
+ "stream": False,
264
+ "options": {"temperature": 0.7},
265
+ }
266
+ response = requests.post(
267
+ f"{self._ollama_base_url}/api/generate",
268
+ json=payload,
269
+ timeout=120,
270
+ )
271
+ response.raise_for_status()
272
+ body = response.json()
273
+ return str(body.get("response", ""))
274
 
275
+ def _extract_json_object(self, raw_response: str) -> Dict[str, Any]:
276
+ content = str(raw_response or "").strip()
277
+ if not content:
278
+ return {}
279
+
280
+ if content.startswith("```"):
281
+ content = re.sub(r"^```(?:json)?\s*", "", content)
282
+ content = re.sub(r"\s*```$", "", content)
283
+
284
+ try:
285
+ parsed = json.loads(content)
286
+ if isinstance(parsed, dict):
287
+ return parsed
288
+ except json.JSONDecodeError:
289
+ pass
290
+
291
+ match = re.search(r"\{.*\}", content, re.DOTALL)
292
+ if match:
293
+ try:
294
+ parsed = json.loads(match.group(0))
295
+ if isinstance(parsed, dict):
296
+ return parsed
297
+ except json.JSONDecodeError:
298
+ return {}
299
+ return {}
300
+
301
+ def _normalize_questions(
302
+ self,
303
+ questions: List[Any],
304
+ expected_count: int,
305
+ difficulty: str,
306
+ ) -> List[Dict[str, Any]]:
307
+ normalized: List[Dict[str, Any]] = []
308
+ for item in questions:
309
+ if not isinstance(item, dict):
310
+ continue
311
+ prompt = str(item.get("question", "")).strip()
312
+ if not prompt:
313
+ continue
314
+
315
+ options_raw = item.get("options")
316
+ options: List[str] = []
317
+ if isinstance(options_raw, list):
318
+ for opt in options_raw:
319
+ text = str(opt).strip()
320
+ if text:
321
+ options.append(text)
322
+
323
+ answer = self._normalize_answer_letter(str(item.get("correct_answer", "")).strip())
324
+ explanation = str(item.get("explanation", "")).strip()
325
+ topic = str(item.get("topic", "")).strip()
326
+
327
+ normalized.append(
328
+ {
329
+ "id": len(normalized) + 1,
330
+ "question": prompt,
331
+ "options": options,
332
+ "correct_answer": answer or "N/A",
333
+ "explanation": explanation,
334
+ "difficulty": difficulty,
335
+ "topic": topic,
336
+ }
337
+ )
338
+ if len(normalized) >= expected_count:
339
+ break
340
+ return normalized
341
+
342
+ def _normalize_answer_letter(self, value: str) -> str:
343
+ match = re.search(r"[A-D]", value.upper())
344
+ return match.group(0) if match else ""
345
 
346
  def _build_quiz_prompt(self, context: str, num_questions: int, difficulty: str) -> str:
347
  """Build the quiz generation prompt."""
tests/test_quiz_llm_providers.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Provider-specific tests for quiz generation.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import pathlib
8
+ import sys
9
+ from unittest.mock import MagicMock, patch
10
+
11
+ ROOT = pathlib.Path(__file__).resolve().parents[1]
12
+ sys.path.insert(0, str(ROOT))
13
+
14
+ from src.artifacts.quiz_generator import QuizGenerator
15
+
16
+
17
+ def _prepare_common_mocks(mock_store_cls):
18
+ mock_store = MagicMock()
19
+ mock_store.query.return_value = [
20
+ ("chunk-1", 0.1, {"document": "Context block for quiz generation.", "metadata": {}})
21
+ ]
22
+ mock_store_cls.return_value = mock_store
23
+
24
+
25
+ def test_quiz_generator_ollama_provider_without_openai_key(tmp_path):
26
+ env = {
27
+ "STORAGE_BASE_DIR": str(tmp_path / "data"),
28
+ "QUIZ_LLM_PROVIDER": "ollama",
29
+ "QUIZ_LLM_MODEL": "qwen2.5:3b",
30
+ "OLLAMA_BASE_URL": "http://127.0.0.1:11434",
31
+ "OPENAI_API_KEY": "",
32
+ }
33
+ with patch.dict(os.environ, env, clear=False):
34
+ with patch("src.artifacts.quiz_generator.Path.exists", return_value=True):
35
+ with patch("src.artifacts.quiz_generator.ChromaAdapter") as mock_store_cls:
36
+ _prepare_common_mocks(mock_store_cls)
37
+
38
+ mock_resp = MagicMock()
39
+ mock_resp.raise_for_status.return_value = None
40
+ mock_resp.json.return_value = {
41
+ "response": (
42
+ '{"questions":[{"id":1,"question":"What is context?",'
43
+ '"options":["A) A","B) B","C) C","D) D"],'
44
+ '"correct_answer":"B","explanation":"Because.","topic":"Basics"}]}'
45
+ )
46
+ }
47
+
48
+ with patch("src.artifacts.quiz_generator.requests.post", return_value=mock_resp):
49
+ generator = QuizGenerator(llm_provider="ollama")
50
+ result = generator.generate_quiz("1", "1", num_questions=1, difficulty="easy")
51
+
52
+ assert "error" not in result
53
+ assert result["metadata"]["llm_provider"] == "ollama"
54
+ assert result["metadata"]["llm_model"] == "qwen2.5:3b"
55
+ assert len(result["questions"]) == 1
56
+ assert result["questions"][0]["correct_answer"] == "B"
57
+
58
+
59
+ def test_quiz_generator_groq_provider_without_openai_key(tmp_path):
60
+ env = {
61
+ "STORAGE_BASE_DIR": str(tmp_path / "data"),
62
+ "QUIZ_LLM_PROVIDER": "groq",
63
+ "QUIZ_LLM_MODEL": "llama-3.1-8b-instant",
64
+ "GROQ_API_KEY": "gsk-test",
65
+ "OPENAI_API_KEY": "",
66
+ }
67
+ with patch.dict(os.environ, env, clear=False):
68
+ with patch("src.artifacts.quiz_generator.Path.exists", return_value=True):
69
+ with patch("src.artifacts.quiz_generator.ChromaAdapter") as mock_store_cls:
70
+ _prepare_common_mocks(mock_store_cls)
71
+
72
+ with patch("groq.Groq") as mock_groq_cls:
73
+ mock_groq = MagicMock()
74
+ mock_groq.chat.completions.create.return_value = MagicMock(
75
+ choices=[
76
+ MagicMock(
77
+ message=MagicMock(
78
+ content=(
79
+ '{"questions":[{"id":1,"question":"What is Groq?",'
80
+ '"options":["A) A","B) B","C) C","D) D"],'
81
+ '"correct_answer":"A","explanation":"Because.","topic":"LLM"}]}'
82
+ )
83
+ )
84
+ )
85
+ ]
86
+ )
87
+ mock_groq_cls.return_value = mock_groq
88
+
89
+ generator = QuizGenerator(llm_provider="groq")
90
+ result = generator.generate_quiz("1", "1", num_questions=1, difficulty="easy")
91
+
92
+ assert "error" not in result
93
+ assert result["metadata"]["llm_provider"] == "groq"
94
+ assert result["metadata"]["llm_model"] == "llama-3.1-8b-instant"
95
+ assert len(result["questions"]) == 1
96
+ assert result["questions"][0]["correct_answer"] == "A"