laichai commited on
Commit
e8e136b
·
verified ·
1 Parent(s): 78e2e73

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +301 -206
app.py CHANGED
@@ -6,13 +6,16 @@ import os
6
  import io
7
  import json
8
  import requests
 
9
  from PIL import Image
10
  from gtts import gTTS
11
  from duckduckgo_search import DDGS
12
  import warnings
13
  warnings.filterwarnings('ignore')
14
 
15
- # Page Configuration
 
 
16
  st.set_page_config(
17
  page_title="H2 Physics Feynman Bot",
18
  page_icon="⚛️",
@@ -20,10 +23,13 @@ st.set_page_config(
20
  initial_sidebar_state="expanded"
21
  )
22
 
23
- # Audio generation
 
 
 
24
  @st.cache_data(show_spinner=False, ttl=3600)
25
  def generate_audio(text):
26
- """Generates MP3 audio from text, skipping code/image tags."""
27
  clean_text = re.sub(r'```.*?```', 'I have generated a graph.', text, flags=re.DOTALL)
28
  clean_text = re.sub(r'\[IMAGE:.*?\]', 'Here is a diagram.', clean_text)
29
 
@@ -37,12 +43,10 @@ def generate_audio(text):
37
  audio_fp.seek(0)
38
  return audio_fp
39
  except Exception as e:
40
- st.error(f"Audio generation error: {e}")
41
  return None
42
 
43
- # Image search functions
44
  def google_search_api(query, api_key, cx):
45
- """Helper: Performs a single Google Search."""
46
  try:
47
  url = "https://www.googleapis.com/customsearch/v1"
48
  params = {
@@ -69,180 +73,219 @@ def google_search_api(query, api_key, cx):
69
  return data["items"][0]["link"]
70
 
71
  except Exception as e:
72
- print(f"Google API Exception: {e}")
73
  return None
74
  return None
75
 
76
  def duckduckgo_search_api(query):
77
- """Helper: Fallback search using DuckDuckGo."""
78
  try:
79
  with DDGS() as ddgs:
80
  results = list(ddgs.images(query, max_results=1))
81
  if results:
82
  return results[0]['image']
83
  except Exception as e:
84
- return f"Search Error: {e}"
85
  return None
86
 
87
  @st.cache_data(show_spinner=False, ttl=300)
88
  def search_image(query):
89
- """MASTER FUNCTION: Google Search -> DuckDuckGo - FIXED FOR HUGGING FACE"""
90
  try:
91
- # Try to get secrets from Hugging Face Spaces
92
- # Method 1: Check st.secrets (Hugging Face Spaces method)
93
- try:
94
- if hasattr(st, 'secrets'):
95
- # Hugging Face Spaces stores secrets in st.secrets
96
- cx = st.secrets.get("GOOGLE_CX", "")
97
- key1 = st.secrets.get("GOOGLE_SEARCH_KEY", "")
98
- key2 = st.secrets.get("GOOGLE_SEARCH_KEY_2", "")
99
-
100
- if key1 and cx:
101
- url = google_search_api(query, key1, cx)
102
- if url:
103
- return url
104
-
105
- if key2 and cx:
106
- url = google_search_api(query, key2, cx)
107
- if url:
108
- return url
109
- except:
110
- pass
111
 
112
- # Method 2: Check environment variables
113
- cx = os.environ.get("GOOGLE_CX", "")
114
- key1 = os.environ.get("GOOGLE_SEARCH_KEY", "")
115
- key2 = os.environ.get("GOOGLE_SEARCH_KEY_2", "")
116
 
117
- if key1 and cx:
118
- url = google_search_api(query, key1, cx)
119
- if url:
120
- return url
121
 
122
- if key2 and cx:
123
- url = google_search_api(query, key2, cx)
124
- if url:
125
- return url
126
 
127
- # Method 3: Fallback to DuckDuckGo
128
- return duckduckgo_search_api(query)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  except Exception as e:
131
- return f"Search Error: {str(e)[:100]}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
 
133
- # Graph plotting function - REORDERED AND FIXED
134
  def execute_plotting_code(code_snippet):
135
- """Execute plotting code in a safe environment."""
136
  try:
137
- # Clear any existing plots FIRST
138
  plt.close('all')
 
139
 
140
- # Create a fresh figure
141
- fig = plt.figure(figsize=(10, 6))
142
-
143
- # Create execution namespace with ALL required imports
144
  namespace = {
145
  'plt': plt,
146
  'np': np,
 
147
  'fig': fig,
148
- 'ax': None,
149
  'math': __import__('math')
150
  }
151
 
152
- # Clean the code
153
  cleaned_code = code_snippet.strip()
154
 
155
- # CRITICAL FIX: Ensure imports are at the top
156
  if 'import matplotlib' not in cleaned_code:
157
  cleaned_code = 'import matplotlib.pyplot as plt\nimport numpy as np\n' + cleaned_code
158
 
159
- # CRITICAL FIX: Ensure plt.figure or plt.subplots is called
160
- if 'plt.figure' not in cleaned_code and 'plt.subplots' not in cleaned_code:
161
- cleaned_code = cleaned_code.replace('import matplotlib.pyplot as plt',
162
- 'import matplotlib.pyplot as plt\nplt.figure(figsize=(10, 6))')
163
-
164
- # CRITICAL FIX: Check if there's actual plotting
165
- plot_keywords = ['plt.plot(', 'ax.plot(', 'plt.scatter(', 'ax.scatter(',
166
- 'plt.bar(', 'ax.bar(', 'plt.hist(', 'ax.hist(']
167
-
168
  has_plot = any(keyword in cleaned_code for keyword in plot_keywords)
169
 
170
  if not has_plot:
171
- # Add a sample plot
172
- cleaned_code += '\n\n# Adding sample plot since no plotting command was found\n'
173
- cleaned_code += 'x = np.linspace(0, 10, 100)\n'
174
- cleaned_code += 'y = np.sin(x)\n'
175
- cleaned_code += 'plt.plot(x, y, "b-", linewidth=2, label="sin(x)")\n'
176
 
177
- # CRITICAL FIX: Ensure plt.show() is NOT in the code (Streamlit handles this)
178
  cleaned_code = cleaned_code.replace('plt.show()', '')
179
- cleaned_code = cleaned_code.replace('plt.show', '')
180
 
181
  # Add labels if missing
182
  if 'plt.xlabel' not in cleaned_code:
183
  cleaned_code += '\nplt.xlabel("X-axis", fontsize=12)'
184
  if 'plt.ylabel' not in cleaned_code:
185
  cleaned_code += '\nplt.ylabel("Y-axis", fontsize=12)'
186
- if 'plt.title' not in cleaned_code:
187
- cleaned_code += '\nplt.title("Physics Graph", fontsize=14)'
188
  if 'plt.grid' not in cleaned_code:
189
  cleaned_code += '\nplt.grid(True, linestyle="--", alpha=0.6)'
190
 
191
- # DEBUG: Print the code being executed
192
- # print("DEBUG - Executing code:\n", cleaned_code[:200])
193
-
194
- # EXECUTE THE CODE
195
  exec(cleaned_code, namespace)
196
 
197
- # Get current figure
198
- fig = plt.gcf()
 
199
 
200
- # Display the plot IMMEDIATELY
201
  st.pyplot(fig)
202
-
203
- # Close the figure
204
  plt.close(fig)
205
 
206
- return True
207
-
208
  except Exception as e:
209
- st.error(f"Graph Error: {str(e)[:100]}")
210
 
211
- # Create a SIMPLE guaranteed plot
212
  try:
213
  fig, ax = plt.subplots(figsize=(10, 6))
214
  x = np.linspace(0, 10, 100)
215
- y = x**2 # Simple quadratic
216
  ax.plot(x, y, 'b-', linewidth=2, label='y = x²')
217
  ax.set_xlabel('X', fontsize=12)
218
  ax.set_ylabel('Y', fontsize=12)
219
- ax.set_title('Sample Graph (Code had issues)', fontsize=14)
220
  ax.grid(True, linestyle='--', alpha=0.6)
221
  ax.legend()
222
  st.pyplot(fig)
223
  plt.close(fig)
224
- st.warning("Displayed sample graph. The AI's code may need adjustment.")
225
  except:
226
- st.error("⚠️ Could not generate any graph.")
227
-
228
- return False
229
- # Display message function
230
- # UPDATED Display message function - FIXED DEPRECATED PARAMETER
231
  def display_message(role, content, enable_voice=False):
232
- """Display chat message with all features."""
233
  with st.chat_message(role):
234
 
235
  text_to_display = content
236
 
237
- # 1. Extract Python code blocks
238
  code_pattern = r'```python\s*(.*?)```'
239
  code_matches = list(re.finditer(code_pattern, content, re.DOTALL))
240
 
241
- # Remove code blocks from displayed text
242
  for match in reversed(code_matches):
243
  text_to_display = text_to_display.replace(match.group(0), "")
244
 
245
- # 2. Check for [IMAGE: query] Tags
246
  image_match = re.search(r'\[IMAGE:\s*(.*?)\]', text_to_display, re.IGNORECASE)
247
  image_result = None
248
 
@@ -252,48 +295,43 @@ def display_message(role, content, enable_voice=False):
252
  with st.spinner(f"Searching for '{search_query}'..."):
253
  image_result = search_image(search_query)
254
 
255
- # --- DISPLAY TEXT FIRST ---
256
  st.markdown(text_to_display)
257
-
258
- # --- SHOW CODE IN EXPANDER FIRST ---
259
  if code_matches and role == "assistant":
260
  for match in code_matches:
261
  code_content = match.group(1).strip()
262
  if code_content:
263
- # Show code FIRST
264
  with st.expander("📝 View Python Code", expanded=False):
265
  st.code(code_content, language='python')
266
 
267
- # Then show graph BELOW the code
268
  with st.expander("📊 Generated Graph", expanded=True):
269
- # Execute and display graph
270
  execute_plotting_code(code_content)
271
 
272
- # --- DISPLAY IMAGE - FIXED WIDTH VALUE ---
273
  if image_match and role == "assistant":
274
- if image_result and not any(error in str(image_result).lower() for error in ["error", "no image", "search error"]):
275
  try:
276
- # FIXED: Use 'auto' for automatic sizing or specify pixel width
277
- st.image(image_result, caption=f"📷 {image_match.group(1)}", width="auto")
278
  st.markdown(f"🔗 [Open Image]({image_result})")
279
- except Exception as img_error:
280
- # Try alternative width values
281
- try:
282
- st.image(image_result, caption=f"📷 {image_match.group(1)}", width=500)
283
- except:
284
- st.warning(f"⚠️ Could not display image from URL: {image_result[:100]}")
285
  else:
286
- st.warning(f"⚠️ Image search failed for: '{image_match.group(1)}'")
287
 
288
-
289
- # --- AUDIO OUTPUT ---
290
  if enable_voice and role == "assistant" and len(text_to_display.strip()) > 10:
291
  audio_bytes = generate_audio(text_to_display)
292
  if audio_bytes:
293
  st.audio(audio_bytes, format='audio/mp3')
294
- # Groq API function
 
 
 
 
295
  def call_groq_api(api_key, messages, max_tokens=2000):
296
- """Call Groq API - FREE tier available (10,000 requests/month)."""
297
  url = "https://api.groq.com/openai/v1/chat/completions"
298
 
299
  headers = {
@@ -301,7 +339,6 @@ def call_groq_api(api_key, messages, max_tokens=2000):
301
  "Content-Type": "application/json"
302
  }
303
 
304
- # Format messages for Groq API
305
  formatted_messages = []
306
  for msg in messages:
307
  if msg["role"] == "system":
@@ -315,11 +352,9 @@ def call_groq_api(api_key, messages, max_tokens=2000):
315
  "content": msg["content"]
316
  })
317
 
318
- # Try multiple models
319
  models_to_try = [
320
  "llama-3.1-8b-instant",
321
  "llama-3.2-3b-preview",
322
- "mixtral-8x7b-32768",
323
  ]
324
 
325
  for model in models_to_try:
@@ -339,18 +374,12 @@ def call_groq_api(api_key, messages, max_tokens=2000):
339
  return result["choices"][0]["message"]["content"]
340
  elif response.status_code == 429:
341
  continue
342
- elif response.status_code == 401:
343
- return "❌ Invalid API Key. Please check your Groq API key."
344
- elif response.status_code == 402:
345
- return "❌ Payment required. The free model might be unavailable. Try again later."
346
 
347
- except requests.exceptions.Timeout:
348
- continue
349
- except Exception as e:
350
  continue
351
 
352
- return "⚠️ All models are currently unavailable. Please try again in a few moments."
353
-
354
  # System Instructions for the AI - Part 5a (Start)
355
  SYSTEM_INSTRUCTIONS = """
356
  **Identity:** Richard Feynman. Tutor for Singapore H2 Physics (9478).
@@ -467,38 +496,124 @@ with st.sidebar:
467
  )
468
 
469
  enable_voice = st.toggle("🗣️ Text-to-Speech", value=False)
470
- st.caption("Read responses aloud")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
471
 
472
  #st.divider()
473
 
474
- # Media Input (Kept simple)
475
- st.header("📤 Upload Content")
476
 
477
  visual_content = None
478
- uploaded_file = st.file_uploader(
479
- "Upload Image",
480
- type=["jpg", "jpeg", "png"],
481
- help="Upload diagrams, graphs, or physics problems"
 
 
 
482
  )
483
 
484
- if uploaded_file:
485
- try:
486
- image = Image.open(uploaded_file)
487
- visual_content = image
488
- st.image(image, caption="Uploaded Image", width=200)
489
- st.success("Image loaded")
490
- except Exception as e:
491
- st.error(f"Error: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
492
 
493
  #st.divider()
494
 
495
- # Simple Controls
496
  if st.button("🧹 Clear Chat History", use_container_width=True):
497
  st.session_state.messages = []
498
  st.rerun()
499
 
500
  st.divider()
501
- #st.caption("H2 Physics (9478) Syllabus")
502
  st.caption("Made with ❤️ for JPJC H2 Physics students | Powered by Groq AI")
503
 
504
  # 7 Main Chat Interface
@@ -526,97 +641,77 @@ if user_input or visual_content:
526
  user_message = ""
527
  if user_input:
528
  user_message += user_input + " "
 
529
  if visual_content:
530
- user_message += "[I've uploaded an image related to physics. Please analyze it.] "
 
 
 
 
531
  if topic != "General / Any":
532
  user_message += f"(Focus on: {topic})"
533
 
534
- # Add user message to history
535
  st.session_state.messages.append({"role": "user", "content": user_message})
536
 
537
  # Display user message
538
  with st.chat_message("user"):
539
  if user_input:
540
  st.markdown(user_input)
 
541
  if visual_content:
542
- st.image(visual_content, caption="Uploaded Image", use_column_width=True)
543
- st.caption("📸 Image attached for analysis")
 
 
 
544
 
545
-
546
- # Check for API key
547
- if not api_key:
 
 
 
 
 
548
  st.error(
549
- "**❌ API Configuration Required**\n\n"
550
- "The app needs API keys to function.\n\n"
551
- "Please contact the administrator or check if the API keys "
552
- "are properly configured in the deployment settings."
553
  )
554
  st.stop()
555
 
556
  # Prepare API call
557
  try:
558
- # Build conversation context
559
  conversation_context = []
560
-
561
- # Add system message
562
  conversation_context.append({"role": "system", "content": SYSTEM_INSTRUCTIONS})
563
 
564
- # Add recent conversation (last 8 messages for context)
565
  recent_messages = st.session_state.messages[-8:]
566
  for msg in recent_messages:
567
- if msg["role"] != "system": # Don't duplicate system message
568
  conversation_context.append(msg)
569
 
570
  # Call Groq API
571
  with st.spinner("Feynman is thinking... ⚛️"):
572
- response_text = call_groq_api(api_key, conversation_context)
573
 
574
- # Handle response
575
  if response_text:
576
- # Check for error messages
577
- if response_text.startswith("") or response_text.startswith("⚠️"):
578
- st.error(response_text)
579
- if "Invalid API Key" in response_text:
580
- st.info(
581
- "**Fix Invalid API Key:**\n"
582
- "1. Go to [console.groq.com](https://console.groq.com)\n"
583
- "2. Check your API keys\n"
584
- "3. Make sure you're using the correct key\n"
585
- "4. Keys start with: `gsk_`"
586
- )
587
- else:
588
- # Add assistant response to history
589
- st.session_state.messages.append({"role": "assistant", "content": response_text})
590
-
591
- # Display response
592
- display_message("assistant", response_text, enable_voice)
593
 
594
- # Show token estimate
595
- estimated_tokens = len(response_text) // 4
596
- st.caption(f"📝 Response length: ~{estimated_tokens} tokens | ⚡ Powered by Groq")
597
  else:
598
- st.error(
599
- "**Failed to get response from Groq API.**\n\n"
600
- "Possible reasons:\n"
601
- "1. **Rate Limit** - Free tier has limits, wait a moment\n"
602
- "2. **Service Down** - Groq might be experiencing issues\n"
603
- "3. **Network Issue** - Check your connection\n\n"
604
- "Try again in a moment."
605
- )
606
 
607
  except Exception as e:
608
- st.error(
609
- f"**❌ Unexpected Error**\n\n"
610
- f"```python\n{str(e)[:200]}\n```\n\n"
611
- f"**Troubleshooting:**\n"
612
- f"1. Refresh the page\n"
613
- f"2. Check your API key\n"
614
- f"3. Clear chat and try again\n"
615
- f"4. Visit [status.groq.com](https://status.groq.com) for service status"
616
- )
617
 
618
  # Footer - SIMPLE VERSION
619
  st.divider()
620
- st.markdown("**H2 Physics Feynman Tutor** | Singapore H2 Physics (9478) Syllabus")
621
- st.markdown("Powered by [Groq AI](https://groq.com)")
622
- st.markdown("*Disclaimer: AI tutoring assistant. Always verify with official syllabus documents.*")
 
6
  import io
7
  import json
8
  import requests
9
+ import base64
10
  from PIL import Image
11
  from gtts import gTTS
12
  from duckduckgo_search import DDGS
13
  import warnings
14
  warnings.filterwarnings('ignore')
15
 
16
+ # -----------------------------------------------------------------------------
17
+ # 1. PAGE CONFIGURATION
18
+ # -----------------------------------------------------------------------------
19
  st.set_page_config(
20
  page_title="H2 Physics Feynman Bot",
21
  page_icon="⚛️",
 
23
  initial_sidebar_state="expanded"
24
  )
25
 
26
+ # -----------------------------------------------------------------------------
27
+ # 2. HELPER FUNCTIONS
28
+ # -----------------------------------------------------------------------------
29
+
30
  @st.cache_data(show_spinner=False, ttl=3600)
31
  def generate_audio(text):
32
+ """Generates MP3 audio from text."""
33
  clean_text = re.sub(r'```.*?```', 'I have generated a graph.', text, flags=re.DOTALL)
34
  clean_text = re.sub(r'\[IMAGE:.*?\]', 'Here is a diagram.', clean_text)
35
 
 
43
  audio_fp.seek(0)
44
  return audio_fp
45
  except Exception as e:
 
46
  return None
47
 
 
48
  def google_search_api(query, api_key, cx):
49
+ """Google Custom Search API."""
50
  try:
51
  url = "https://www.googleapis.com/customsearch/v1"
52
  params = {
 
73
  return data["items"][0]["link"]
74
 
75
  except Exception as e:
 
76
  return None
77
  return None
78
 
79
  def duckduckgo_search_api(query):
80
+ """DuckDuckGo image search."""
81
  try:
82
  with DDGS() as ddgs:
83
  results = list(ddgs.images(query, max_results=1))
84
  if results:
85
  return results[0]['image']
86
  except Exception as e:
87
+ return None
88
  return None
89
 
90
  @st.cache_data(show_spinner=False, ttl=300)
91
  def search_image(query):
92
+ """Image search with fallback."""
93
  try:
94
+ # Check secrets
95
+ if hasattr(st, 'secrets'):
96
+ cx = st.secrets.get("GOOGLE_CX", "")
97
+ key1 = st.secrets.get("GOOGLE_SEARCH_KEY", "")
98
+ key2 = st.secrets.get("GOOGLE_SEARCH_KEY_2", "")
99
+
100
+ if key1 and cx:
101
+ url = google_search_api(query, key1, cx)
102
+ if url: return url
103
+
104
+ if key2 and cx:
105
+ url = google_search_api(query, key2, cx)
106
+ if url: return url
 
 
 
 
 
 
 
107
 
108
+ # Fallback to DuckDuckGo
109
+ return duckduckgo_search_api(query)
 
 
110
 
111
+ except Exception as e:
112
+ return None
 
 
113
 
114
+ # -----------------------------------------------------------------------------
115
+ # 3. QWEN-VL IMAGE ANALYSIS FUNCTIONS
116
+ # -----------------------------------------------------------------------------
 
117
 
118
+ def analyze_image_with_qwen_api(image, query="What physics concepts are shown in this image?", api_key=None):
119
+ """Analyze image using DashScope Qwen-VL API."""
120
+ try:
121
+ # Convert image to base64
122
+ buffered = io.BytesIO()
123
+
124
+ # Convert to RGB if necessary
125
+ if image.mode != 'RGB':
126
+ image = image.convert('RGB')
127
+
128
+ image.save(buffered, format="JPEG", quality=85)
129
+ img_base64 = base64.b64encode(buffered.getvalue()).decode('utf-8')
130
+
131
+ # Prepare API request
132
+ url = "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation"
133
+
134
+ headers = {
135
+ "Authorization": f"Bearer {api_key}",
136
+ "Content-Type": "application/json"
137
+ }
138
+
139
+ # Build message
140
+ messages = [
141
+ {
142
+ "role": "system",
143
+ "content": [
144
+ {
145
+ "text": "You are a physics expert analyzing images for educational purposes. Focus on identifying physics concepts, diagrams, equations, and explaining them clearly."
146
+ }
147
+ ]
148
+ },
149
+ {
150
+ "role": "user",
151
+ "content": [
152
+ {"image": f"data:image/jpeg;base64,{img_base64}"},
153
+ {"text": query}
154
+ ]
155
+ }
156
+ ]
157
+
158
+ payload = {
159
+ "model": "qwen-vl-plus",
160
+ "input": {"messages": messages},
161
+ "parameters": {
162
+ "max_tokens": 1000,
163
+ "temperature": 0.7
164
+ }
165
+ }
166
 
167
+ # Make API call
168
+ response = requests.post(url, json=payload, headers=headers, timeout=30)
169
+
170
+ if response.status_code == 200:
171
+ result = response.json()
172
+ if "output" in result and "choices" in result["output"]:
173
+ return result["output"]["choices"][0]["message"]["content"]
174
+ else:
175
+ return "Analysis completed but no detailed response."
176
+ else:
177
+ return f"API Error {response.status_code}: {response.text[:200]}"
178
+
179
  except Exception as e:
180
+ return f"Image analysis error: {str(e)[:100]}"
181
+
182
+ def analyze_image_local_fallback(image, query):
183
+ """Simple local image analysis fallback."""
184
+ try:
185
+ # Get basic image info
186
+ width, height = image.size
187
+ format_info = image.format if image.format else "Unknown"
188
+ mode = image.mode
189
+
190
+ analysis = f"Image analysis: {width}x{height} pixels, format: {format_info}, mode: {mode}. "
191
+
192
+ # Try to detect if it's a physics-related image
193
+ if width > height:
194
+ analysis += "The image appears to be landscape orientation. "
195
+ else:
196
+ analysis += "The image appears to be portrait orientation. "
197
+
198
+ # Add physics context
199
+ analysis += "For physics analysis, please describe what you see in the image, and I'll help explain the physics concepts."
200
+
201
+ return analysis
202
+ except:
203
+ return "Image received. Please describe what you see in the image for physics analysis."
204
+
205
+ # -----------------------------------------------------------------------------
206
+ # 4. GRAPH FUNCTIONS
207
+ # -----------------------------------------------------------------------------
208
 
 
209
  def execute_plotting_code(code_snippet):
210
+ """Execute Python plotting code."""
211
  try:
 
212
  plt.close('all')
213
+ fig, ax = plt.subplots(figsize=(10, 6))
214
 
 
 
 
 
215
  namespace = {
216
  'plt': plt,
217
  'np': np,
218
+ 'ax': ax,
219
  'fig': fig,
 
220
  'math': __import__('math')
221
  }
222
 
 
223
  cleaned_code = code_snippet.strip()
224
 
225
+ # Ensure imports
226
  if 'import matplotlib' not in cleaned_code:
227
  cleaned_code = 'import matplotlib.pyplot as plt\nimport numpy as np\n' + cleaned_code
228
 
229
+ # Ensure plotting
230
+ plot_keywords = ['plt.plot(', 'ax.plot(', 'plt.scatter(', 'ax.scatter(']
 
 
 
 
 
 
 
231
  has_plot = any(keyword in cleaned_code for keyword in plot_keywords)
232
 
233
  if not has_plot:
234
+ cleaned_code += '\n\n# Sample plot\nx = np.linspace(0, 10, 100)\ny = np.sin(x)\nplt.plot(x, y, "b-", linewidth=2, label="sin(x)")\n'
 
 
 
 
235
 
236
+ # Remove plt.show
237
  cleaned_code = cleaned_code.replace('plt.show()', '')
 
238
 
239
  # Add labels if missing
240
  if 'plt.xlabel' not in cleaned_code:
241
  cleaned_code += '\nplt.xlabel("X-axis", fontsize=12)'
242
  if 'plt.ylabel' not in cleaned_code:
243
  cleaned_code += '\nplt.ylabel("Y-axis", fontsize=12)'
 
 
244
  if 'plt.grid' not in cleaned_code:
245
  cleaned_code += '\nplt.grid(True, linestyle="--", alpha=0.6)'
246
 
 
 
 
 
247
  exec(cleaned_code, namespace)
248
 
249
+ ax = plt.gca()
250
+ if not ax.get_title():
251
+ ax.set_title('Physics Graph', fontsize=14)
252
 
 
253
  st.pyplot(fig)
 
 
254
  plt.close(fig)
255
 
 
 
256
  except Exception as e:
257
+ st.error(f"Graph Error: {str(e)[:100]}")
258
 
259
+ # Fallback plot
260
  try:
261
  fig, ax = plt.subplots(figsize=(10, 6))
262
  x = np.linspace(0, 10, 100)
263
+ y = x**2
264
  ax.plot(x, y, 'b-', linewidth=2, label='y = x²')
265
  ax.set_xlabel('X', fontsize=12)
266
  ax.set_ylabel('Y', fontsize=12)
267
+ ax.set_title('Sample Graph', fontsize=14)
268
  ax.grid(True, linestyle='--', alpha=0.6)
269
  ax.legend()
270
  st.pyplot(fig)
271
  plt.close(fig)
 
272
  except:
273
+ st.warning("Could not generate graph.")
274
+
 
 
 
275
  def display_message(role, content, enable_voice=False):
276
+ """Display chat message."""
277
  with st.chat_message(role):
278
 
279
  text_to_display = content
280
 
281
+ # Extract code blocks
282
  code_pattern = r'```python\s*(.*?)```'
283
  code_matches = list(re.finditer(code_pattern, content, re.DOTALL))
284
 
 
285
  for match in reversed(code_matches):
286
  text_to_display = text_to_display.replace(match.group(0), "")
287
 
288
+ # Check for image tags
289
  image_match = re.search(r'\[IMAGE:\s*(.*?)\]', text_to_display, re.IGNORECASE)
290
  image_result = None
291
 
 
295
  with st.spinner(f"Searching for '{search_query}'..."):
296
  image_result = search_image(search_query)
297
 
298
+ # Display text
299
  st.markdown(text_to_display)
300
+
301
+ # Show code and graph
302
  if code_matches and role == "assistant":
303
  for match in code_matches:
304
  code_content = match.group(1).strip()
305
  if code_content:
 
306
  with st.expander("📝 View Python Code", expanded=False):
307
  st.code(code_content, language='python')
308
 
 
309
  with st.expander("📊 Generated Graph", expanded=True):
 
310
  execute_plotting_code(code_content)
311
 
312
+ # Show image
313
  if image_match and role == "assistant":
314
+ if image_result and "http" in str(image_result):
315
  try:
316
+ st.image(image_result, caption=f"📷 {image_match.group(1)}", width=500)
 
317
  st.markdown(f"🔗 [Open Image]({image_result})")
318
+ except:
319
+ st.warning("Could not display image.")
 
 
 
 
320
  else:
321
+ st.warning(f"Image search failed.")
322
 
323
+ # Audio output
 
324
  if enable_voice and role == "assistant" and len(text_to_display.strip()) > 10:
325
  audio_bytes = generate_audio(text_to_display)
326
  if audio_bytes:
327
  st.audio(audio_bytes, format='audio/mp3')
328
+
329
+ # -----------------------------------------------------------------------------
330
+ # 5. GROQ API FUNCTION
331
+ # -----------------------------------------------------------------------------
332
+
333
  def call_groq_api(api_key, messages, max_tokens=2000):
334
+ """Call Groq API."""
335
  url = "https://api.groq.com/openai/v1/chat/completions"
336
 
337
  headers = {
 
339
  "Content-Type": "application/json"
340
  }
341
 
 
342
  formatted_messages = []
343
  for msg in messages:
344
  if msg["role"] == "system":
 
352
  "content": msg["content"]
353
  })
354
 
 
355
  models_to_try = [
356
  "llama-3.1-8b-instant",
357
  "llama-3.2-3b-preview",
 
358
  ]
359
 
360
  for model in models_to_try:
 
374
  return result["choices"][0]["message"]["content"]
375
  elif response.status_code == 429:
376
  continue
 
 
 
 
377
 
378
+ except:
 
 
379
  continue
380
 
381
+ return "Service temporarily unavailable. Please try again."
382
+
383
  # System Instructions for the AI - Part 5a (Start)
384
  SYSTEM_INSTRUCTIONS = """
385
  **Identity:** Richard Feynman. Tutor for Singapore H2 Physics (9478).
 
496
  )
497
 
498
  enable_voice = st.toggle("🗣️ Text-to-Speech", value=False)
499
+
500
+ st.divider()
501
+
502
+ # Image Analysis Settings
503
+ st.header("🖼️ Image Analysis")
504
+
505
+ image_analysis_mode = st.radio(
506
+ "Analysis Method:",
507
+ ["DashScope API", "Basic Analysis"],
508
+ index=0,
509
+ help="DashScope: Advanced AI analysis. Basic: Simple image info."
510
+ )
511
+
512
+ if image_analysis_mode == "DashScope API":
513
+ # Get API key from secrets or env
514
+ dashscope_key = None
515
+ if hasattr(st, 'secrets') and "DASHSCOPE_API_KEY" in st.secrets:
516
+ dashscope_key = st.secrets["DASHSCOPE_API_KEY"]
517
+ st.success("✓ DashScope API configured")
518
+ elif "DASHSCOPE_API_KEY" in os.environ:
519
+ dashscope_key = os.environ["DASHSCOPE_API_KEY"]
520
+ st.success("✓ DashScope API configured")
521
+ else:
522
+ st.warning("Add DASHSCOPE_API_KEY to secrets")
523
+ st.info("Get free credits: dashscope.aliyun.com")
524
+ else:
525
+ st.info("Basic image analysis mode")
526
 
527
  #st.divider()
528
 
529
+ # Media Input
530
+ st.header("📤 Input Methods")
531
 
532
  visual_content = None
533
+ image_analysis = None
534
+
535
+ # Method selection
536
+ input_method = st.radio(
537
+ "Choose input method:",
538
+ ["Camera", "Upload"],
539
+ horizontal=True
540
  )
541
 
542
+ if input_method == "Camera":
543
+ st.subheader("📷 Camera")
544
+ camera_photo = st.camera_input("Take a photo of physics problem")
545
+
546
+ if camera_photo:
547
+ try:
548
+ image = Image.open(camera_photo)
549
+ visual_content = image
550
+ st.image(image, caption="Camera Capture", width=200)
551
+ st.success("✓ Photo captured")
552
+
553
+ # Analyze immediately
554
+ with st.spinner("Analyzing image..."):
555
+ if image_analysis_mode == "DashScope API" and dashscope_key:
556
+ image_analysis = analyze_image_with_qwen_api(
557
+ image,
558
+ "Analyze this physics image. What concepts, diagrams, or equations do you see?",
559
+ dashscope_key
560
+ )
561
+ else:
562
+ image_analysis = analyze_image_local_fallback(
563
+ image,
564
+ "Analyze this physics image"
565
+ )
566
+
567
+ if image_analysis:
568
+ st.info(f"📋 Analysis: {image_analysis[:150]}...")
569
+
570
+ except Exception as e:
571
+ st.error(f"Camera error: {e}")
572
+
573
+ else: # Upload
574
+ st.subheader("📁 Upload")
575
+ uploaded_file = st.file_uploader(
576
+ "Choose image",
577
+ type=["jpg", "jpeg", "png"],
578
+ help="Upload physics diagrams or problems"
579
+ )
580
+
581
+ if uploaded_file:
582
+ try:
583
+ image = Image.open(uploaded_file)
584
+ visual_content = image
585
+ st.image(image, caption="Uploaded Image", width=200)
586
+ st.success("✓ Image loaded")
587
+
588
+ # Analyze immediately
589
+ with st.spinner("Analyzing image..."):
590
+ if image_analysis_mode == "DashScope API" and dashscope_key:
591
+ image_analysis = analyze_image_with_qwen_api(
592
+ image,
593
+ "Analyze this physics image. What concepts, diagrams, or equations do you see?",
594
+ dashscope_key
595
+ )
596
+ else:
597
+ image_analysis = analyze_image_local_fallback(
598
+ image,
599
+ "Analyze this physics image"
600
+ )
601
+
602
+ if image_analysis:
603
+ st.info(f"📋 Analysis: {image_analysis[:150]}...")
604
+
605
+ except Exception as e:
606
+ st.error(f"Upload error: {e}")
607
 
608
  #st.divider()
609
 
610
+ # Controls
611
  if st.button("🧹 Clear Chat History", use_container_width=True):
612
  st.session_state.messages = []
613
  st.rerun()
614
 
615
  st.divider()
616
+ st.caption("H2 Physics 9478 | Qwen-VL Image Analysis")
617
  st.caption("Made with ❤️ for JPJC H2 Physics students | Powered by Groq AI")
618
 
619
  # 7 Main Chat Interface
 
641
  user_message = ""
642
  if user_input:
643
  user_message += user_input + " "
644
+
645
  if visual_content:
646
+ user_message += "[I have uploaded/taken a photo of a physics problem] "
647
+
648
+ if image_analysis:
649
+ user_message += f"[Image Analysis: {image_analysis}] "
650
+
651
  if topic != "General / Any":
652
  user_message += f"(Focus on: {topic})"
653
 
654
+ # Add to history
655
  st.session_state.messages.append({"role": "user", "content": user_message})
656
 
657
  # Display user message
658
  with st.chat_message("user"):
659
  if user_input:
660
  st.markdown(user_input)
661
+
662
  if visual_content:
663
+ st.image(visual_content, caption="Your Image", width=300)
664
+
665
+ if image_analysis:
666
+ with st.expander("📋 Image Analysis Details"):
667
+ st.write(image_analysis)
668
 
669
+ # Check for Groq API key
670
+ groq_key = None
671
+ if hasattr(st, 'secrets') and "GROQ_API_KEY" in st.secrets:
672
+ groq_key = st.secrets["GROQ_API_KEY"]
673
+ elif "GROQ_API_KEY" in os.environ:
674
+ groq_key = os.environ["GROQ_API_KEY"]
675
+
676
+ if not groq_key:
677
  st.error(
678
+ "**API Configuration Required**\n\n"
679
+ "Groq API key not found. Please ensure GROQ_API_KEY is set in secrets."
 
 
680
  )
681
  st.stop()
682
 
683
  # Prepare API call
684
  try:
 
685
  conversation_context = []
 
 
686
  conversation_context.append({"role": "system", "content": SYSTEM_INSTRUCTIONS})
687
 
 
688
  recent_messages = st.session_state.messages[-8:]
689
  for msg in recent_messages:
690
+ if msg["role"] != "system":
691
  conversation_context.append(msg)
692
 
693
  # Call Groq API
694
  with st.spinner("Feynman is thinking... ⚛️"):
695
+ response_text = call_groq_api(groq_key, conversation_context)
696
 
 
697
  if response_text:
698
+ st.session_state.messages.append({"role": "assistant", "content": response_text})
699
+ display_message("assistant", response_text, enable_voice)
700
+
701
+ # Clear image content after processing
702
+ if 'visual_content' in locals():
703
+ visual_content = None
704
+ if 'image_analysis' in locals():
705
+ image_analysis = None
 
 
 
 
 
 
 
 
 
706
 
 
 
 
707
  else:
708
+ st.error("Failed to get response. Please try again.")
 
 
 
 
 
 
 
709
 
710
  except Exception as e:
711
+ st.error(f"Error: {str(e)[:200]}")
 
 
 
 
 
 
 
 
712
 
713
  # Footer - SIMPLE VERSION
714
  st.divider()
715
+ st.markdown("H2 Physics Feynman Tutor | Singapore H2 Physics (9478) Syllabus")
716
+ st.markdown("Powered by Groq AI + Qwen-VL Image Analysis")
717
+ st.markdown("AI tutoring assistant. Verify with official syllabus.")