MonaHamid commited on
Commit
2fab740
Β·
verified Β·
1 Parent(s): 249d61d

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +634 -0
app.py ADDED
@@ -0,0 +1,634 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io
2
+ import os
3
+ import requests
4
+ from PIL import Image
5
+ from typing import Optional, Tuple, List, Dict
6
+ import gradio as gr
7
+ from dotenv import load_dotenv
8
+ import google.generativeai as genai
9
+ import pdfplumber
10
+ import tempfile
11
+ from fpdf import FPDF
12
+ import re # Import regex for parsing quiz
13
+
14
+ # ─────────────────────────────────────────────
15
+ # Load environment variables & Constants
16
+ # ─────────────────────────────────────────────
17
+ load_dotenv()
18
+ OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
19
+ HF_TOKEN = os.getenv("HF_TOKEN")
20
+ GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
21
+
22
+ MISTRAL_MODEL = "mistralai/mistral-7b-instruct"
23
+ FLUX_MODEL_API = "https://api-inference.huggingface.co/models/black-forest-labs/FLUX.1-schnell"
24
+ OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions"
25
+ GEMINI_MODEL_NAME = "gemini-1.5-flash"
26
+ PDF_TEXT_LIMIT = 8000
27
+
28
+ if not all([OPENROUTER_API_KEY, HF_TOKEN, GEMINI_API_KEY]):
29
+ raise ValueError("❌ Missing one or more required environment variables.")
30
+
31
+ # ─────────────────────────────────────────────
32
+ # Configure Gemini
33
+ # ─────────────────────────────────────────────
34
+ genai.configure(api_key=GEMINI_API_KEY)
35
+ gemini_model = genai.GenerativeModel(GEMINI_MODEL_NAME)
36
+
37
+ # ─────────────────────────────────────────────
38
+ # Prompt Templates
39
+ # ─────────────────────────────────────────────
40
+ LEVEL_PROMPTS = {
41
+ "Kid": "Explain like I'm 7 years old: ",
42
+ "Beginner": "Explain in simple terms: ",
43
+ "Advanced": "Explain in technical detail: "
44
+ }
45
+
46
+ # ─────────────────────────────────────────────
47
+ # UI HTML & CSS Styling
48
+ # ─────────────────────────────────────────────
49
+ LOTTIE_ANIMATION_HTML = """
50
+ <div style="text-align: center; margin-bottom: 20px;">
51
+ <script src="https://unpkg.com/@lottiefiles/lottie-player@latest/dist/lottie-player.js"></script>
52
+ <lottie-player src="https://assets5.lottiefiles.com/packages/lf20_M9p23l.json"
53
+ background="transparent" speed="1" style="width: 200px; height: 200px; margin: auto;"
54
+ loop autoplay></lottie-player>
55
+ </div>
56
+ """
57
+
58
+ # Combined CSS for dark mode, hover effect, and accordion styling
59
+ APP_CSS = """
60
+ body.dark {
61
+ --body-background-fill: #121212;
62
+ --background-fill-primary: #1E1E1E;
63
+ --background-fill-secondary: #2C2C2C;
64
+ --text-color-primary: #FFFFFF;
65
+ --text-color-secondary: #E0E0E0;
66
+ --border-color-primary: #333333;
67
+ --input-background-fill: #2C2C2C;
68
+ --button-secondary-background-fill: #333333;
69
+ --button-secondary-text-color: #FFFFFF;
70
+ --button-primary-background-fill: #6d28d9; /* Purple */
71
+ --button-primary-border-color: #6d28d9; /* Purple */
72
+ --button-primary-text-color: #FFFFFF;
73
+ }
74
+ .dark .gradio-container { background: var(--body-background-fill); }
75
+ .dark .gradio-tabs-nav { background: var(--background-fill-secondary); }
76
+
77
+ /* Button Hover Effect */
78
+ button:hover {
79
+ transition: all 0.3s ease;
80
+ transform: scale(1.01);
81
+ box-shadow: 0px 0px 8px rgba(255, 255, 255, 0.1);
82
+ }
83
+
84
+ /* Style for Accordions */
85
+ /* Adjusting background and text color for accordions in both modes */
86
+ .gradio-accordion.secondary > .label {
87
+ background-color: var(--background-fill-primary); /* Use primary background for label */
88
+ color: var(--text-color-primary);
89
+ border-color: var(--border-color-primary);
90
+ }
91
+ .gradio-accordion.secondary > .label:hover {
92
+ background-color: var(--background-fill-secondary); /* Slightly change on hover */
93
+ }
94
+ .gradio-accordion.secondary > .label > .icon {
95
+ color: var(--text-color-primary);
96
+ }
97
+ .gradio-accordion.secondary > .panel {
98
+ background-color: var(--background-fill-secondary); /* Secondary background for panel content */
99
+ border-color: var(--border-color-primary);
100
+ }
101
+
102
+ /* Style for Dropdowns inside Accordions */
103
+ .gradio-accordion .gr-form { /* Target forms/inputs inside accordion panels */
104
+ background-color: var(--background-fill-secondary); /* Ensure consistent background */
105
+ }
106
+ .gradio-accordion .gr-dropdown-value {
107
+ color: var(--text-color-primary); /* Set text color for dropdown */
108
+ }
109
+ .gradio-accordion .gr-dropdown-options {
110
+ background-color: var(--background-fill-secondary); /* Options background */
111
+ border-color: var(--border-color-primary);
112
+ }
113
+ .gradio-accordion .gr-dropdown-options .gr-dropdown-option {
114
+ color: var(--text-color-primary); /* Options text color */
115
+ }
116
+ .gradio-accordion .gr-dropdown-options .gr-dropdown-option:hover {
117
+ background-color: var(--background-fill-primary); /* Option hover background */
118
+ }
119
+
120
+ /* Style for feedback text */
121
+ #quiz_feedback_box {
122
+ margin-top: 15px; /* Add space above feedback */
123
+ padding: 10px;
124
+ border: 1px solid var(--border-color-primary);
125
+ border-radius: 5px;
126
+ }
127
+ #quiz_feedback_box.correct {
128
+ border-color: green;
129
+ color: green;
130
+ }
131
+ #quiz_feedback_box.incorrect {
132
+ border-color: red;
133
+ color: red;
134
+ }
135
+
136
+
137
+ """
138
+
139
+
140
+ # ─────────────────────────────────────────────
141
+ # API Calls
142
+ # ─────────────────────────────────────────────
143
+ def generate_mistral_response(prompt: str, system_message: str) -> str:
144
+ headers = {"Authorization": f"Bearer {OPENROUTER_API_KEY}"}
145
+ payload = {
146
+ "model": MISTRAL_MODEL,
147
+ "messages": [{"role": "system", "content": system_message}, {"role": "user", "content": prompt}],
148
+ "temperature": 0.7
149
+ }
150
+ try:
151
+ response = requests.post(OPENROUTER_API_URL, headers=headers, json=payload, timeout=45)
152
+ response.raise_for_status()
153
+ return response.json()["choices"][0]["message"]["content"].strip()
154
+ except requests.exceptions.RequestException as e:
155
+ return f"❌ Mistral Network Error: {e}"
156
+ except (KeyError, IndexError):
157
+ return "❌ Mistral API Error: Unexpected response format."
158
+ except Exception as e:
159
+ return f"❌ Mistral Error: {e}"
160
+
161
+
162
+ def generate_diagram_image(prompt: str) -> Optional[str]:
163
+ try:
164
+ response = requests.post(
165
+ FLUX_MODEL_API,
166
+ headers={"Authorization": f"Bearer {HF_TOKEN}"},
167
+ json={"inputs": prompt},
168
+ timeout=60
169
+ )
170
+ response.raise_for_status()
171
+ image = Image.open(io.BytesIO(response.content))
172
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as temp_file:
173
+ image.save(temp_file.name, "PNG")
174
+ return temp_file.name
175
+ except requests.exceptions.RequestException as e:
176
+ print(f"❌ FLUX Network Error: {e}")
177
+ return None
178
+ except Exception as e:
179
+ print(f"❌ FLUX Error: {e}")
180
+ return None
181
+
182
+
183
+ def gemini_explain_file(file, question: Optional[str] = None) -> str:
184
+ if not file: return "⚠️ No file uploaded."
185
+ try:
186
+ file_path = file if isinstance(file, str) else file.name
187
+
188
+ if file_path.lower().endswith((".png", ".jpg", ".jpeg")):
189
+ img = Image.open(file_path)
190
+ prompt = f"Explain the science in this image. If there's a specific question, address it: {question}" if question else "Explain the science in this image."
191
+ response = gemini_model.generate_content([prompt, img])
192
+ return response.text
193
+ elif file_path.lower().endswith(".pdf"):
194
+ with pdfplumber.open(file_path) as pdf:
195
+ text = "\n".join(page.extract_text() or "" for page in pdf.pages)
196
+ prompt = f"Explain the science in this PDF, focusing on this question: {question}\n\nPDF Content:\n{text[:PDF_TEXT_LIMIT]}" if question else f"Summarize and explain the science in this PDF:\n\n{text[:PDF_TEXT_LIMIT]}"
197
+ response = gemini_model.generate_content(prompt)
198
+ return response.text
199
+ else:
200
+ return "⚠️ Unsupported file type."
201
+ except Exception as e:
202
+ return f"❌ Gemini Error: {e}"
203
+
204
+
205
+ # ─────────────────────────────────────────────
206
+ # Feature Functions
207
+ # ─────────────────────────────────────────────
208
+ def generate_quiz(explanation_text: str) -> str:
209
+ """Generates a 2-question quiz based on the explanation."""
210
+ if not explanation_text or "❌" in explanation_text or "⚠️" in explanation_text:
211
+ return "Quiz could not be generated based on the explanation."
212
+
213
+ # Prompt to ensure clear formatting for parsing, including the marker
214
+ system_message = "You are a quiz creator. Based on the provided text, create a simple 2-question multiple-choice quiz. For each question, provide 3-4 options. Clearly indicate the correct answer by putting '(Correct answer)' immediately after it. Format each question as follows: 'Question #: [Question Text]\nA) Option A\nB) Option B\nC) Option C (Correct answer)\nD) Option D'. Put a blank line between questions. Do not include introductory or concluding remarks or explanations for the answers."
215
+ prompt = f"Create a 2-question multiple-choice quiz from this explanation:\n\n{explanation_text[:PDF_TEXT_LIMIT*2]}"
216
+ quiz_result = generate_mistral_response(prompt, system_message)
217
+
218
+ if "❌" in quiz_result:
219
+ return f"Could not generate quiz: {quiz_result}"
220
+ return quiz_result
221
+
222
+
223
+ def parse_quiz_text_for_dropdown(quiz_text: str) -> Tuple[List[Dict[str, any]], List[str]]:
224
+ """
225
+ Parses AI quiz text. Returns a list of question data (label, choices)
226
+ and a list of *clean* correct answers (without the marker) for state.
227
+ """
228
+ questions_data = []
229
+ correct_answers_clean = [] # To store correct answers without marker
230
+
231
+ question_blocks = quiz_text.strip().split('\n\n')
232
+
233
+ for raw_block in question_blocks[:2]: # Process up to 2 questions
234
+ if not raw_block.strip(): continue
235
+
236
+ lines = raw_block.strip().split('\n')
237
+ if not lines: continue
238
+
239
+ q_text_line = lines[0].strip()
240
+ q_text_match = re.match(r'^\s*Question\s*\d+:\s*(.*)$', q_text_line, flags=re.IGNORECASE)
241
+ q_text = q_text_match.group(1).strip() if q_text_match else q_text_line
242
+
243
+ options_for_dropdown = [] # Options without the marker
244
+ correct_option_clean = None # Clean correct answer for state
245
+
246
+ for line in lines[1:]:
247
+ line = line.strip()
248
+ if not line: continue
249
+ # Check if it looks like an option line (starts with A), capture text before marker
250
+ option_match = re.match(r'^[A-Z]\)\s*(.*?)(?:\s*\(Correct answer\))?\s*$', line, flags=re.IGNORECASE)
251
+ if option_match:
252
+ option_text_clean = option_match.group(1).strip()
253
+ options_for_dropdown.append(option_text_clean) # Add clean text to dropdown options
254
+
255
+ # Check if this is the correct answer
256
+ if "(Correct answer)" in line:
257
+ correct_option_clean = option_text_clean # Store the clean correct answer
258
+
259
+ if q_text and options_for_dropdown:
260
+ questions_data.append({
261
+ "label": q_text,
262
+ "choices": options_for_dropdown # Use the cleaned options for dropdown
263
+ })
264
+ # Store the clean correct answer, ensure it corresponds to this question
265
+ correct_answers_clean.append(correct_option_clean if correct_option_clean else "No correct answer found")
266
+ else:
267
+ print(f"Warning: Could not fully parse quiz block for dropdowns: {raw_block[:100]}...")
268
+ correct_answers_clean.append("Error parsing question") # Add placeholder
269
+
270
+
271
+ # Ensure we always return exactly two correct answers (fill with None if fewer than 2 questions)
272
+ while len(correct_answers_clean) < 2:
273
+ correct_answers_clean.append(None)
274
+
275
+ return questions_data, correct_answers_clean[:2] # Return parsed data and the list of correct answers
276
+
277
+
278
+ # CHANGED: Grade quiz function takes user answers and state values
279
+ def grade_quiz(q1_answer_user: str, q2_answer_user: str, correct_q1_stored: str, correct_q2_stored: str) -> Tuple[str, gr.update]:
280
+ """Grades the user's answers based on the stored correct answers."""
281
+ feedback_lines = ["### Quiz Feedback"]
282
+ total_correct = 0
283
+ graded_count = 0
284
+
285
+ # Grade Question 1
286
+ if correct_q1_stored is not None and "Error" not in str(correct_q1_stored):
287
+ graded_count += 1
288
+ if q1_answer_user is None:
289
+ feedback_lines.append(f"⚠️ Question 1: No answer selected. Correct was: '{correct_q1_stored}'")
290
+ elif q1_answer_user == correct_q1_stored:
291
+ feedback_lines.append(f"βœ… Question 1: Correct!")
292
+ total_correct += 1
293
+ else:
294
+ feedback_lines.append(f"❌ Question 1: Incorrect. Your answer: '{q1_answer_user}'. Correct was: '{correct_q1_stored}'")
295
+ elif q1_answer_user: # User selected an answer but question/correct answer wasn't parsed
296
+ feedback_lines.append(f"⚠️ Question 1: Answer selected, but correct answer could not be determined.")
297
+
298
+
299
+ # Grade Question 2
300
+ if correct_q2_stored is not None and "Error" not in str(correct_q2_stored):
301
+ graded_count += 1
302
+ if q2_answer_user is None:
303
+ feedback_lines.append(f"⚠️ Question 2: No answer selected. Correct was: '{correct_q2_stored}'")
304
+ elif q2_answer_user == correct_q2_stored:
305
+ feedback_lines.append(f"βœ… Question 2: Correct!")
306
+ total_correct += 1
307
+ else:
308
+ feedback_lines.append(f"❌ Question 2: Incorrect. Your answer: '{q2_answer_user}'. Correct was: '{correct_q2_stored}'")
309
+ elif q2_answer_user: # User selected an answer but question/correct answer wasn't parsed
310
+ feedback_lines.append(f"⚠️ Question 2: Answer selected, but correct answer could not be determined.")
311
+
312
+ # Overall score
313
+ if graded_count > 0:
314
+ score_message = f"Overall Score: {total_correct}/{graded_count}."
315
+ feedback_lines.append(score_message)
316
+
317
+ # Determine feedback box class based on score
318
+ if total_correct == graded_count:
319
+ feedback_css_class = "correct"
320
+ elif total_correct > 0:
321
+ feedback_css_class = "partial" # Using 'partial' class if you add one in CSS
322
+ else:
323
+ feedback_css_class = "incorrect"
324
+ else:
325
+ feedback_lines.append("No quiz questions were available to grade.")
326
+ feedback_css_class = "" # No specific class if nothing graded
327
+
328
+
329
+ feedback_text = "\n".join(feedback_lines)
330
+
331
+ # Update feedback box visibility and value
332
+ feedback_update = gr.update(value=feedback_text, visible=True, elem_classes=[feedback_css_class])
333
+
334
+ return feedback_update
335
+
336
+
337
+ def create_report(report_format: str, explanation: str, image_path: Optional[str], raw_quiz_text: str) -> Optional[str]:
338
+ """Creates a downloadable report in PDF or Markdown format."""
339
+ if not explanation:
340
+ return None
341
+
342
+ if report_format == "PDF":
343
+ pdf = FPDF()
344
+ pdf.add_page()
345
+ pdf.set_font("Helvetica", 'B', 16)
346
+ pdf.cell(0, 10, 'Science Report', 0, 1, 'C')
347
+ pdf.ln(10)
348
+
349
+ pdf.set_font("Helvetica", 'B', 14)
350
+ pdf.cell(0, 10, 'Explanation', 0, 1)
351
+ pdf.set_font("Helvetica", '', 12)
352
+ try:
353
+ pdf.multi_cell(0, 10, explanation.encode('latin-1', 'replace').decode('latin-1'))
354
+ except Exception as e:
355
+ print(f"PDF Explanation Encoding Error: {e}")
356
+ pdf.multi_cell(0, 10, "Error encoding explanation text for PDF.") # Fallback
357
+ pdf.ln(5)
358
+
359
+ if image_path and os.path.exists(image_path):
360
+ pdf.set_font("Helvetica", 'B', 14)
361
+ pdf.cell(0, 10, 'Generated Diagram', 0, 1)
362
+ pdf.ln(5)
363
+ try:
364
+ available_width = pdf.w - pdf.l_margin - pdf.r_margin
365
+ pdf.image(image_path, x=pdf.get_x(), y=pdf.get_y(), w=available_width)
366
+ # Move cursor down past where image would be (approximate height based on width)
367
+ try:
368
+ img_w, img_h = Image.open(image_path).size
369
+ img_display_height = (img_h / img_w) * available_width
370
+ pdf.ln(min(img_display_height, pdf.h - pdf.get_y() - pdf.b_margin) + 5) # Add space after image, don't go past page bottom
371
+ except Exception as img_size_e:
372
+ print(f"Could not get image size for PDF spacing: {img_size_e}")
373
+ pdf.ln(100) # Fallback fixed spacing if size fails
374
+
375
+ except Exception as e:
376
+ print(f"PDF Image Error: {e}")
377
+ pdf.ln(15) # Add some space even if image failed
378
+
379
+
380
+ pdf.set_font("Helvetica", 'B', 14)
381
+ pdf.cell(0, 10, 'Quiz', 0, 1)
382
+ pdf.set_font("Helvetica", '', 12)
383
+ # Use the raw quiz text for the report as it contains correct answers
384
+ if raw_quiz_text and "Quiz could not be generated" not in raw_quiz_text and "❌" not in raw_quiz_text and "⚠️" not in raw_quiz_text:
385
+ try:
386
+ pdf.multi_cell(0, 10, raw_quiz_text.encode('latin-1', 'replace').decode('latin-1'))
387
+ except Exception as e:
388
+ print(f"PDF Quiz Encoding Error: {e}")
389
+ pdf.multi_cell(0, 10, "Error encoding quiz text for PDF.")
390
+ else:
391
+ pdf.multi_cell(0, 10, "Quiz was not available.")
392
+ pdf.ln(5)
393
+
394
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as temp_file:
395
+ pdf.output(temp_file.name)
396
+ return temp_file.name
397
+
398
+ elif report_format == "Markdown":
399
+ content = f"# Science Report\n\n## Explanation\n\n{explanation}\n\n"
400
+ if image_path:
401
+ content += "## Diagram\n\n(Note: The diagram image is a separate file.)\n\n"
402
+ content += f"## Quiz\n\n{raw_quiz_text}\n" # Use raw text for Markdown too
403
+
404
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".md", mode='w', encoding='utf-8') as temp_file:
405
+ temp_file.write(content)
406
+ return temp_file.name
407
+ return None
408
+
409
+
410
+ # ─────────────────────────────────────────────
411
+ # Main Handler
412
+ # ─────────────────────────────────────────────
413
+ # This function updates ALL relevant output components and state variables.
414
+ # The order of returned values MUST match the outputs list in submit_btn.click
415
+ def handle_combined_input(question, level, uploaded_file):
416
+ explanation = ""
417
+ image_path = None # This holds the temporary file path
418
+ raw_quiz_text = "Your quiz will appear here..." # Store raw AI output for quiz
419
+ reasoning = ""
420
+ download_box_visible = gr.update(visible=False) # Assume hidden initially
421
+ diagram_visible = gr.update(visible=False) # Hide diagram by default
422
+
423
+ # --- Generation Logic ---
424
+ if uploaded_file:
425
+ explanation = gemini_explain_file(uploaded_file, question)
426
+ reasoning = "🧠 Analysis powered by Google Gemini."
427
+ image_path = None # No diagram for file upload
428
+ diagram_visible = gr.update(visible=False)
429
+ elif question and question.strip():
430
+ prompt = LEVEL_PROMPTS.get(level, "") + question
431
+ explanation = generate_mistral_response(prompt, "You are a helpful science explainer.")
432
+ reasoning = "🧠 Explanation powered by Mistral via OpenRouter."
433
+ if "❌" not in explanation and "⚠️" not in explanation: # Only try image if explanation worked
434
+ image_path = generate_diagram_image(question)
435
+ if image_path:
436
+ reasoning += "\n🎨 Diagram generated by FLUX via Hugging Face."
437
+ diagram_visible = gr.update(visible=True) # Show diagram if successful
438
+ else:
439
+ reasoning += "\n❌ Diagram generation failed."
440
+ diagram_visible = gr.update(visible=False) # Hide if failed
441
+ else:
442
+ image_path = None # If explanation failed, no image is generated
443
+ diagram_visible = gr.update(visible=False)
444
+ else:
445
+ explanation = "⚠️ Please ask a science question or upload a file."
446
+ reasoning = "" # Clear reasoning if no input
447
+ image_path = None
448
+ diagram_visible = gr.update(visible=False)
449
+
450
+ # Generate quiz only if explanation was successful and not empty/warning
451
+ parsed_quiz_data = [] # Initialize parsed data and correct answers
452
+ correct_answers = [None, None] # Initialize state values
453
+
454
+ if explanation.strip() and "❌" not in explanation and "⚠️" not in explanation:
455
+ raw_quiz_text = generate_quiz(explanation)
456
+ # Parse quiz text for dropdowns and get correct answers for state
457
+ parsed_quiz_data, correct_answers = parse_quiz_text_for_dropdown(raw_quiz_text)
458
+
459
+ # Only show download box if explanation AND quiz generation seem OK
460
+ if "❌" not in raw_quiz_text and "⚠️" not in raw_quiz_text and raw_quiz_text.strip() and "Quiz could not be generated" not in raw_quiz_text:
461
+ download_box_visible = gr.update(visible=True)
462
+ # If quiz generation failed, raw_quiz_text will contain the error message
463
+
464
+
465
+ # --- Prepare UI Updates ---
466
+
467
+ # Updates for Quiz Question 1 Dropdown
468
+ if len(parsed_quiz_data) > 0:
469
+ q1_label = parsed_quiz_data[0]["label"]
470
+ q1_choices = parsed_quiz_data[0]["choices"]
471
+ q1_update = gr.update(
472
+ label=f"Question 1: {q1_label}", # Add "Question 1:" prefix
473
+ choices=q1_choices,
474
+ value=None, # Reset value
475
+ visible=True # Make visible
476
+ )
477
+ else:
478
+ # If no Q1, hide the dropdown
479
+ q1_update = gr.update(label="Quiz Question 1", choices=[], value=None, visible=False)
480
+
481
+
482
+ # Updates for Quiz Question 2 Dropdown
483
+ if len(parsed_quiz_data) > 1:
484
+ q2_label = parsed_quiz_data[1]["label"]
485
+ q2_choices = parsed_quiz_data[1]["choices"]
486
+ q2_update = gr.update(
487
+ label=f"Question 2: {q2_label}", # Add "Question 2:" prefix
488
+ choices=q2_choices,
489
+ value=None, # Reset value
490
+ visible=True # Make visible
491
+ )
492
+ else:
493
+ # If no Q2, hide the dropdown
494
+ q2_update = gr.update(label="Quiz Question 2", choices=[], value=None, visible=False)
495
+
496
+ # Update the raw quiz markdown text display
497
+ # Only show the raw text if quiz generation provided some content
498
+ if raw_quiz_text.strip() and "Your quiz will appear here" not in raw_quiz_text:
499
+ raw_quiz_markdown_update = gr.update(value=f"**Raw Quiz Output:**\n\n{raw_quiz_text}", visible=True)
500
+ else:
501
+ raw_quiz_markdown_update = gr.update(value="", visible=False)
502
+
503
+ # Hide quiz feedback initially or if quiz generation failed
504
+ quiz_feedback_update = gr.update(value="", visible=False, elem_classes="") # Also clear feedback class
505
+ # Show submit button only if at least one question was parsed
506
+ submit_quiz_btn_update = gr.update(visible= True if len(parsed_quiz_data) > 0 else False)
507
+
508
+
509
+ # Return all the updated components' values/updates
510
+ # Order MUST match the outputs list in submit_btn.click
511
+ return (explanation, # explanation_out
512
+ image_path, # diagram_out (path to temp file)
513
+ diagram_visible, # diagram_out visibility update
514
+ reasoning, # reasoning_out
515
+ q1_update, # quiz_q1 dropdown update
516
+ q2_update, # quiz_q2 dropdown update
517
+ raw_quiz_markdown_update, # raw_quiz_markdown update
518
+ download_box_visible, # download_box visibility update
519
+ submit_quiz_btn_update, # submit_quiz_btn visibility update
520
+ quiz_feedback_update, # quiz_feedback visibility/value update
521
+ correct_answers[0], # correct_q1_state (value update)
522
+ correct_answers[1] # correct_q2_state (value update)
523
+ )
524
+
525
+
526
+ # ─────────────────────────────────────────────
527
+ # Launch Gradio App
528
+ # ─────────────────────────────────────────────
529
+ def launch_app():
530
+ # Use the combined CSS
531
+ with gr.Blocks(theme=gr.themes.Soft(), css=APP_CSS) as demo:
532
+ gr.HTML(LOTTIE_ANIMATION_HTML)
533
+ gr.Markdown("# πŸ“š ExplainAnything.AI\nYour personal AI-powered science tutor.")
534
+
535
+ # Hidden state variables to store correct answers
536
+ correct_q1_state = gr.State(value=None)
537
+ correct_q2_state = gr.State(value=None)
538
+
539
+ with gr.Row():
540
+ with gr.Column(scale=1):
541
+ question = gr.Textbox(label="Ask a science question", placeholder="e.g., How does photosynthesis work?", info="Ask any science-related question.")
542
+ level = gr.Radio(choices=["Kid", "Beginner", "Advanced"], value="Beginner", label="Explanation Level", info="Choose how detailed the explanation should be.")
543
+ uploaded_file = gr.File(label="Upload Image/PDF", file_types=["image", ".pdf"])
544
+
545
+ with gr.Row():
546
+ toggle_theme_btn = gr.Button("Toggle Theme πŸŒ’/β˜€οΈ")
547
+ submit_btn = gr.Button("Generate Explanation", variant="primary")
548
+
549
+ with gr.Column(scale=2):
550
+ # Use Accordions - added elem_classes for styling
551
+ with gr.Accordion("πŸ“˜ Explanation", open=True, elem_classes="secondary"):
552
+ explanation_out = gr.Textbox(lines=8, label="Explanation", interactive=False)
553
+ reasoning_out = gr.Textbox(lines=2, label="🧠 Processing Details", interactive=False)
554
+
555
+ with gr.Accordion("πŸ–ΌοΈ Diagram", open=False, elem_classes="secondary"):
556
+ diagram_out = gr.Image(label="Generated Diagram", interactive=False, show_label=True, visible=False)
557
+
558
+ with gr.Accordion("πŸ§ͺ Quiz", open=False, elem_classes="secondary") as quiz_accordion:
559
+ gr.Markdown("Test Your Knowledge:")
560
+ # Pre-defined Dropdown menus for Quiz Questions (up to 2 questions)
561
+ quiz_q1 = gr.Dropdown(label="Quiz Question 1", choices=[], value=None, interactive=True, visible=False)
562
+ quiz_q2 = gr.Dropdown(label="Quiz Question 2", choices=[], value=None, interactive=True, visible=False)
563
+ # Raw quiz text output - changed from Markdown to Textbox as in your provided code
564
+ raw_quiz_markdown = gr.Markdown(value="Your quiz will appear here...", label="Raw Quiz Data", visible=False) # Switched back to Markdown
565
+ submit_quiz_btn = gr.Button("Submit Answers", variant="secondary", visible=False)
566
+ # Feedback textbox - initially hidden, given an ID for CSS styling
567
+ quiz_feedback = gr.Textbox(label="Quiz Feedback", lines=3, interactive=False, visible=False, elem_id="quiz_feedback_box")
568
+
569
+
570
+ with gr.Group(visible=False) as download_box:
571
+ gr.Markdown("### πŸ“₯ Download Report")
572
+ with gr.Row():
573
+ report_format = gr.Radio(["PDF", "Markdown"], label="Choose Format", value="PDF")
574
+ download_btn = gr.Button("Download")
575
+ download_file = gr.File(label="Your report is ready to download", interactive=False)
576
+
577
+ # --- Event Handlers ---
578
+ # Inputs for handle_combined_input: question, level, uploaded_file
579
+ # Outputs: explanation_out, diagram_out (value), diagram_out (visibility),
580
+ # reasoning_out (value), quiz_q1 (update), quiz_q2 (update),
581
+ # raw_quiz_markdown (update), download_box (visibility),
582
+ # submit_quiz_btn (visibility), quiz_feedback (visibility),
583
+ # correct_q1_state (update), correct_q2_state (update)
584
+ # The order here MUST match the return order in handle_combined_input
585
+ submit_btn.click(
586
+ fn=handle_combined_input,
587
+ inputs=[question, level, uploaded_file],
588
+ outputs=[explanation_out, diagram_out, diagram_out, # Diagram value and visibility
589
+ reasoning_out,
590
+ quiz_q1, quiz_q2, # Dropdown updates
591
+ raw_quiz_markdown, # Raw text update
592
+ download_box, # Download box visibility
593
+ submit_quiz_btn, # Submit quiz button visibility
594
+ quiz_feedback, # Feedback box visibility/value
595
+ correct_q1_state, # State update for correct answer 1
596
+ correct_q2_state # State update for correct answer 2
597
+ ]
598
+ )
599
+
600
+ # Handle quiz submission
601
+ # Inputs: Current value of quiz_q1, quiz_q2, and the state variables holding correct answers
602
+ # Output: Update the quiz_feedback textbox
603
+ submit_quiz_btn.click(
604
+ fn=grade_quiz,
605
+ inputs=[quiz_q1, quiz_q2, correct_q1_state, correct_q2_state], # Get selected answers AND stored correct answers
606
+ outputs=[quiz_feedback] # Update the feedback textbox
607
+ )
608
+
609
+ toggle_theme_btn.click(
610
+ fn=None, inputs=None, outputs=None,
611
+ js="() => { document.body.classList.toggle('dark'); }"
612
+ )
613
+
614
+
615
+ # Inputs for create_report need the CURRENT values from the outputs AFTER submit is run
616
+ # Inputs: report_format (from radio), explanation (from explanation_out),
617
+ # image_path (from diagram_out), raw_quiz_text (from raw_quiz_markdown)
618
+ # Output: download_file (the downloadable file)
619
+ # Note: We pass the component objects themselves, Gradio gets their current VALUE
620
+ download_btn.click(
621
+ fn=create_report,
622
+ inputs=[report_format, explanation_out, diagram_out, raw_quiz_markdown],
623
+ outputs=[download_file]
624
+ )
625
+
626
+ print("Launching Gradio app with a public link...")
627
+ demo.launch(share=True, debug=True)
628
+
629
+
630
+ # ─────────────────────────────────────────────
631
+ # Run App
632
+ # ─────────────────────────────────────────────
633
+ if __name__ == "__main__":
634
+ launch_app()