Create app.py
Browse files
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()
|