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 @@ -
+ + +

Physics Motion Quiz 🎯

Test your knowledge of motion concepts!

@@ -19,37 +21,48 @@

Physics Motion Quiz 🎯

-
+

Loading...

-
- - - -
+
+ +
+ + + +
+ +

+ -