Spaces:
Sleeping
Sleeping
| """Gradio Space entrypoint for Calculus Animator. | |
| Hosted on Hugging Face Spaces. Mirrors the desktop app's solve pipeline | |
| without spawning the pygame render worker (matplotlib renders the | |
| visualization in-process for headless containers). The AI tutor routes | |
| through the same multi-provider router the desktop app uses. | |
| Configuration (set as Space secrets): | |
| LLM_PROVIDER one of: deepseek, google, openai, anthropic | |
| DEEPSEEK_API_KEY | |
| GOOGLE_API_KEY | |
| OPENAI_API_KEY | |
| ANTHROPIC_API_KEY | |
| """ | |
| import os | |
| import tempfile | |
| import gradio as gr | |
| import matplotlib | |
| matplotlib.use("Agg") # headless backend for container environments | |
| import matplotlib.pyplot as plt # noqa: E402 | |
| # โโโ Gradio-client schema-introspection workaround โโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| # gradio_client/utils.py:_json_schema_to_python_type does not guard against | |
| # JSON Schema's bool form (additionalProperties: true). On Python 3.13 + | |
| # Pydantic 2.x the generated schemas occasionally contain bool entries, | |
| # which then crash the API-info endpoint with | |
| # "TypeError: argument of type 'bool' is not iterable". Patching here makes | |
| # the helper bail to "Any" for any non-dict schema so the UI keeps serving. | |
| import gradio_client.utils as _gradio_client_utils # noqa: E402 | |
| _original_json_schema_to_python_type = _gradio_client_utils._json_schema_to_python_type | |
| def _safe_json_schema_to_python_type(schema, defs=None): # noqa: ANN001 โ match upstream sig | |
| if not isinstance(schema, dict): | |
| return "Any" | |
| return _original_json_schema_to_python_type(schema, defs) | |
| _gradio_client_utils._json_schema_to_python_type = _safe_json_schema_to_python_type | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| from core.animation_engine import AnimationEngine # noqa: E402 | |
| from core.detector import TypeDetector # noqa: E402 | |
| from core.extractor import ExpressionExtractor # noqa: E402 | |
| from core.parser import ExpressionParser # noqa: E402 | |
| from core.solver import CalculusSolver # noqa: E402 | |
| from core.step_generator import StepGenerator # noqa: E402 | |
| # โโโ Solve pipeline โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| # Module-level singletons mirror api/bridge.py:CalculusAPI.__init__ but skip | |
| # the persistent render-worker spawn (pygame is unreliable in headless | |
| # containers). | |
| _parser = ExpressionParser() | |
| _detector = TypeDetector() | |
| _extractor = ExpressionExtractor() | |
| _solver = CalculusSolver() | |
| _step_gen = StepGenerator() | |
| _animator = AnimationEngine() | |
| def _solve_expression(latex_str: str) -> dict: | |
| """Run the solve pipeline; mirrors CalculusAPI.solve without render hop.""" | |
| detected = _detector.detect(latex_str, None) | |
| inner_latex, merged = _extractor.extract(latex_str, None, {}) | |
| parsed = _parser.parse(inner_latex) | |
| if not parsed.get("success"): | |
| return {"success": False, "error": parsed.get("error", "Parse failed")} | |
| expr = parsed["sympy_expr"] | |
| result = _solver.solve(expr, detected, merged) | |
| if not result.get("success"): | |
| return result | |
| anim_steps = _step_gen.generate(result, detected) | |
| result["animation_steps"] = [s.to_dict() for s in anim_steps] | |
| result["result"] = str(result["result"]) | |
| result["detected_type"] = detected.name | |
| try: | |
| gd = _animator.generate_graph_data(expr) | |
| if gd.get("success"): | |
| result["graph_original"] = gd | |
| except (ValueError, TypeError, AttributeError): | |
| pass | |
| return result | |
| def _format_steps(steps: list) -> str: | |
| """Render solver step dicts as readable Markdown.""" | |
| if not steps: | |
| return "_(no detailed steps available)_" | |
| lines: list[str] = [] | |
| for i, step in enumerate(steps, 1): | |
| desc = step.get("description") or step.get("rule", "step") | |
| lines.append(f"**{i}. {desc}**") | |
| if step.get("before"): | |
| lines.append(f" Before: `{step['before']}`") | |
| if step.get("after"): | |
| lines.append(f" After: `{step['after']}`") | |
| lines.append("") | |
| return "\n".join(lines) | |
| def _plot_graph(graph_data: dict, title: str) -> str: | |
| """Plot the solver's x/y data via matplotlib; return temp PNG path.""" | |
| fig, ax = plt.subplots(figsize=(8, 5), dpi=110) | |
| xs = graph_data.get("x", []) or [] | |
| ys = graph_data.get("y", []) or [] | |
| cleaned = [(x, y) for x, y in zip(xs, ys) if y is not None] | |
| if cleaned: | |
| xs2, ys2 = zip(*cleaned) | |
| ax.plot(xs2, ys2, linewidth=2.0, color="#3b82f6") | |
| ax.axhline(0, color="#888", linewidth=0.5) | |
| ax.axvline(0, color="#888", linewidth=0.5) | |
| ax.grid(True, linestyle=":", alpha=0.4) | |
| ax.set_title(title) | |
| ax.set_xlabel("x") | |
| ax.set_ylabel("f(x)") | |
| out = tempfile.NamedTemporaryFile( | |
| prefix="calc_anim_", suffix=".png", delete=False | |
| ) | |
| fig.savefig(out.name, bbox_inches="tight") | |
| plt.close(fig) | |
| return out.name | |
| def solve_and_animate(expression: str): | |
| """Gradio handler: parse + solve + render visualization.""" | |
| if not (expression or "").strip(): | |
| return "Enter a calculus expression to begin.", None | |
| try: | |
| result = _solve_expression(expression) | |
| if not result.get("success"): | |
| return ( | |
| f"Could not solve: **{result.get('error', 'unknown error')}**", | |
| None, | |
| ) | |
| steps_md = _format_steps(result.get("steps", [])) | |
| title = f"{result.get('detected_type', 'Result')}: {result.get('result', '')}" | |
| graph = result.get("graph_original", {}) | |
| png = _plot_graph(graph, title) if graph.get("success") else None | |
| return steps_md, png | |
| except Exception as e: # noqa: BLE001 โ surface any error cleanly to the UI | |
| return f"Error: {e}", None | |
| # โโโ AI Tutor pipeline โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| # Defer importing the router until first use so the Space can boot even if | |
| # no provider keys are configured. The user gets a clear error in the tutor | |
| # tab rather than a launch crash. | |
| def chat(message: str, history: list) -> str: | |
| """Gradio handler: route the user's message through the LLM router.""" | |
| if not (message or "").strip(): | |
| return "" | |
| try: | |
| from ai_tutor.providers.router import generate | |
| ctx_lines: list[str] = [] | |
| for turn in history or []: | |
| if isinstance(turn, dict): | |
| role = str(turn.get("role", "user")).upper() | |
| content = str(turn.get("content", "")) | |
| elif isinstance(turn, (list, tuple)) and len(turn) == 2: | |
| # Older "tuples" history format: [user_msg, bot_msg] | |
| ctx_lines.append(f"USER: {turn[0]}") | |
| ctx_lines.append(f"ASSISTANT: {turn[1]}") | |
| continue | |
| else: | |
| continue | |
| ctx_lines.append(f"{role}: {content}") | |
| ctx = "\n".join(ctx_lines) | |
| prompt = f"{ctx}\n\nUSER: {message}\n\nASSISTANT:" if ctx else message | |
| return str(generate(prompt, mode="fast")) | |
| except Exception as e: # noqa: BLE001 โ surface tutor errors as chat replies | |
| provider = os.getenv("LLM_PROVIDER", "(unset)") | |
| return ( | |
| f"AI tutor error: {e}\n\n" | |
| f"Active provider: `{provider}`. Make sure `LLM_PROVIDER` and the " | |
| "matching API key are set in the Space secrets โ for example, " | |
| "`LLM_PROVIDER=deepseek` + `DEEPSEEK_API_KEY=...`." | |
| ) | |
| # โโโ Gradio Blocks UI โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| with gr.Blocks(title="Calculus Animator", theme=gr.themes.Soft()) as demo: | |
| gr.Markdown( | |
| "# Calculus Animator\n\n" | |
| "Symbolic calculus solver with step-by-step solutions and an AI tutor. " | |
| "Source: [github.com/Rsan0948/calculus_animator]" | |
| "(https://github.com/Rsan0948/calculus_animator)" | |
| ) | |
| with gr.Tab("Solve"): | |
| with gr.Row(): | |
| with gr.Column(): | |
| expr_input = gr.Textbox( | |
| label="Calculus expression (LaTeX)", | |
| placeholder=r"\frac{d}{dx}(x^2 \sin x)", | |
| lines=2, | |
| ) | |
| solve_btn = gr.Button("Solve", variant="primary") | |
| steps_output = gr.Markdown() | |
| with gr.Column(): | |
| visualization = gr.Image(label="Visualization", type="filepath") | |
| gr.Examples( | |
| examples=[ | |
| [r"\frac{d}{dx}(x^3 \sin x)"], | |
| [r"\int x^2 e^x \, dx"], | |
| [r"\lim_{x \to 0} \frac{\sin x}{x}"], | |
| [r"\int_0^1 x^2 \, dx"], | |
| [r"\frac{d}{dx} \tan(x^2 + 1)"], | |
| ], | |
| inputs=[expr_input], | |
| ) | |
| solve_btn.click( | |
| solve_and_animate, | |
| inputs=[expr_input], | |
| outputs=[steps_output, visualization], | |
| ) | |
| with gr.Tab("AI Tutor"): | |
| gr.Markdown( | |
| "Ask any calculus question. Powered by the same multi-provider " | |
| "LLM router as the desktop app. Provider is selected via the " | |
| "`LLM_PROVIDER` Space secret." | |
| ) | |
| gr.ChatInterface(chat, type="messages") | |
| if __name__ == "__main__": | |
| # show_api=False sidesteps Gradio's auto-introspection of handler | |
| # signatures, which trips a bool-vs-dict bug in some Pydantic 2.x + | |
| # Python 3.13 combinations. The Space UI is unaffected. | |
| demo.launch(server_name="0.0.0.0", server_port=7860, show_api=False) | |