Awab-Elsadig commited on
Commit
4b240c2
·
1 Parent(s): c33a552

Initialize standalone Quiz_Generation service repo

Browse files
Files changed (3) hide show
  1. .gitignore +10 -82
  2. README.md +27 -61
  3. app/services/quiz_service.py +93 -47
.gitignore CHANGED
@@ -1,85 +1,13 @@
1
- # compiled output
2
- /dist
3
- /node_modules
4
- /build
5
-
6
- # Logs
7
- logs
8
- *.log
9
- npm-debug.log*
10
- pnpm-debug.log*
11
- yarn-debug.log*
12
- yarn-error.log*
13
- lerna-debug.log*
14
-
15
- # OS
16
- .DS_Store
17
-
18
- # Tests
19
- /coverage
20
- /.nyc_output
21
-
22
- # IDEs and editors
23
- /.idea
24
- .project
25
- .classpath
26
- .c9/
27
- *.launch
28
- .settings/
29
- *.sublime-workspace
30
-
31
- # IDE - VSCode
32
- .vscode/*
33
- !.vscode/settings.json
34
- !.vscode/tasks.json
35
- !.vscode/launch.json
36
- !.vscode/extensions.json
37
-
38
-
39
- # Aiven / cloud DB TLS (local only; never commit real certs)
40
- ca.pem
41
-
42
- .env
43
- .env.local
44
- .env.aiven
45
- .env.*.local
46
- .env.test.local
47
- .env.production.local
48
- # temp directory
49
- .temp
50
- .tmp
51
-
52
- # Runtime data
53
- pids
54
- *.pid
55
- *.seed
56
- *.pid.lock
57
-
58
- # Diagnostic reports (https://nodejs.org/api/report.html)
59
- report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
60
-
61
- !.env.example
62
-
63
- # Python (AI microservices and bytecode)
64
- __pycache__/
65
- *.py[cod]
66
- *$py.class
67
  .venv/
68
- venv/
69
- ENV/
70
- ai-services/**/__pycache__/
71
- ai-services/**/.venv/
72
- ai-services/**/venv/
73
- ai-services/**/*.pyc
74
- # Local test images (not committed). README stays tracked so the folder exists in the repo.
75
- ai-services/attendance/dataset/*
76
- !ai-services/attendance/dataset/README.txt
77
- ai-services/attendance/Attendance/
78
- ai-services/attendance/temp_*
79
 
80
- # Quiz generator local runtime state (upload registry + stored quizzes)
81
- ai-services/quiz-generator/quizzes.json
82
- ai-services/quiz-generator/uploads.json
 
83
 
84
- # Runtime uploads should never be versioned
85
- uploads/
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
 
 
 
 
 
 
 
 
5
 
6
+ # Runtime/local state
7
+ uploads.json
8
+ quizzes.json
9
+ results/
10
 
11
+ # OS/editor artifacts
12
+ .DS_Store
13
+ Thumbs.db
README.md CHANGED
@@ -13,8 +13,10 @@ A FastAPI-based microservice that generates quizzes from uploaded
13
  documents using an AI model.
14
 
15
  The API accepts PDF, DOCX, or TXT files, extracts their text, and
16
- generates different types of questions such as: - Multiple Choice
17
- Questions (MCQ) - Fill in the Blank - Explanation Questions
 
 
18
 
19
  The questions are generated using the Groq LLM (Llama 3.3 70B) via
20
  LangChain.
@@ -40,21 +42,13 @@ INSTALLATION
40
 
41
  ---
42
 
43
- 1. Clone the repository
44
-
45
- git clone cd ai-exam-generator
46
-
47
- 2. Create a virtual environment
48
-
49
- python -m venv .venv
50
-
51
- 3. Activate the environment
52
-
53
- Windows (PowerShell) .venv
54
-
55
- 4. Install dependencies
56
-
57
- pip install -r requirements.txt
58
 
59
  ---
60
 
@@ -62,9 +56,9 @@ ENVIRONMENT VARIABLES
62
 
63
  ---
64
 
65
- Create a .env file in the project root:
66
 
67
- MY_API_KEY=your_groq_api_key_here
68
 
69
  This key is required to access the Groq LLM.
70
 
@@ -76,15 +70,15 @@ RUNNING THE API
76
 
77
  Start the server:
78
 
79
- uvicorn app.main:app –reload
80
 
81
  The API will run at:
82
 
83
- http://127.0.0.1:8000
84
 
85
  Interactive API documentation:
86
 
87
- http://127.0.0.1:8000/docs
88
 
89
  ---
90
 
@@ -92,40 +86,12 @@ API ENDPOINTS
92
 
93
  ---
94
 
95
- POST /api/v1/upload Upload a document (PDF, DOCX, TXT)
96
-
97
- Example response:
98
-
99
- { uploadId: 1234-uuid, status: text_extracted,
100
- total_characters: 5000 }
101
-
102
- ---
103
-
104
- POST /api/v1/generate
105
-
106
- Form parameters: uploadId numQuestions questionType difficulty
107
- saveAsFiles
108
-
109
- Example response:
110
-
111
- { quizId: uuid, status: completed, numQuestions: 5 }
112
-
113
- ---
114
-
115
- GET /api/v1/quiz/{quiz_id} Returns the generated quiz with questions and
116
- answer key.
117
-
118
- ---
119
-
120
- GET /api/v1/download/{filename} Download the generated quiz file.
121
-
122
- ---
123
-
124
- GET /api/v1/uploads List all uploaded documents.
125
-
126
- ---
127
-
128
- DELETE /api/v1/upload/{upload_id} Delete an uploaded document.
129
 
130
  ---
131
 
@@ -133,9 +99,9 @@ EXAMPLE WORKFLOW
133
 
134
  ---
135
 
136
- 1. Upload a file
137
- 2. Generate a quiz
138
- 3. Retrieve the quiz results
139
 
140
  ---
141
 
@@ -143,8 +109,8 @@ TECHNOLOGIES USED
143
 
144
  ---
145
 
146
- FastAPI LangChain Groq LLM (Llama 3.3 70B) Python pdfplumber python-docx
147
- FPDF
148
 
149
  ---
150
 
 
13
  documents using an AI model.
14
 
15
  The API accepts PDF, DOCX, or TXT files, extracts their text, and
16
+ generates different types of questions such as:
17
+ - Multiple Choice Questions (MCQ)
18
+ - Fill in the Blank
19
+ - Explanation Questions
20
 
21
  The questions are generated using the Groq LLM (Llama 3.3 70B) via
22
  LangChain.
 
42
 
43
  ---
44
 
45
+ 1. Clone the repository
46
+ 2. Create a virtual environment
47
+ `python -m venv .venv`
48
+ 3. Activate the environment (Windows PowerShell)
49
+ `.\.venv\Scripts\Activate.ps1`
50
+ 4. Install dependencies
51
+ `pip install -r requirements.txt`
 
 
 
 
 
 
 
 
52
 
53
  ---
54
 
 
56
 
57
  ---
58
 
59
+ Create a `.env` file in the project root:
60
 
61
+ `MY_API_KEY=your_groq_api_key_here`
62
 
63
  This key is required to access the Groq LLM.
64
 
 
70
 
71
  Start the server:
72
 
73
+ `uvicorn app.main:app --reload`
74
 
75
  The API will run at:
76
 
77
+ `http://127.0.0.1:8000`
78
 
79
  Interactive API documentation:
80
 
81
+ `http://127.0.0.1:8000/docs`
82
 
83
  ---
84
 
 
86
 
87
  ---
88
 
89
+ - `POST /api/v1/upload` Upload a document (PDF, DOCX, TXT)
90
+ - `POST /api/v1/generate` Generate questions from uploaded text
91
+ - `GET /api/v1/quiz/{quiz_id}` Returns generated quiz with answer key
92
+ - `GET /api/v1/download/{filename}` Download generated quiz file
93
+ - `GET /api/v1/uploads` List uploaded documents
94
+ - `DELETE /api/v1/upload/{upload_id}` Delete uploaded document
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
 
96
  ---
97
 
 
99
 
100
  ---
101
 
102
+ 1. Upload a file
103
+ 2. Generate a quiz
104
+ 3. Retrieve the quiz results
105
 
106
  ---
107
 
 
109
 
110
  ---
111
 
112
+ FastAPI, LangChain, Groq LLM (Llama 3.3 70B), Python, pdfplumber,
113
+ python-docx, FPDF
114
 
115
  ---
116
 
app/services/quiz_service.py CHANGED
@@ -16,16 +16,21 @@ def format_standard_question(index: int, body: str) -> str:
16
  text = text[m.end() :].strip()
17
  return f"Question {index + 1}: {text}"
18
 
 
19
  llm = ChatGroq(
20
  api_key=MY_API_KEY,
21
  model="llama-3.3-70b-versatile",
22
- temperature=0.0
 
 
23
  )
24
 
25
  mcq_prompt = PromptTemplate(
26
- input_variables=["context", "num_questions"],
27
  template="""
28
- Generate {num_questions} multiple-choice questions from the following text.
 
 
29
  List all questions first, then provide a complete answer key at the end.
30
 
31
  Text:
@@ -51,13 +56,15 @@ D) [Option D]
51
  Question 1: [The letter of the correct option]
52
  Question 2: [The letter of the correct option]
53
  ...and so on.
54
- """
55
  )
56
 
57
  fill_blank_prompt = PromptTemplate(
58
- input_variables=["context", "num_questions"],
59
  template="""
60
  Generate {num_questions} fill-in-the-blank questions from the following text.
 
 
61
  List all questions first, then provide a complete answer key at the end.
62
 
63
  Text:
@@ -73,13 +80,15 @@ Question 2: [Another sentence with a _____ blank]
73
  Question 1: [The word that fills the blank]
74
  Question 2: [The word that fills the blank]
75
  ...and so on.
76
- """
77
  )
78
 
79
  explain_prompt = PromptTemplate(
80
- input_variables=["context", "num_questions"],
81
  template="""
82
  Generate {num_questions} explanation questions from the following text. These should require a detailed answer.
 
 
83
 
84
  Text:
85
  {context}
@@ -95,7 +104,7 @@ Reference: [A relevant quote or fact from the text]
95
 
96
  …continue until you have {num_questions} "## Explanation Question" blocks.
97
  Each question line must start with "Question N:" where N matches the block order (1, 2, 3, …).
98
- """
99
  )
100
 
101
  mcq_chain = mcq_prompt | llm | StrOutputParser()
@@ -103,27 +112,35 @@ fill_blank_chain = fill_blank_prompt | llm | StrOutputParser()
103
  explain_chain = explain_prompt | llm | StrOutputParser()
104
 
105
 
106
- def generate_quiz_from_text(text: str, num_questions: int, question_type: str, difficulty: str):
 
 
107
  final_questions = []
108
  answer_key = []
109
  raw_ai_output = ""
110
 
111
  if question_type == "MCQ":
112
- raw_ai_output = mcq_chain.invoke({"context": text, "num_questions": num_questions})
 
 
 
 
 
 
113
  if "--- ANSWER KEY ---" not in raw_ai_output:
114
  raise ValueError("AI did not provide a separate answer key.")
115
 
116
  questions_block, answers_block = raw_ai_output.split("--- ANSWER KEY ---")
117
 
118
  answers_map = {}
119
- for line in answers_block.strip().split('\n'):
120
  if ":" in line:
121
  q_id, answer = line.split(":", 1)
122
  answers_map[q_id.strip()] = answer.strip()
123
 
124
  question_chunks = questions_block.strip().split("Question")[1:]
125
  for i, chunk in enumerate(question_chunks):
126
- lines = chunk.strip().split('\n')
127
  stem = (
128
  lines[0].split(":", 1)[1].strip()
129
  if lines and ":" in lines[0]
@@ -138,26 +155,36 @@ def generate_quiz_from_text(text: str, num_questions: int, question_type: str, d
138
 
139
  q_id = f"Question {i+1}"
140
  if question_text and options and q_id in answers_map:
141
- final_questions.append({
142
- "type": "MCQ",
143
- "difficulty": difficulty,
144
- "question": question_text,
145
- "options": options
146
- })
147
- answer_key.append({
148
- "questionId": q_id,
149
- "correctAnswer": answers_map[q_id]
150
- })
 
 
 
 
151
 
152
  elif question_type == "FillBlank":
153
- raw_ai_output = fill_blank_chain.invoke({"context": text, "num_questions": num_questions})
 
 
 
 
 
 
154
  if "--- ANSWER KEY ---" not in raw_ai_output:
155
  raise ValueError("AI did not provide a separate answer key.")
156
 
157
  questions_block, answers_block = raw_ai_output.split("--- ANSWER KEY ---")
158
 
159
  answers_map = {}
160
- for line in answers_block.strip().split('\n'):
161
  if ":" in line:
162
  q_id, answer = line.split(":", 1)
163
  answers_map[q_id.strip()] = answer.strip()
@@ -167,27 +194,41 @@ def generate_quiz_from_text(text: str, num_questions: int, question_type: str, d
167
  # Chunk may start with " 1: ..." after splitting on "Question"; strip that
168
  # number so we do not produce "Question 3: 3: ...".
169
  first_line = chunk.strip().split("\n", 1)[0]
170
- stem = first_line.split(":", 1)[1].strip() if ":" in first_line else first_line.strip()
 
 
 
 
171
  rest = chunk.strip().split("\n", 1)
172
  body = stem + ("\n" + rest[1] if len(rest) > 1 else "")
173
  question_text = format_standard_question(i, body)
174
 
175
  q_id = f"Question {i+1}"
176
  if question_text and q_id in answers_map:
177
- final_questions.append({
178
- "type": "FillBlank",
179
- "difficulty": difficulty,
180
- "question": question_text
181
- })
182
- answer_key.append({
183
- "questionId": q_id,
184
- "answer": answers_map[q_id]
185
- })
 
 
 
 
186
 
187
  else:
188
- raw_ai_output = explain_chain.invoke({"context": text, "num_questions": num_questions})
 
 
 
 
 
 
189
  for i, item in enumerate(raw_ai_output.split("## Explanation Question")[1:]):
190
- lines = item.strip().split('\n')
191
  q_body = ""
192
  q_ref = ""
193
  for line in lines:
@@ -200,14 +241,19 @@ def generate_quiz_from_text(text: str, num_questions: int, question_type: str, d
200
  if q_body:
201
  q_id = f"Question {i+1}"
202
  question_text = format_standard_question(i, q_body)
203
- final_questions.append({
204
- "type": "Explain",
205
- "difficulty": difficulty,
206
- "question": question_text
207
- })
208
- answer_key.append({
209
- "questionId": q_id,
210
- "reference": q_ref
211
- })
212
-
213
- return final_questions, answer_key, raw_ai_output
 
 
 
 
 
 
16
  text = text[m.end() :].strip()
17
  return f"Question {index + 1}: {text}"
18
 
19
+
20
  llm = ChatGroq(
21
  api_key=MY_API_KEY,
22
  model="llama-3.3-70b-versatile",
23
+ # Non-zero temperature reduces repeated identical outputs
24
+ # when the same source file is generated multiple times.
25
+ temperature=1.1,
26
  )
27
 
28
  mcq_prompt = PromptTemplate(
29
+ input_variables=["context", "num_questions", "difficulty"],
30
  template="""
31
+ Generate {num_questions} multiple-choice questions from the following text.
32
+ Target difficulty: {difficulty}.
33
+ Vary the selected concepts and phrasing across questions, and avoid repeating near-identical question stems.
34
  List all questions first, then provide a complete answer key at the end.
35
 
36
  Text:
 
56
  Question 1: [The letter of the correct option]
57
  Question 2: [The letter of the correct option]
58
  ...and so on.
59
+ """,
60
  )
61
 
62
  fill_blank_prompt = PromptTemplate(
63
+ input_variables=["context", "num_questions", "difficulty"],
64
  template="""
65
  Generate {num_questions} fill-in-the-blank questions from the following text.
66
+ Target difficulty: {difficulty}.
67
+ Vary the selected concepts and sentence structures, and avoid repeating near-identical stems.
68
  List all questions first, then provide a complete answer key at the end.
69
 
70
  Text:
 
80
  Question 1: [The word that fills the blank]
81
  Question 2: [The word that fills the blank]
82
  ...and so on.
83
+ """,
84
  )
85
 
86
  explain_prompt = PromptTemplate(
87
+ input_variables=["context", "num_questions", "difficulty"],
88
  template="""
89
  Generate {num_questions} explanation questions from the following text. These should require a detailed answer.
90
+ Target difficulty: {difficulty}.
91
+ Vary concepts and wording, and avoid near-duplicate question stems.
92
 
93
  Text:
94
  {context}
 
104
 
105
  …continue until you have {num_questions} "## Explanation Question" blocks.
106
  Each question line must start with "Question N:" where N matches the block order (1, 2, 3, …).
107
+ """,
108
  )
109
 
110
  mcq_chain = mcq_prompt | llm | StrOutputParser()
 
112
  explain_chain = explain_prompt | llm | StrOutputParser()
113
 
114
 
115
+ def generate_quiz_from_text(
116
+ text: str, num_questions: int, question_type: str, difficulty: str
117
+ ):
118
  final_questions = []
119
  answer_key = []
120
  raw_ai_output = ""
121
 
122
  if question_type == "MCQ":
123
+ raw_ai_output = mcq_chain.invoke(
124
+ {
125
+ "context": text,
126
+ "num_questions": num_questions,
127
+ "difficulty": difficulty,
128
+ }
129
+ )
130
  if "--- ANSWER KEY ---" not in raw_ai_output:
131
  raise ValueError("AI did not provide a separate answer key.")
132
 
133
  questions_block, answers_block = raw_ai_output.split("--- ANSWER KEY ---")
134
 
135
  answers_map = {}
136
+ for line in answers_block.strip().split("\n"):
137
  if ":" in line:
138
  q_id, answer = line.split(":", 1)
139
  answers_map[q_id.strip()] = answer.strip()
140
 
141
  question_chunks = questions_block.strip().split("Question")[1:]
142
  for i, chunk in enumerate(question_chunks):
143
+ lines = chunk.strip().split("\n")
144
  stem = (
145
  lines[0].split(":", 1)[1].strip()
146
  if lines and ":" in lines[0]
 
155
 
156
  q_id = f"Question {i+1}"
157
  if question_text and options and q_id in answers_map:
158
+ final_questions.append(
159
+ {
160
+ "type": "MCQ",
161
+ "difficulty": difficulty,
162
+ "question": question_text,
163
+ "options": options,
164
+ }
165
+ )
166
+ answer_key.append(
167
+ {
168
+ "questionId": q_id,
169
+ "correctAnswer": answers_map[q_id],
170
+ }
171
+ )
172
 
173
  elif question_type == "FillBlank":
174
+ raw_ai_output = fill_blank_chain.invoke(
175
+ {
176
+ "context": text,
177
+ "num_questions": num_questions,
178
+ "difficulty": difficulty,
179
+ }
180
+ )
181
  if "--- ANSWER KEY ---" not in raw_ai_output:
182
  raise ValueError("AI did not provide a separate answer key.")
183
 
184
  questions_block, answers_block = raw_ai_output.split("--- ANSWER KEY ---")
185
 
186
  answers_map = {}
187
+ for line in answers_block.strip().split("\n"):
188
  if ":" in line:
189
  q_id, answer = line.split(":", 1)
190
  answers_map[q_id.strip()] = answer.strip()
 
194
  # Chunk may start with " 1: ..." after splitting on "Question"; strip that
195
  # number so we do not produce "Question 3: 3: ...".
196
  first_line = chunk.strip().split("\n", 1)[0]
197
+ stem = (
198
+ first_line.split(":", 1)[1].strip()
199
+ if ":" in first_line
200
+ else first_line.strip()
201
+ )
202
  rest = chunk.strip().split("\n", 1)
203
  body = stem + ("\n" + rest[1] if len(rest) > 1 else "")
204
  question_text = format_standard_question(i, body)
205
 
206
  q_id = f"Question {i+1}"
207
  if question_text and q_id in answers_map:
208
+ final_questions.append(
209
+ {
210
+ "type": "FillBlank",
211
+ "difficulty": difficulty,
212
+ "question": question_text,
213
+ }
214
+ )
215
+ answer_key.append(
216
+ {
217
+ "questionId": q_id,
218
+ "answer": answers_map[q_id],
219
+ }
220
+ )
221
 
222
  else:
223
+ raw_ai_output = explain_chain.invoke(
224
+ {
225
+ "context": text,
226
+ "num_questions": num_questions,
227
+ "difficulty": difficulty,
228
+ }
229
+ )
230
  for i, item in enumerate(raw_ai_output.split("## Explanation Question")[1:]):
231
+ lines = item.strip().split("\n")
232
  q_body = ""
233
  q_ref = ""
234
  for line in lines:
 
241
  if q_body:
242
  q_id = f"Question {i+1}"
243
  question_text = format_standard_question(i, q_body)
244
+ final_questions.append(
245
+ {
246
+ "type": "Explain",
247
+ "difficulty": difficulty,
248
+ "question": question_text,
249
+ }
250
+ )
251
+ answer_key.append(
252
+ {
253
+ "questionId": q_id,
254
+ "reference": q_ref,
255
+ }
256
+ )
257
+
258
+ return final_questions, answer_key, raw_ai_output
259
+