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()