MasteredUltraInstinct commited on
Commit
280fb96
Β·
verified Β·
1 Parent(s): 9f23e99

Update image.py

Browse files
Files changed (1) hide show
  1. image.py +308 -24
image.py CHANGED
@@ -9,14 +9,14 @@ import io
9
  import logging
10
  from llm_utils import explain_with_llm # βœ… Added for LLM explanation
11
 
12
- # Logging setup
13
  logging.basicConfig(level=logging.INFO)
14
  logger = logging.getLogger(__name__)
15
 
16
- # Define symbols
17
  x, y = sp.symbols('x y')
18
 
19
- # βœ… Pix2Text model initialized here instead of being passed in
20
  try:
21
  p2t_model = Pix2Text.from_config()
22
  logger.info("Pix2Text model loaded successfully")
@@ -24,12 +24,292 @@ except Exception as e:
24
  logger.error(f"Failed to load Pix2Text model: {e}")
25
  p2t_model = None
26
 
27
- # βœ… Only function header is changed to remove argument
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  def image_tab():
29
  """Create the Image Upload Solver tab"""
30
  with gr.Tab("Image Upload Solver"):
31
  gr.Markdown("## Solve Equations from Image")
32
-
33
  with gr.Row():
34
  image_input = gr.File(
35
  label="Upload Question Image",
@@ -37,7 +317,7 @@ def image_tab():
37
  file_count="single"
38
  )
39
  image_upload_btn = gr.Button("Process Image")
40
-
41
  gr.Markdown("**Supported Formats:** .pdf, .png, .jpg, .jpeg")
42
 
43
  with gr.Row():
@@ -45,7 +325,7 @@ def image_tab():
45
  preview_image_btn = gr.Button("Preview Equation")
46
 
47
  image_equation_display = gr.Markdown()
48
-
49
  with gr.Row():
50
  confirm_image_btn = gr.Button("Display Solution", visible=False)
51
  edit_image_btn = gr.Button("Make Changes Manually", visible=False)
@@ -57,8 +337,8 @@ def image_tab():
57
  image_plot_output = gr.Plot()
58
  extracted_eq_state = gr.State()
59
 
60
- # βœ… Added LLM input/output components
61
- llm_url_input = gr.Textbox(label="LLM Microservice URL (optional)", placeholder="https://your-llm-url.ngrok.app")
62
  explain_image_btn = gr.Button("Explain with LLM")
63
  image_solution_txt = gr.Textbox(visible=False)
64
 
@@ -92,7 +372,7 @@ def image_tab():
92
 
93
  preview_image_btn.click(
94
  fn=preview_image_equation,
95
- inputs=[extracted_eq_state, real_image_checkbox],
96
  outputs=[image_equation_display, confirm_image_btn, edit_image_btn,
97
  image_steps_md, image_plot_output]
98
  )
@@ -101,23 +381,25 @@ def image_tab():
101
  if not eq_data or eq_data["type"] == "error":
102
  return "⚠️ No valid equation to solve.", None, ""
103
  try:
104
- steps, plot, error = solve_extracted_equation(eq_data, real_only)
105
- return steps, plot, steps # βœ… store solution for LLM
106
  except Exception as e:
107
  return f"❌ Error solving equation: {str(e)}", None, ""
108
 
109
  confirm_image_btn.click(
110
  fn=confirm_image_solution,
111
- inputs=[extracted_eq_state, real_image_checkbox],
112
- outputs=[image_steps_md, image_plot_output, image_solution_txt] # βœ… added solution_txt
113
  )
114
 
115
  def enable_manual_edit(eq_data):
116
  latex_value = eq_data.get("latex", "") if eq_data and eq_data["type"] != "error" else "Error in extraction."
117
- return (gr.update(visible=True, value=latex_value),
118
- gr.update(visible=True),
119
- gr.update(visible=False),
120
- gr.update(visible=False))
 
 
121
 
122
  edit_image_btn.click(
123
  fn=enable_manual_edit,
@@ -132,22 +414,23 @@ def image_tab():
132
  eq_type = parse_equation_type(latex_input)
133
  if eq_type == 'polynomial':
134
  eq_data = extract_polynomial_coefficients(latex_input)
135
- return solve_polynomial(eq_data["degree"], eq_data["coeffs"], real_only)[:2] + (solve_polynomial(eq_data["degree"], eq_data["coeffs"], real_only)[0],)
136
  elif eq_type == 'linear_system':
137
  eq_data = extract_linear_system_coefficients(latex_input)
138
- return solve_linear_system_from_coeffs(eq_data["eq1_coeffs"], eq_data["eq2_coeffs"])[:2] + (solve_linear_system_from_coeffs(eq_data["eq1_coeffs"], eq_data["eq2_coeffs"])[1],)
139
  else:
140
  return "❌ Unsupported equation type", None, ""
 
141
  except Exception as e:
142
- return f"❌ Error: {str(e)}", None, ""
143
 
144
  save_edit_btn.click(
145
  fn=save_manual_changes,
146
  inputs=[edit_latex_input, real_image_checkbox],
147
- outputs=[image_steps_md, image_plot_output, image_solution_txt] # βœ… added solution_txt
148
  )
149
 
150
- # βœ… LLM Explain button click logic
151
  explain_image_btn.click(
152
  fn=lambda sol, url: explain_with_llm(sol, "image", url),
153
  inputs=[image_solution_txt, llm_url_input],
@@ -158,5 +441,6 @@ def image_tab():
158
  image_input, image_upload_btn, real_image_checkbox, preview_image_btn,
159
  image_equation_display, confirm_image_btn, edit_image_btn, edit_latex_input,
160
  save_edit_btn, image_steps_md, image_plot_output, extracted_eq_state,
161
- llm_url_input, explain_image_btn, image_solution_txt # βœ… added LLM components to return
162
  )
 
 
9
  import logging
10
  from llm_utils import explain_with_llm # βœ… Added for LLM explanation
11
 
12
+ # Configure logging for debugging
13
  logging.basicConfig(level=logging.INFO)
14
  logger = logging.getLogger(__name__)
15
 
16
+ # Define symbolic variables
17
  x, y = sp.symbols('x y')
18
 
19
+ # Initialize Pix2Text model globally
20
  try:
21
  p2t_model = Pix2Text.from_config()
22
  logger.info("Pix2Text model loaded successfully")
 
24
  logger.error(f"Failed to load Pix2Text model: {e}")
25
  p2t_model = None
26
 
27
+ def clean_latex_expression(latex_str):
28
+ """Clean and normalize LaTeX expression for SymPy parsing"""
29
+ if not latex_str:
30
+ return ""
31
+
32
+ latex_str = latex_str.strip()
33
+ latex_str = re.sub(r'^\$\$|\$\$$', '', latex_str) # Remove $$ delimiters
34
+ latex_str = re.sub(r'\\[a-zA-Z]+\{([^}]*)\}', r'\1', latex_str) # Remove LaTeX commands
35
+ latex_str = re.sub(r'\\{2,}', r'\\', latex_str) # Fix multiple backslashes
36
+ latex_str = re.sub(r'\s+', ' ', latex_str) # Normalize whitespace
37
+ latex_str = re.sub(r'\^{([^}]+)}', r'**\1', latex_str) # Convert x^{n} to x**n
38
+ latex_str = re.sub(r'(\d*\.?\d+)\s*([xy])', r'\1*\2', latex_str) # Add multiplication: 1.0x -> 1.0*x
39
+ latex_str = re.sub(r'\s*([+\-*/=])\s*', r'\1', latex_str) # Remove spaces around operators
40
+ if '=' in latex_str:
41
+ left, right = latex_str.split('=')
42
+ latex_str = f"{left} - ({right})" # Move right-hand side to left
43
+ return latex_str.strip()
44
+
45
+ def parse_equation_type(latex_str):
46
+ """Determine if the equation is polynomial (single-variable) or linear system (two-variable)"""
47
+ try:
48
+ cleaned = clean_latex_expression(latex_str)
49
+ if not cleaned:
50
+ return 'polynomial'
51
+
52
+ # Check for two-variable system
53
+ if 'y' in cleaned and 'x' in cleaned:
54
+ if '\\\\' in latex_str or '\n' in latex_str or len(re.split(r'\\\\|\n|;', latex_str)) >= 2:
55
+ return 'linear_system'
56
+ return 'linear' # Single equation with x and y
57
+
58
+ # Check for single-variable polynomial
59
+ try:
60
+ expr = sp.sympify(cleaned.split('-')[0] if '-' in cleaned else cleaned)
61
+ if x in expr.free_symbols and y not in expr.free_symbols:
62
+ degree = sp.degree(expr, x)
63
+ return 'polynomial' if degree > 0 else 'linear'
64
+ elif x not in expr.free_symbols and y in expr.free_symbols:
65
+ return 'polynomial' # Treat as polynomial in y if x is absent
66
+ else:
67
+ return 'polynomial' # Default to polynomial if no clear variables
68
+ except:
69
+ if 'x**' in cleaned or '^' in latex_str:
70
+ return 'polynomial'
71
+ return 'polynomial' # Fallback to polynomial
72
+ except Exception as e:
73
+ logger.error(f"Error determining equation type: {e}")
74
+ return 'polynomial'
75
+
76
+ def extract_polynomial_coefficients(latex_str):
77
+ try:
78
+ cleaned = clean_latex_expression(latex_str)
79
+ if '-' in cleaned:
80
+ cleaned = cleaned.split('-')[0].strip()
81
+ expr = sp.sympify(cleaned, evaluate=False)
82
+ if x not in expr.free_symbols and y not in expr.free_symbols:
83
+ raise ValueError("No variable (x or y) found in expression")
84
+ variable = x if x in expr.free_symbols else y
85
+ degree = sp.degree(expr, variable)
86
+ if degree < 1 or degree > 8:
87
+ raise ValueError(f"Polynomial degree {degree} is out of supported range (1-8)")
88
+ poly = sp.Poly(expr, variable)
89
+ coeffs = [float(poly.coeff_monomial(variable**i)) for i in range(degree, -1, -1)]
90
+ return {
91
+ "type": "polynomial",
92
+ "degree": degree,
93
+ "coeffs": " ".join(map(str, coeffs)),
94
+ "latex": latex_str,
95
+ "success": True,
96
+ "variable": str(variable)
97
+ }
98
+ except Exception as e:
99
+ logger.error(f"Error extracting polynomial coefficients: {e}")
100
+ return {
101
+ "type": "polynomial",
102
+ "degree": 2,
103
+ "coeffs": "1 0 0",
104
+ "latex": latex_str,
105
+ "success": False,
106
+ "error": str(e),
107
+ "variable": "x"
108
+ }
109
+
110
+ def extract_linear_system_coefficients(latex_str):
111
+ try:
112
+ cleaned = clean_latex_expression(latex_str)
113
+ equations = re.split(r'\\\\|\n|;', latex_str)
114
+ if len(equations) < 2:
115
+ equations = re.split(r'(?<=[0-9])\s*(?=[+-]?\s*[0-9]*[xy])', cleaned)
116
+ if len(equations) < 2 or 'y' not in cleaned or 'x' not in cleaned:
117
+ raise ValueError("Could not find two equations or two variables (x, y) in system")
118
+ eq1_str = equations[0].strip()
119
+ eq2_str = equations[1].strip()
120
+ def parse_linear_eq(eq_str):
121
+ if '-' not in eq_str:
122
+ raise ValueError("No equals sign (converted to '-') found")
123
+ left, right = eq_str.split('-')
124
+ expr = sp.sympify(left) - sp.sympify(right or '0')
125
+ a = float(expr.coeff(x, 1)) if expr.coeff(x, 1) else 0
126
+ b = float(expr.coeff(y, 1)) if expr.coeff(y, 1) else 0
127
+ c = float(-expr.as_coefficients_dict()[1]) if 1 in expr.as_coefficients_dict() else 0
128
+ return f"{a} {b} {c}"
129
+ eq1_coeffs = parse_linear_eq(eq1_str)
130
+ eq2_coeffs = parse_linear_eq(eq2_str)
131
+ return {
132
+ "type": "linear",
133
+ "eq1_coeffs": eq1_coeffs,
134
+ "eq2_coeffs": eq2_coeffs,
135
+ "latex": latex_str,
136
+ "success": True
137
+ }
138
+ except Exception as e:
139
+ logger.error(f"Error extracting linear system coefficients: {e}")
140
+ return {
141
+ "type": "linear",
142
+ "eq1_coeffs": "1 1 3",
143
+ "eq2_coeffs": "1 -1 1",
144
+ "latex": latex_str,
145
+ "success": False,
146
+ "error": str(e)
147
+ }
148
+
149
+ def extract_equation_from_image(image_file):
150
+ try:
151
+ if p2t_model is None:
152
+ return {
153
+ "type": "error",
154
+ "latex": "Pix2Text model not loaded. Please check installation.",
155
+ "success": False
156
+ }
157
+ if image_file is None:
158
+ return {
159
+ "type": "error",
160
+ "latex": "No image file provided.",
161
+ "success": False
162
+ }
163
+ if isinstance(image_file, str):
164
+ image = Image.open(image_file)
165
+ else:
166
+ image = Image.open(image_file.name)
167
+ if image.mode != 'RGB':
168
+ image = image.convert('RGB')
169
+ logger.info(f"Processing image of size: {image.size}")
170
+ result = p2t_model.recognize_text_formula(image)
171
+ if not result or result.strip() == "":
172
+ return {
173
+ "type": "error",
174
+ "latex": "No text or formulas detected in the image.",
175
+ "success": False
176
+ }
177
+ logger.info(f"Extracted text: {result}")
178
+ eq_type = parse_equation_type(result)
179
+ if eq_type == 'polynomial':
180
+ return extract_polynomial_coefficients(result)
181
+ elif eq_type == 'linear_system':
182
+ return extract_linear_system_coefficients(result)
183
+ else:
184
+ return {
185
+ "type": "error",
186
+ "latex": f"Unsupported equation type detected: {eq_type}",
187
+ "success": False
188
+ }
189
+ except Exception as e:
190
+ logger.error(f"Error processing image: {e}")
191
+ return {
192
+ "type": "error",
193
+ "latex": f"Error processing image: {str(e)}",
194
+ "success": False
195
+ }
196
+
197
+ def solve_polynomial(degree, coeff_string, real_only):
198
+ try:
199
+ coeffs = list(map(float, coeff_string.strip().split()))
200
+ if len(coeffs) != degree + 1:
201
+ return f"⚠️ Please enter exactly {degree + 1} coefficients.", None, None
202
+
203
+ poly = sum([coeffs[i] * x**(degree - i) for i in range(degree + 1)])
204
+ simplified = sp.simplify(poly)
205
+ factored = sp.factor(simplified)
206
+ roots = sp.solve(sp.Eq(simplified, 0), x)
207
+
208
+ if real_only:
209
+ roots = [r for r in roots if sp.im(r) == 0]
210
+
211
+ roots_output = "$$\n" + "\\ ".join(
212
+ [f"r_{{{i}}} = {sp.latex(sp.nsimplify(r, rational=True))}" for i, r in enumerate(roots, 1)]
213
+ ) + "\n$$"
214
+
215
+ steps_output = f"""
216
+ ### Polynomial Expression
217
+ $$ {sp.latex(poly)} = 0 $$
218
+ ### Simplified
219
+ $$ {sp.latex(simplified)} = 0 $$
220
+ ### Factored
221
+ $$ {sp.latex(factored)} = 0 $$
222
+ ### Roots {'(Only Real)' if real_only else '(All Roots)'}
223
+ {roots_output}
224
+ """
225
+
226
+ x_vals = np.linspace(-10, 10, 400)
227
+ y_vals = np.polyval(coeffs, x_vals)
228
+
229
+ fig, ax = plt.subplots(figsize=(6, 4))
230
+ ax.plot(x_vals, y_vals, label="Polynomial", color="blue")
231
+ ax.axhline(0, color='black', linewidth=0.5)
232
+ ax.axvline(0, color='black', linewidth=0.5)
233
+ ax.grid(True)
234
+ ax.set_title("Graph of the Polynomial")
235
+ ax.set_xlabel("x")
236
+ ax.set_ylabel("f(x)")
237
+ ax.legend()
238
+
239
+ return steps_output, fig, ""
240
+ except Exception as e:
241
+ return f"❌ Error: {e}", None, ""
242
+
243
+ def solve_linear_system_from_coeffs(eq1_str, eq2_str):
244
+ try:
245
+ coeffs1 = list(map(float, eq1_str.strip().split()))
246
+ coeffs2 = list(map(float, eq2_str.strip().split()))
247
+
248
+ if len(coeffs1) != 3 or len(coeffs2) != 3:
249
+ return "⚠️ Please enter exactly 3 coefficients for each equation.", None, None, None
250
+
251
+ a1, b1, c1 = coeffs1
252
+ a2, b2, c2 = coeffs2
253
+
254
+ eq1 = sp.Eq(a1 * x + b1 * y, c1)
255
+ eq2 = sp.Eq(a2 * x + b2 * y, c2)
256
+
257
+ sol = sp.solve([eq1, eq2], (x, y), dict=True)
258
+ if not sol:
259
+ return "❌ No unique solution.", None, None, None
260
+
261
+ solution = sol[0]
262
+ eq_latex = f"$$ {sp.latex(eq1)} \\ {sp.latex(eq2)} $$"
263
+
264
+ steps = rf"""
265
+ ### Step-by-step Solution
266
+ 1. **Original Equations:**
267
+ $$ {sp.latex(eq1)} $$
268
+ $$ {sp.latex(eq2)} $$
269
+ 2. **Standard Form:** Already provided.
270
+ 3. **Solve using SymPy `solve`:** Internally applies substitution/elimination.
271
+ 4. **Solve for `x` and `y`:**
272
+ $$ x = {sp.latex(solution[x])}, \quad y = {sp.latex(solution[y])} $$
273
+ 5. **Verification:** Substitute back into both equations."""
274
+
275
+ x_vals = np.linspace(-10, 10, 400)
276
+ f1 = sp.solve(eq1, y)
277
+ f2 = sp.solve(eq2, y)
278
+
279
+ fig, ax = plt.subplots()
280
+ if f1:
281
+ f1_func = sp.lambdify(x, f1[0], modules='numpy')
282
+ ax.plot(x_vals, f1_func(x_vals), label=sp.latex(eq1))
283
+ if f2:
284
+ f2_func = sp.lambdify(x, f2[0], modules='numpy')
285
+ ax.plot(x_vals, f2_func(x_vals), label=sp.latex(eq2))
286
+
287
+ ax.plot(solution[x], solution[y], 'ro', label=f"Solution ({solution[x]}, {solution[y]})")
288
+ ax.axhline(0, color='black', linewidth=0.5)
289
+ ax.axvline(0, color='black', linewidth=0.5)
290
+ ax.legend()
291
+ ax.set_title("Graph of the Linear System")
292
+ ax.grid(True)
293
+
294
+ return eq_latex, steps, fig, ""
295
+ except Exception as e:
296
+ return f"❌ Error: {e}", None, None, None
297
+
298
+ def solve_extracted_equation(eq_data, real_only):
299
+ if eq_data["type"] == "polynomial":
300
+ return solve_polynomial(eq_data["degree"], eq_data["coeffs"], real_only)
301
+ elif eq_data["type"] == "linear":
302
+ return "❌ Single linear equation not supported. Please upload a system of equations.", None, ""
303
+ elif eq_data["type"] == "linear_system":
304
+ return solve_linear_system_from_coeffs(eq_data["eq1_coeffs"], eq_data["eq2_coeffs"])
305
+ else:
306
+ return "❌ Unknown equation type", None, ""
307
+
308
  def image_tab():
309
  """Create the Image Upload Solver tab"""
310
  with gr.Tab("Image Upload Solver"):
311
  gr.Markdown("## Solve Equations from Image")
312
+
313
  with gr.Row():
314
  image_input = gr.File(
315
  label="Upload Question Image",
 
317
  file_count="single"
318
  )
319
  image_upload_btn = gr.Button("Process Image")
320
+
321
  gr.Markdown("**Supported Formats:** .pdf, .png, .jpg, .jpeg")
322
 
323
  with gr.Row():
 
325
  preview_image_btn = gr.Button("Preview Equation")
326
 
327
  image_equation_display = gr.Markdown()
328
+
329
  with gr.Row():
330
  confirm_image_btn = gr.Button("Display Solution", visible=False)
331
  edit_image_btn = gr.Button("Make Changes Manually", visible=False)
 
337
  image_plot_output = gr.Plot()
338
  extracted_eq_state = gr.State()
339
 
340
+ # βœ… Added for LLM explanation
341
+ llm_url_input = gr.Textbox(label="LLM Microservice URL (optional)", placeholder="https://your-llm.ngrok.app")
342
  explain_image_btn = gr.Button("Explain with LLM")
343
  image_solution_txt = gr.Textbox(visible=False)
344
 
 
372
 
373
  preview_image_btn.click(
374
  fn=preview_image_equation,
375
+ inputs=[extracted_eq_state, real_only],
376
  outputs=[image_equation_display, confirm_image_btn, edit_image_btn,
377
  image_steps_md, image_plot_output]
378
  )
 
381
  if not eq_data or eq_data["type"] == "error":
382
  return "⚠️ No valid equation to solve.", None, ""
383
  try:
384
+ steps, plot, _ = solve_extracted_equation(eq_data, real_only)
385
+ return steps, plot, steps # βœ… Send steps to hidden box for LLM
386
  except Exception as e:
387
  return f"❌ Error solving equation: {str(e)}", None, ""
388
 
389
  confirm_image_btn.click(
390
  fn=confirm_image_solution,
391
+ inputs=[extracted_eq_state, real_only],
392
+ outputs=[image_steps_md, image_plot_output, image_solution_txt] # βœ… includes solution
393
  )
394
 
395
  def enable_manual_edit(eq_data):
396
  latex_value = eq_data.get("latex", "") if eq_data and eq_data["type"] != "error" else "Error in extraction."
397
+ return (
398
+ gr.update(visible=True, value=latex_value),
399
+ gr.update(visible=True),
400
+ gr.update(visible=False),
401
+ gr.update(visible=False)
402
+ )
403
 
404
  edit_image_btn.click(
405
  fn=enable_manual_edit,
 
414
  eq_type = parse_equation_type(latex_input)
415
  if eq_type == 'polynomial':
416
  eq_data = extract_polynomial_coefficients(latex_input)
417
+ steps, plot, _ = solve_polynomial(eq_data["degree"], eq_data["coeffs"], real_only)
418
  elif eq_type == 'linear_system':
419
  eq_data = extract_linear_system_coefficients(latex_input)
420
+ _, steps, plot, _ = solve_linear_system_from_coeffs(eq_data["eq1_coeffs"], eq_data["eq2_coeffs"])
421
  else:
422
  return "❌ Unsupported equation type", None, ""
423
+ return steps, plot, steps # βœ… Save steps for LLM
424
  except Exception as e:
425
+ return f"❌ Error parsing manual input: {str(e)}", None, ""
426
 
427
  save_edit_btn.click(
428
  fn=save_manual_changes,
429
  inputs=[edit_latex_input, real_image_checkbox],
430
+ outputs=[image_steps_md, image_plot_output, image_solution_txt]
431
  )
432
 
433
+ # βœ… Button to send solution to LLM
434
  explain_image_btn.click(
435
  fn=lambda sol, url: explain_with_llm(sol, "image", url),
436
  inputs=[image_solution_txt, llm_url_input],
 
441
  image_input, image_upload_btn, real_image_checkbox, preview_image_btn,
442
  image_equation_display, confirm_image_btn, edit_image_btn, edit_latex_input,
443
  save_edit_btn, image_steps_md, image_plot_output, extracted_eq_state,
444
+ llm_url_input, explain_image_btn, image_solution_txt # βœ… added for LLM
445
  )
446
+