diff --git a/chatbot.py b/chatbot.py
index 75b0659..d2d160d 100644
--- a/chatbot.py
+++ b/chatbot.py
@@ -1,32 +1,104 @@
import os
+import time
+import html
+from collections import defaultdict, deque
+
from flask import Flask, request, jsonify, render_template
from flask_cors import CORS
import google.generativeai as genai
-# Load API Key securely from environment variable
-# Never hardcode secrets in source code.
-# Set your key with: export GEMINI_API_KEY="your_key_here" (Linux/Mac)
-# set GEMINI_API_KEY=your_key_here (Windows)
+# -----------------------------
+# Security: API key via env var
+# -----------------------------
API_KEY = os.environ.get("GEMINI_API_KEY")
if not API_KEY:
raise EnvironmentError(
"GEMINI_API_KEY environment variable is not set. "
- "Please set it before running the application. "
- "See .env.example for guidance."
+ "Set it before running the server."
)
genai.configure(api_key=API_KEY)
+# -----------------------------
+# App setup
+# -----------------------------
app = Flask(__name__, template_folder="templates", static_folder="static")
CORS(app)
-
-def format_response(text):
- """Formats chatbot response for better readability."""
- # Safely convert markdown-like bold to HTML
- formatted_text = text.replace("**", "").replace("*", "").replace("\n", "
")
- return formatted_text.strip()
+# Create the model once at startup (cheaper than per request)
+MODEL_NAME = os.environ.get("GEMINI_MODEL", "gemini-1.5-pro")
+model = genai.GenerativeModel(MODEL_NAME)
+
+
+# -----------------------------
+# Input/output hardening
+# -----------------------------
+MAX_MESSAGE_CHARS = int(os.environ.get("CHAT_MAX_MESSAGE_CHARS", "2000"))
+
+# Simple refusal/guardrail keywords (heuristic)
+ABUSE_KEYWORDS = [
+ # Prompt injection patterns
+ "ignore previous",
+ "disregard",
+ "system prompt",
+ "developer prompt",
+ "jailbreak",
+ "act as",
+ "bypass",
+ "reveal your instructions",
+ # Harmful/illegal categories (basic)
+ "how to build a bomb",
+ "make a bomb",
+ "poison",
+ "suicide",
+ "self-harm",
+ "kill yourself",
+ "kill someone",
+ "murder",
+ "steal",
+ "credit card",
+ "creditcard",
+]
+
+
+def format_response(text: str) -> str:
+ """Return safe text for the frontend (no unsafe HTML)."""
+ if text is None:
+ return ""
+ # Escape HTML to prevent XSS; preserve line breaks.
+ return html.escape(text).replace("\n", "
").strip()
+
+
+def looks_like_abuse(user_text: str) -> bool:
+ t = (user_text or "").lower()
+ return any(k in t for k in ABUSE_KEYWORDS)
+
+
+# -----------------------------
+# Rate limiting (per IP)
+# In-memory sliding window.
+# -----------------------------
+RATE_LIMIT_WINDOW_SECONDS = int(os.environ.get("CHAT_RATE_LIMIT_WINDOW_SECONDS", "60"))
+RATE_LIMIT_COUNT = int(os.environ.get("CHAT_RATE_LIMIT_COUNT", "10"))
+
+# ip -> deque[timestamps]
+_requests = defaultdict(deque)
+
+
+def rate_limited(ip: str) -> bool:
+ now = time.time()
+ q = _requests[ip]
+
+ # Drop old timestamps
+ while q and (now - q[0]) > RATE_LIMIT_WINDOW_SECONDS:
+ q.popleft()
+
+ if len(q) >= RATE_LIMIT_COUNT:
+ return True
+
+ q.append(now)
+ return False
@app.route("/")
@@ -43,27 +115,70 @@ def chat():
user_input = data["message"]
- if not user_input or not user_input.strip():
+ if not isinstance(user_input, str):
+ return jsonify({"error": "Message must be a string"}), 400
+
+ user_input = user_input.strip()
+
+ if not user_input:
return jsonify({"error": "Message cannot be empty"}), 400
+ if len(user_input) > MAX_MESSAGE_CHARS:
+ return (
+ jsonify({"error": f"Message too large (max {MAX_MESSAGE_CHARS} characters)."}),
+ 413,
+ )
+
+ # Rate limit per IP
+ ip = request.headers.get("X-Forwarded-For", request.remote_addr) or "unknown"
+ ip = ip.split(",")[0].strip() # handle proxies lists
+
+ if rate_limited(ip):
+ return jsonify({"error": "Too many requests. Please try again later."}), 429
+
+ # Basic abuse prevention / guardrails
+ if looks_like_abuse(user_input):
+ reply = (
+ "I can’t help with that request. "
+ "If you need help with a physics, maths, chemistry, or biology topic, tell me what concept you’re working on."
+ )
+ return jsonify({"reply": format_response(reply)})
+
try:
- model = genai.GenerativeModel("gemini-1.5-pro")
- response = model.generate_content(user_input)
+ # Keep the model constrained to educational tutoring.
+ system_guardrails = (
+ "You are an educational tutor for Physics, Maths, Chemistry, and Biology. "
+ "Be concise, step-by-step when useful, and avoid unsafe or illegal content. "
+ "If the user asks for something unrelated, politely guide them back to learning topics."
+ )
+
+ # Structured prompt (reduces injection impact)
+ prompt = f"{system_guardrails}\n\nUser message: {user_input}"
+
+ response = model.generate_content(prompt)
+
+ reply_text = ""
+ if response and getattr(response, "candidates", None):
+ cand0 = response.candidates[0] if response.candidates else None
+ parts = getattr(getattr(cand0, "content", None), "parts", None)
+ if parts and len(parts) > 0:
+ reply_text = getattr(parts[0], "text", None) or ""
- if response and response.candidates:
- reply = format_response(response.candidates[0].content.parts[0].text)
- else:
- reply = "I'm not sure how to respond to that. Can you try rephrasing?"
+ if not reply_text:
+ reply_text = "I’m not sure how to respond to that. Can you try rephrasing?"
+
+ return jsonify({"reply": format_response(reply_text)})
except Exception as e:
# Log internally but don't expose raw exception to the client
print(f"[ERROR] Chatbot error: {e}")
- return jsonify({"reply": "An error occurred while processing your request. Please try again later."}), 500
-
- return jsonify({"reply": reply})
+ return (
+ jsonify({"reply": "An error occurred while processing your request. Please try again later."}),
+ 500,
+ )
if __name__ == "__main__":
- # Disable debug mode in production
debug_mode = os.environ.get("FLASK_DEBUG", "false").lower() == "true"
app.run(debug=debug_mode)
+
diff --git a/my_progress.html b/my_progress.html
new file mode 100644
index 0000000..9e39e2a
--- /dev/null
+++ b/my_progress.html
@@ -0,0 +1,168 @@
+
+
+
+
+
+
+ My Progress - LearnSphere
+
+
+
+
+
+
+
+
+
+
My Progress
+
Track quiz performance, accuracy trends, and daily streak.
+
+
+
+
Current Streak
+
—
+
—
+
+
+
Overall Accuracy
+
—
+
—
+
+
+
+
+
+
Accuracy over time
+
+
+ Based on the last 14 days of quiz attempts.
+
+
+
+
+
Recommended next
+
+
+ Recommendations focus on weaker or less-attempted topics.
+
+
+
+
+
+
Topic-wise performance
+
+
+ Shows accuracy and attempts per topic.
+
+
+
+
+
+
+
+
+
+
diff --git a/my_progress.js b/my_progress.js
new file mode 100644
index 0000000..6d6740b
--- /dev/null
+++ b/my_progress.js
@@ -0,0 +1,182 @@
+function pct(n) {
+ if (typeof n !== "number" || Number.isNaN(n)) return "—";
+ return `${Math.round(n * 100)}%`;
+}
+
+function formatAttempts(n) {
+ if (typeof n !== "number") return "0";
+ return String(n);
+}
+
+function drawLineChart(canvas, labels, accuracyByDay) {
+ const ctx = canvas.getContext("2d");
+ const w = canvas.width;
+ const h = canvas.height;
+
+ // Clear
+ ctx.clearRect(0, 0, w, h);
+
+ // Background grid
+ ctx.strokeStyle = "rgba(255,255,255,0.08)";
+ ctx.lineWidth = 1;
+ for (let i = 1; i <= 4; i++) {
+ const y = (h / 5) * i;
+ ctx.beginPath();
+ ctx.moveTo(0, y);
+ ctx.lineTo(w, y);
+ ctx.stroke();
+ }
+
+ // Build points (skip nulls)
+ const valid = accuracyByDay
+ .map((a, idx) => ({ a, idx }))
+ .filter(p => typeof p.a === "number");
+
+ if (valid.length < 2) {
+ ctx.fillStyle = "rgba(255,255,255,0.7)";
+ ctx.font = "14px Arial";
+ ctx.fillText("Complete at least 2 quiz attempts to see a trend.", 16, 28);
+ return;
+ }
+
+ const xStep = w / (labels.length - 1);
+ const toY = (acc) => {
+ // acc: 0..1
+ const marginTop = 16;
+ const marginBottom = 24;
+ const usable = h - marginTop - marginBottom;
+ return marginTop + (1 - acc) * usable;
+ };
+
+ // Line
+ ctx.strokeStyle = "#66fcf1";
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+
+ // Find first valid index
+ const firstIdx = valid[0].idx;
+ let started = false;
+ for (let i = 0; i < accuracyByDay.length; i++) {
+ const a = accuracyByDay[i];
+ if (typeof a !== "number") continue;
+ const x = i * xStep;
+ const y = toY(a);
+ if (!started) {
+ ctx.moveTo(x, y);
+ started = true;
+ } else {
+ ctx.lineTo(x, y);
+ }
+ }
+ ctx.stroke();
+
+ // Points
+ valid.forEach(({ a, idx }) => {
+ const x = idx * xStep;
+ const y = toY(a);
+ ctx.fillStyle = "#66fcf1";
+ ctx.beginPath();
+ ctx.arc(x, y, 4, 0, 2 * Math.PI);
+ ctx.fill();
+ });
+
+ // X labels (mm/dd)
+ ctx.fillStyle = "rgba(255,255,255,0.65)";
+ ctx.font = "12px Arial";
+ const stride = Math.max(1, Math.floor(labels.length / 6));
+ labels.forEach((lab, i) => {
+ if (i % stride !== 0 && i !== labels.length - 1) return;
+ ctx.fillText(lab, i * xStep - 10, h - 8);
+ });
+}
+
+function init() {
+ const streak = window.quizProgress.getStreak();
+ const streakValue = document.getElementById("streakValue");
+ const streakMeta = document.getElementById("streakMeta");
+
+ if (streakValue) streakValue.textContent = String(streak.currentStreak || 0);
+ if (streakMeta) {
+ const last = streak.lastPracticeDate;
+ streakMeta.textContent = last ? `Last practice: ${last}` : "No practice yet.";
+ }
+
+ const overall = window.quizProgress.getOverallAccuracy();
+ const overallAccuracyValue = document.getElementById("overallAccuracyValue");
+ const overallAccuracyMeta = document.getElementById("overallAccuracyMeta");
+
+ if (overallAccuracyValue) overallAccuracyValue.textContent = overall.accuracy == null ? "—" : pct(overall.accuracy);
+ if (overallAccuracyMeta) {
+ overallAccuracyMeta.textContent = overall.total > 0 ? `${overall.correct} correct out of ${overall.total} answers` : "Complete a quiz to populate your stats.";
+ }
+
+ // Chart
+ const series = window.quizProgress.getAccuracySeries({ days: 14 });
+ const canvas = document.getElementById("accuracyChart");
+ if (canvas) {
+ // Ensure canvas resolution matches layout size
+ // (Canvas width/height are attributes; default may be too small.)
+ const rect = canvas.getBoundingClientRect();
+ const dpr = window.devicePixelRatio || 1;
+ canvas.width = Math.floor(rect.width * dpr);
+ canvas.height = Math.floor(240 * dpr);
+
+ drawLineChart(canvas, series.labels, series.accuracyByDay);
+ }
+
+ // Topic stats
+ const topicStatsEl = document.getElementById("topicStats");
+ const byTopic = window.quizProgress.getAllTopicStats();
+
+ if (topicStatsEl) {
+ const topics = window.quizProgress.QUIZ_TOPICS;
+ const sorted = [...topics].sort((a, b) => {
+ const aAttempts = byTopic[a.id]?.attempts || 0;
+ const bAttempts = byTopic[b.id]?.attempts || 0;
+ return bAttempts - aAttempts;
+ });
+
+ topicStatsEl.innerHTML = "";
+
+ sorted.forEach(t => {
+ const agg = byTopic[t.id];
+ const attempts = agg?.attempts || 0;
+ const qTotal = agg?.questionsTotal || 0;
+ const correctTotal = agg?.correctTotal || 0;
+ const accuracy = qTotal > 0 ? correctTotal / qTotal : null;
+ const barW = accuracy == null ? 0 : Math.max(0, Math.min(100, Math.round(accuracy * 100)));
+
+ const row = document.createElement("div");
+ row.className = "topic-row";
+ row.innerHTML = `
+ ${t.label}
+
+
+
+
+
${accuracy == null ? "—" : pct(accuracy)}
+
${formatAttempts(attempts)} attempts
+
+ `;
+
+ topicStatsEl.appendChild(row);
+ });
+ }
+
+ // Recommendations
+ const recEl = document.getElementById("recommendedTopics");
+ if (recEl) {
+ const recs = window.quizProgress.getRecommendedTopics({ limit: 3 });
+ recEl.innerHTML = "";
+ recs.forEach(r => {
+ const chip = document.createElement("span");
+ chip.className = "recommend-chip";
+ const accText = r.accuracy == null ? "not attempted" : `accuracy ${pct(r.accuracy)}`;
+ chip.textContent = `${r.topic.label} • ${accText}`;
+ recEl.appendChild(chip);
+ });
+ }
+}
+
+document.addEventListener("DOMContentLoaded", init);
+
diff --git a/quiz/motionquiz.css b/quiz/motionquiz.css
index ca3f8bc..bfff603 100644
--- a/quiz/motionquiz.css
+++ b/quiz/motionquiz.css
@@ -8,6 +8,41 @@ body {
min-height: 100vh;
}
+.skip-link {
+ position: absolute;
+ top: -100px;
+ left: 0;
+ background: #00ffcc;
+ color: #000;
+ padding: 10px 14px;
+ z-index: 9999;
+ border-radius: 8px;
+ text-decoration: none;
+}
+
+.skip-link:focus-visible {
+ top: 10px;
+ outline: 2px solid #00ffcc;
+ outline-offset: 3px;
+}
+
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+:focus-visible {
+ outline: 3px solid #00ffcc;
+ outline-offset: 3px;
+}
+
.container {
max-width: 900px;
margin: auto;
@@ -75,6 +110,11 @@ body {
border: none;
}
+.option:focus-visible {
+ outline: 3px solid #00ffcc;
+ outline-offset: 3px;
+}
+
.option:hover {
background: #0056b3;
transform: scale(1.05);
@@ -103,6 +143,11 @@ button {
transition: 0.3s;
}
+button:focus-visible {
+ outline: 3px solid #00ffcc;
+ outline-offset: 3px;
+}
+
button:disabled {
background: #444;
cursor: not-allowed;
@@ -134,6 +179,10 @@ button:hover:enabled {
z-index: 1000;
}
+.popup:focus-visible {
+ outline: 3px solid #00ffcc;
+ outline-offset: 3px;
+}
.correct {
color: green;
@@ -145,25 +194,6 @@ button:hover:enabled {
font-weight: bold;
}
-.option {
- display: block;
- width: 100%;
- padding: 10px;
- margin: 5px 0;
- font-size: 16px;
- cursor: pointer;
- border: 2px solid #ccc;
- border-radius: 5px;
- background-color: blue;
- transition: all 0.3s ease;
-}
-
-.option.selected {
- background-color: green;
- color: white;
- border-color: #007bff;
-}
-
.correct-answer {
color: green;
font-weight: bold;
@@ -184,6 +214,23 @@ button:hover:enabled {
color: white;
}
-.hidden {
- display: none;
+@media (max-width: 560px) {
+ .options-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .button-group {
+ flex-direction: column;
+ align-items: stretch;
+ }
}
+
+@media (prefers-reduced-motion: reduce) {
+ * {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ scroll-behavior: auto !important;
+ }
+}
+
diff --git a/quiz/motionquiz.html b/quiz/motionquiz.html
index a57ac76..503cbfb 100644
--- a/quiz/motionquiz.html
+++ b/quiz/motionquiz.html
@@ -10,7 +10,9 @@
-
+
Skip to content
+
+
Physics Motion Quiz 🎯
Test your knowledge of motion concepts!
@@ -19,37 +21,48 @@
Physics Motion Quiz 🎯
-
+
Loading...
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-