# ═══════════════════════════════════════════════════════════════════ # Saad.AI — BSc Mathematics Solver # Engine: Groq (free) + SymPy (verified computation) # ═══════════════════════════════════════════════════════════════════ # ── Runtime imports ─────────────────────────────────────────────────── import streamlit as st import os import uuid as _uuid from html import escape as _escape_html import sympy as sp from sympy import ( symbols, diff, integrate, limit, solve, sympify, oo, sin, cos, tan, exp, log, sqrt, pi, E, Matrix, Symbol, Function, dsolve, Eq ) from sympy.parsing.sympy_parser import ( parse_expr, standard_transformations, implicit_multiplication_application ) import re # MUST be last — 'from sympy import *' would overwrite re otherwise import matplotlib matplotlib.use('Agg') # non-interactive backend — required on HuggingFace Spaces import matplotlib.pyplot as plt import numpy as np from config import settings from sympy_engine import run_sympy from ai import ask_ai, ask_gemini_vision, handle_uploaded_file # ── Page config ────────────────────────────────────────────────────── st.set_page_config( page_title="Saad.AI", page_icon="🧠", layout="wide", initial_sidebar_state="expanded" ) # ── CSS — ChatGPT-style, compact font, clean dark theme ───────────── st.markdown(""" """, unsafe_allow_html=True) # ── Session state ──────────────────────────────────────────────────── import datetime as _dt if "messages" not in st.session_state: st.session_state.messages = [] if "last_submitted" not in st.session_state: st.session_state.last_submitted = "" if "chats" not in st.session_state: st.session_state.chats = {} if "current_chat_id" not in st.session_state: st.session_state.current_chat_id = None if "pending_file_bytes" not in st.session_state: st.session_state.pending_file_bytes = None if "pending_file_name" not in st.session_state: st.session_state.pending_file_name = None if "pending_file_mime" not in st.session_state: st.session_state.pending_file_mime = None # ── Attached file kept in memory for follow-up questions ───────── if "attached_file_bytes" not in st.session_state: st.session_state.attached_file_bytes = None if "attached_file_name" not in st.session_state: st.session_state.attached_file_name = None if "attached_file_mime" not in st.session_state: st.session_state.attached_file_mime = None if "show_uploader" not in st.session_state: st.session_state.show_uploader = False # Chat history is intentionally session-local for the portfolio edition. # This keeps the demo simple, avoids database configuration, and makes the # project straightforward to deploy on Streamlit or Hugging Face Spaces. def new_chat(): """Start a fresh chat session.""" chat_id = f"chat_{_uuid.uuid4().hex}" st.session_state.current_chat_id = chat_id st.session_state.messages = [] st.session_state.last_submitted = "" # Clear any attached file memory from previous chat st.session_state.attached_file_bytes = None st.session_state.attached_file_name = None st.session_state.attached_file_mime = None def save_current_chat(): """Save current messages to the current Streamlit session.""" cid = st.session_state.current_chat_id if not cid or not st.session_state.messages: return # Auto-title from first user message first_user = next((m["content"] for m in st.session_state.messages if m["role"]=="user"), "New Chat") title = first_user[:35] + "..." if len(first_user) > 35 else first_user st.session_state.chats[cid] = { "title": title, "messages": list(st.session_state.messages), "created": _dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S") # ISO-sortable } def load_chat(chat_id): """Load a previous chat.""" if chat_id in st.session_state.chats: st.session_state.current_chat_id = chat_id st.session_state.messages = list(st.session_state.chats[chat_id]["messages"]) st.session_state.last_submitted = "" # Clear attached file memory — it belongs to a different chat session st.session_state.attached_file_bytes = None st.session_state.attached_file_name = None st.session_state.attached_file_mime = None # ════════════════════════════════════════════════════════════════════ # SYMPY ENGINE — exact symbolic computation, no AI needed for this # ════════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════════ # GRAPH PLOTTING — only when user asks to plot/graph/draw/visualize # ════════════════════════════════════════════════════════════════════ def plot_graph(problem: str, sympy_info: dict): """ Plot graph only when user explicitly requests it. Triggered by: plot, graph, draw, sketch, visualize, show graph. Expression extracted from: y=..., f(x)=..., or after keyword. Safe fallback — never crashes the app. """ p = problem.lower().strip() # ── Only trigger on explicit plot keywords ────────────────────── if not any(k in p for k in ["plot", "graph", "draw", "sketch", "visualize", "show graph"]): return # user didn't ask — do nothing try: x_sym = sp.Symbol('x') tfms = standard_transformations + (implicit_multiplication_application,) ld = { "x": x_sym, "e": sp.E, "E": sp.E, "pi": sp.pi, "PI": sp.pi, "sin": sp.sin, "cos": sp.cos, "tan": sp.tan, "exp": sp.exp, "log": sp.log, "ln": sp.log, "sqrt": sp.sqrt } def clean_expr(s): s = re.sub(r"\s+", "", s) s = re.sub(r"\^", "**", s) return s # ── Extract expression — 3 strategies in order ─────────────── expr_sym = None label = "" # Strategy 1: y = ... or f(x) = ... (most explicit) m = re.search( r"(?:y\s*=\s*|f\s*\(x\)\s*=\s*)(.+?)(?:\s+from|\s+for|\s*$)", p) if m: raw = clean_expr(m.group(1).strip()) try: expr_sym = parse_expr(raw, transformations=tfms, local_dict=ld) label = raw except Exception: pass # Strategy 2: after keyword — plot/draw/graph/sketch if expr_sym is None: m = re.search( r"(?:plot|draw|graph|sketch|visualize)\s+(.+?)(?:\s+from|\s+for|\s*$)", p) if m: raw = m.group(1).strip() # Strip noise words that are not expressions noise_words = ["the graph of", "the graph", "the function of", "the function", "the curve of", "the curve", "this", "it", "me", "of"] for nw in noise_words: raw = raw.replace(nw, "").strip() raw = clean_expr(raw) if raw: try: expr_sym = parse_expr(raw, transformations=tfms, local_dict=ld) label = raw except Exception: pass # Strategy 3: use sympy_info latex if already computed if expr_sym is None and sympy_info.get("latex"): raw = clean_expr(sympy_info["latex"]) try: expr_sym = parse_expr(raw, transformations=tfms, local_dict=ld) label = raw except Exception: pass # Nothing found — ask user to be specific if expr_sym is None: st.info("📊 Please specify the function. Example: *plot y = x^2 - 4*") return # ── Determine x range ──────────────────────────────────────── x_range_m = re.search( r"(?:from|between)\s*([-\d\.]+)\s*(?:to|and)\s*([-\d\.]+)", p) x_min = float(x_range_m.group(1)) if x_range_m else -10 x_max = float(x_range_m.group(2)) if x_range_m else 10 # ── Lambdify ───────────────────────────────────────────────── f_num = sp.lambdify(x_sym, expr_sym, modules=["numpy"]) df_sym = sp.diff(expr_sym, x_sym) df_num = sp.lambdify(x_sym, df_sym, modules=["numpy"]) x_vals = np.linspace(x_min, x_max, 800) with np.errstate(all="ignore"): y_vals = np.array(f_num(x_vals), dtype=float) dy_vals = np.array(df_num(x_vals), dtype=float) y_vals[~np.isfinite(y_vals)] = np.nan dy_vals[~np.isfinite(dy_vals)] = np.nan # ── Build plot — dark theme matching Saad.AI ───────────────── fig, ax = plt.subplots(figsize=(8, 4)) fig.patch.set_facecolor("#0f0f0f") ax.set_facecolor("#1a1a1a") # f(x) — blue ax.plot(x_vals, y_vals, color="#3b82f6", linewidth=2.2, label=f"$f(x) = {sp.latex(expr_sym)}$") # f'(x) — orange dashed — only when derivative was asked if sympy_info.get("type") == "Derivative": ax.plot(x_vals, dy_vals, color="#f59e0b", linewidth=1.8, linestyle="--", label=f"$f'(x) = {sp.latex(df_sym)}$") # Axes lines ax.axhline(0, color="#555", linewidth=0.8) ax.axvline(0, color="#555", linewidth=0.8) # Grid ax.grid(True, color="#2a2a2a", linewidth=0.6, linestyle="--") # Labels ax.set_xlabel("x", color="#ececec", fontsize=11) ax.set_ylabel("y", color="#ececec", fontsize=11) ax.set_title(f"$y = {sp.latex(expr_sym)}$", color="#ffffff", fontsize=13, pad=12) # Tick + spine colors ax.tick_params(colors="#888", labelsize=9) for spine in ax.spines.values(): spine.set_edgecolor("#2a2a2a") # Legend ax.legend(facecolor="#1a1a1a", edgecolor="#2a2a2a", labelcolor="#ececec", fontsize=9) # Smart y limits — clip extreme outliers valid_y = y_vals[np.isfinite(y_vals)] if len(valid_y) > 0: y_med = np.median(valid_y) y_std = np.std(valid_y) pad = (y_std * 5) * 0.1 if y_std > 0 else 1 ax.set_ylim( max(valid_y.min(), y_med - 5*y_std) - pad, min(valid_y.max(), y_med + 5*y_std) + pad ) plt.tight_layout() st.pyplot(fig) plt.close(fig) # free memory # Caption below graph deriv_label = " 🟠 f'(x)" if sympy_info.get("type") == "Derivative" else "" st.caption(f"📊 $y = {sp.latex(expr_sym)}$ " f"| x ∈ [{x_min}, {x_max}]" f"{deriv_label}") except Exception: pass # silent fallback — never crash the app # ════════════════════════════════════════════════════════════════════ # AI / VISION SERVICES # ════════════════════════════════════════════════════════════════════ def ask_ai_streaming(problem: str, sympy_info: dict, history: list) -> str: """Fetch the full provider response and render it without artificial delay.""" full_response = ask_ai(problem, sympy_info, history) # Provider calls are currently non-streaming. Render the completed response # once instead of replaying it with a synthetic sleep between word chunks. st.write_stream(iter((full_response,))) return full_response def _has_ai_explanation(answer: str) -> bool: """Return False for deterministic-only or explicit provider-failure responses.""" normalized = (answer or "").lstrip() return bool(normalized) and not normalized.startswith(( "✅ **SymPy Verified**", "⚠️ **AI explanation unavailable", "⚠️ **No AI provider", )) # ════════════════════════════════════════════════════════════════════ # SIDEBAR # ════════════════════════════════════════════════════════════════════ with st.sidebar: st.markdown("### 🧠 Saad.AI") st.caption("B.Sc. Mathematics Engine") st.markdown("Deterministic calculations with AI-powered explanations.") if settings.any_text_provider_enabled: st.success("AI explanations enabled") st.caption("Providers explain the verified SymPy result step by step.") else: st.info("SymPy-only mode") st.caption("Deterministic calculations work; add a provider secret for AI explanations.") st.divider() # ── New Chat Button ────────────────────────────────────────── if st.button("➕ New Chat", use_container_width=True): save_current_chat() new_chat() st.rerun() st.divider() # ── Chat History ───────────────────────────────────────────── if st.session_state.chats: st.markdown("**💬 Chat History**") # Show most recent first sorted_chats = sorted( st.session_state.chats.items(), key=lambda x: x[1]["created"], reverse=True ) for chat_id, chat_data in sorted_chats: col1, col2 = st.columns([4,1]) with col1: # Highlight current chat is_current = chat_id == st.session_state.current_chat_id label = ("▶ " if is_current else "") + chat_data["title"] if st.button(label, key=f"load_{chat_id}", use_container_width=True): save_current_chat() load_chat(chat_id) st.rerun() with col2: if st.button("🗑", key=f"del_{chat_id}"): del st.session_state.chats[chat_id] if chat_id == st.session_state.current_chat_id: new_chat() st.rerun() st.divider() st.markdown("**🎯 Topics**") st.markdown("""
📈 Calculus 🔢 Linear Algebra 📉 ODEs 🧮 Numerical 🔍 Number Theory 📐 Diff. Geometry 🌊 Hydro Mechanics 📊 Real Analysis II 📈 Graph Plotting ➕ General Math
""", unsafe_allow_html=True) st.divider() st.markdown("**⚡ Example Problems**") examples = { "-- Select --": "", "📈 Derivative": "Find the derivative of x^3 + 5x^2 - 3x + 7", "∫ Integral": "Integrate sin(x) * e^x dx", "📐 Limit": "Find limit of sin(x)/x as x -> 0", "🔢 Eigenvalues": "Find eigenvalues of matrix [[4,1],[2,3]]", "🔁 Congruence": "Solve 14x ≡ 30 (mod 44) using Euclidean algorithm", "📉 ODE": "Solve dy/dx + 2y = e^(-x) with y(0) = 1", "🧮 Newton-Raphson": "Apply Newton-Raphson to x^3 - 2x - 5 = 0, x0=2, 3 iterations", "📊 Series": "Test convergence of sum 1/n^2 from n=1 to infinity", "🌊 Bernoulli": "Explain Bernoulli equation in fluid mechanics with example", "🔍 Fermat": "State and prove Fermat's Little Theorem with example", } selected = st.selectbox( "Try an example problem", list(examples.keys()), key="example_select", help="Choose a prompt to place in the chat input.", ) st.divider() st.markdown("**🔧 How Saad.AI works**") st.markdown( "1. **SymPy** handles supported calculations exactly.\n" "2. **AI providers** explain the result step by step.\n" "3. The response is labeled when deterministic verification is available." ) st.divider() if st.button("🗑️ Clear Chat", use_container_width=True): new_chat() st.rerun() # ════════════════════════════════════════════════════════════════════ # MAIN AREA # ════════════════════════════════════════════════════════════════════ # Welcome screen — only when no messages if not st.session_state.messages: st.markdown("""
π Δ λ
Saad.AI
B.Sc. Mathematics Engine · Verified by SymPy · Explained by AI
Ask a mathematical question to begin.
""", unsafe_allow_html=True) # Small corner header — only when chat has started else: st.markdown("""
∑ Saad.AI
""", unsafe_allow_html=True) # Render chat history for i, msg in enumerate(st.session_state.messages): avatar = "🧑‍🎓" if msg["role"] == "user" else "📐" with st.chat_message(msg["role"], avatar=avatar): st.markdown(msg["content"]) if msg["role"] == "assistant": if msg.get("verified") and _has_ai_explanation(msg["content"]): st.caption("✓ AI explanation · SymPy verified computation") elif msg.get("verified"): st.caption("✓ SymPy verified · AI explanation unavailable for this response") else: st.caption("AI-generated explanation — deterministic verification was unavailable for this request.") with st.expander("Solution tools"): st.download_button( "Download Markdown", data=msg["content"], file_name=f"saad-ai-solution-{i + 1}.md", mime="text/markdown", key=f"download_solution_{i}", ) st.code(msg["content"], language=None) # ════════════════════════════════════════════════════════════════════ # FILE ATTACH — FIXED: real Streamlit button toggle (no JS tricks) # Works reliably on HuggingFace Spaces — no iframe/JS issues # ════════════════════════════════════════════════════════════════════ _pending = st.session_state.pending_file_bytes is not None _memfile = st.session_state.attached_file_name # ── Status bar ─────────────────────────────────────────────────────── if _pending: col_s, col_x = st.columns([9, 1]) with col_s: st.markdown( f'
' f'✅ {_escape_html(st.session_state.pending_file_name or "")} — ready · type your question and press Enter
', unsafe_allow_html=True ) with col_x: if st.button("✕", key="rm_pending", help="Remove file"): st.session_state.pending_file_bytes = None st.session_state.pending_file_name = None st.session_state.pending_file_mime = None st.session_state["last_uploaded_file"] = "" st.session_state.show_uploader = False st.rerun() elif _memfile: col_s, col_x = st.columns([9, 1]) with col_s: st.markdown( f'
' f'📎 {_escape_html(_memfile or "")} in memory — ask a follow-up or click 📎 to attach new
', unsafe_allow_html=True ) with col_x: if st.button("✕", key="rm_attached", help="Clear file memory"): st.session_state.attached_file_bytes = None st.session_state.attached_file_name = None st.session_state.attached_file_mime = None st.rerun() # ── 📎 Attach toggle button ────────────────────────────────────────── attach_col, _ = st.columns([1, 8]) with attach_col: btn_label = "📎 Attached" if (st.session_state.show_uploader or _pending) else "📎 Attach" if st.button(btn_label, key="toggle_uploader", help="Attach image or PDF"): st.session_state.show_uploader = not st.session_state.show_uploader st.rerun() # ── Real file uploader — only shown when toggled on ────────────────── if st.session_state.show_uploader and not _pending: uploaded = st.file_uploader( "Upload an image or PDF", type=["jpg", "jpeg", "png", "webp", "pdf"], help="Maximum size is controlled by MAX_UPLOAD_BYTES.", key="main_uploader", ) if uploaded is not None: _fkey = f"{uploaded.size}_{uploaded.type}_{uploaded.name}" if _fkey != st.session_state.get("last_uploaded_file", ""): st.session_state["last_uploaded_file"] = _fkey st.session_state.pending_file_bytes = uploaded.read() st.session_state.pending_file_name = uploaded.name st.session_state.pending_file_mime = uploaded.type or "application/octet-stream" st.session_state.show_uploader = False st.rerun() # ════════════════════════════════════════════════════════════════════ # INPUT — ChatGPT-style input bar # ════════════════════════════════════════════════════════════════════ # Pre-fill from example selector prefill = examples.get(selected, "") if selected != "-- Select --" else "" user_input = st.chat_input( placeholder="Type a math problem... or attach a file in the sidebar ← then ask here", ) if prefill and prefill != st.session_state.last_submitted: problem = prefill elif user_input and user_input.strip(): problem = user_input.strip() else: problem = "" # ════════════════════════════════════════════════════════════════════ # PROCESS — only when there's a new problem # ════════════════════════════════════════════════════════════════════ if problem and problem != st.session_state.last_submitted: st.session_state.last_submitted = problem # ── If a NEW file is attached, send file + question to Vision ──── if st.session_state.pending_file_bytes is not None: import base64 file_bytes = st.session_state.pending_file_bytes file_name = st.session_state.pending_file_name file_mime = st.session_state.pending_file_mime # ── Validate size (5 MB limit) ──────────────────────────────── MAX_FILE_SIZE = settings.max_upload_bytes if len(file_bytes) > MAX_FILE_SIZE: st.session_state.pending_file_bytes = None st.session_state.pending_file_name = None st.session_state.pending_file_mime = None st.session_state["last_uploaded_file"] = "" st.error(f"⚠️ File too large ({len(file_bytes)//1024} KB). Please upload under 5 MB.") st.stop() # ── Validate MIME type ──────────────────────────────────────── _allowed_mimes = {"image/jpeg", "image/png", "image/webp", "application/pdf"} if file_mime not in _allowed_mimes: st.session_state.pending_file_bytes = None st.session_state.pending_file_name = None st.session_state.pending_file_mime = None st.session_state["last_uploaded_file"] = "" st.error("⚠️ Unsupported format. Please upload JPG, PNG, WEBP or PDF.") st.stop() # Clear pending (one-time) but keep in attached memory for follow-ups st.session_state.pending_file_bytes = None st.session_state.pending_file_name = None st.session_state.pending_file_mime = None st.session_state.attached_file_bytes = file_bytes st.session_state.attached_file_name = file_name st.session_state.attached_file_mime = file_mime with st.chat_message("user", avatar="🧑‍🎓"): st.markdown(f"📎 **{file_name}** — {problem}") # Show file preview so user can see what was attached if file_mime and file_mime.startswith("image/"): st.image(file_bytes, caption=file_name, use_column_width=True) else: # PDF — try to show first page try: import fitz, io doc = fitz.open(stream=file_bytes, filetype="pdf") pix = doc[0].get_pixmap(matrix=fitz.Matrix(1.5, 1.5)) doc.close() st.image(pix.tobytes("png"), caption=f"📄 {file_name} (page 1 preview)", use_column_width=True) except Exception: st.caption(f"📄 {file_name}") with st.chat_message("assistant", avatar="📐"): with st.spinner("📖 Reading your file..."): answer = handle_uploaded_file( _MemoryUpload(file_bytes, file_name, file_mime), problem, ) st.markdown(answer) with st.expander("📋 Copy"): st.code(answer, language=None) if not st.session_state.current_chat_id: new_chat() st.session_state.messages.append({ "role": "user", "content": f"📎 {file_name} — {problem}" }) st.session_state.messages.append({ "role": "assistant", "content": answer, "verified": "SymPy Verified" in answer, }) save_current_chat() st.stop() # ── Follow-up about previously attached file ────────────────────── # Detects "solve q3", "next question", "question 2" etc. and re-sends the file _p = problem.lower() _followup_triggers = [ "question", "solve q", "q1","q2","q3","q4","q5","q6","q7","q8","q9","q10", "next one", "next question", "next qus", "next ques", "previous", "solve the next", "solve all", "solve rest", "number ", "no.", "no ", "#", "part ", "part(", "section", ] _is_file_followup = ( st.session_state.attached_file_bytes is not None and any(t in _p for t in _followup_triggers) ) if _is_file_followup: import base64 as _b64_fu with st.chat_message("user", avatar="🧑‍🎓"): st.markdown(f"📎 *{st.session_state.attached_file_name}* — {problem}") with st.chat_message("assistant", avatar="📐"): with st.spinner("📖 Re-reading your file..."): _fb64 = _b64_fu.b64encode(st.session_state.attached_file_bytes).decode("utf-8") answer = ask_gemini_vision(_fb64, st.session_state.attached_file_mime, problem) st.markdown(answer) with st.expander("📋 Copy"): st.code(answer, language=None) if not st.session_state.current_chat_id: new_chat() st.session_state.messages.append({"role": "user", "content": f"📎 {st.session_state.attached_file_name} — {problem}"}) st.session_state.messages.append({ "role": "assistant", "content": answer, "verified": "SymPy Verified" in answer, }) save_current_chat() st.stop() # ── Detect casual / non-math messages ─────────────────────────── p_lower = problem.lower().strip() casual_keywords = [ "hi", "hello", "hey", "how are you", "how r u", "what's up", "whats up", "good morning", "good evening", "good night", "who are you", "what are you", "what can you do", "help", "thanks", "thank you", "bye", "goodbye", "ok", "okay", "what is your name", "your name", "who made you", "who built you", "how do you work", "what do you do", "sup", "hlo", "hlw", "what u doing", "what are you doing", "hows it going", ] is_casual = ( any(p_lower == kw for kw in casual_keywords) or any(p_lower.startswith(kw) for kw in casual_keywords) or (len(p_lower.split()) <= 4 and not any(c in p_lower for c in [ "=", "+", "-", "*", "/", "^", "∫", "∑", "√", "dx", "dy", "sin", "cos", "tan", "log", "lim", "diff", "solve", "find", "calculate", "compute", "prove", "matrix", "eigen", "gcd", "integral", "derivative", "equation", "theorem" ])) ) # Show user message immediately with st.chat_message("user", avatar="🧑‍🎓"): st.markdown(problem) # Show AI response with st.chat_message("assistant", avatar="📐"): if is_casual: # ── Casual message — no SymPy, no math structure ───────── casual_sympy = {"type": "casual", "result": None, "latex": ""} answer = ask_ai_streaming(problem, casual_sympy, st.session_state.messages) else: # ── Math message — full SymPy + structured response ─────── import random spinner_msgs = [ "🧮 Computing with SymPy...", "∫ Integrating the solution...", "∑ Summing it all up...", "📐 Applying the formula...", "🔢 Crunching the numbers...", "📊 Verifying the answer...", ] with st.spinner(random.choice(spinner_msgs)): sympy_result = run_sympy(problem) answer = ask_ai_streaming(problem, sympy_result, st.session_state.messages) plot_graph(problem, sympy_result) if is_casual: st.caption("AI response") elif sympy_result.get("result") and sympy_result.get("result") not in ("matrix_detected", "mod_detected"): if _has_ai_explanation(answer): st.caption("✓ AI explanation · SymPy verified computation") else: st.caption("✓ SymPy verified · AI explanation unavailable for this response") else: st.caption("AI-generated explanation — deterministic verification was unavailable for this request.") with st.expander("Solution tools"): st.download_button( "Download Markdown", data=answer, file_name="saad-ai-solution.md", mime="text/markdown", key="download_current_solution", ) st.code(answer, language=None) # Save to history if not st.session_state.current_chat_id: new_chat() # create chat ID BEFORE appending — avoids wiping messages st.session_state.messages.append({"role": "user", "content": problem}) st.session_state.messages.append({ "role": "assistant", "content": answer, "verified": bool( not is_casual and sympy_result.get("result") and sympy_result.get("result") not in ("matrix_detected", "mod_detected") ) if not is_casual else False, }) save_current_chat()