Spaces:
Sleeping
Sleeping
Upload 25 files
Browse files- .gitignore +0 -0
- app.py +29 -0
- config.py +17 -0
- models.py +55 -0
- requirements.txt +8 -0
- routes/__pycache__/auth.cpython-310.pyc +0 -0
- routes/__pycache__/test_routes.cpython-310.pyc +0 -0
- routes/auth.py +51 -0
- routes/test_routes.py +86 -0
- seed_test.py +11 -0
- services/__pycache__/email_service.cpython-310.pyc +0 -0
- services/__pycache__/evaluation.cpython-310.pyc +0 -0
- services/__pycache__/feedback_agent.cpython-310.pyc +0 -0
- services/__pycache__/huggingface_api.cpython-310.pyc +0 -0
- services/email_service.py +33 -0
- services/evaluation.py +99 -0
- services/feedback_agent.py +225 -0
- services/huggingface_api.py +118 -0
- static/style.css +6 -0
- templates/base.html +42 -0
- templates/dashboard.html +22 -0
- templates/home.html +22 -0
- templates/login.html +25 -0
- templates/signup.html +32 -0
- templates/test_question.html +27 -0
.gitignore
ADDED
|
Binary file (28 Bytes). View file
|
|
|
app.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Flask, render_template
|
| 2 |
+
import config
|
| 3 |
+
from extensions import mongo, mail
|
| 4 |
+
|
| 5 |
+
app = Flask(__name__)
|
| 6 |
+
app.config.from_object(config)
|
| 7 |
+
|
| 8 |
+
# Initialize extensions
|
| 9 |
+
mongo.init_app(app)
|
| 10 |
+
mail.init_app(app)
|
| 11 |
+
|
| 12 |
+
# Import blueprints after extensions
|
| 13 |
+
from routes.auth import auth_bp
|
| 14 |
+
from routes.test_routes import test_bp
|
| 15 |
+
|
| 16 |
+
app.register_blueprint(auth_bp)
|
| 17 |
+
app.register_blueprint(test_bp)
|
| 18 |
+
|
| 19 |
+
from services.feedback_agent import build_feedback_agent
|
| 20 |
+
|
| 21 |
+
app.feedback_agent = build_feedback_agent()
|
| 22 |
+
|
| 23 |
+
@app.route("/")
|
| 24 |
+
def home():
|
| 25 |
+
tests = list(mongo.db.tests.find({}, {"_id": 0}))
|
| 26 |
+
return render_template("home.html", tests=tests)
|
| 27 |
+
|
| 28 |
+
if __name__ == "__main__":
|
| 29 |
+
app.run(debug=True)
|
config.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from dotenv import load_dotenv
|
| 3 |
+
load_dotenv()
|
| 4 |
+
|
| 5 |
+
SECRET_KEY = os.getenv("SECRET_KEY", "secret123")
|
| 6 |
+
MONGO_URI = os.getenv("MONGO_URI", "mongodb://localhost:27017/assessment_db")
|
| 7 |
+
|
| 8 |
+
# Hugging Face
|
| 9 |
+
HF_API_KEY = os.getenv("HF_API_KEY", "")
|
| 10 |
+
|
| 11 |
+
# Email
|
| 12 |
+
MAIL_SERVER = os.getenv("MAIL_SERVER", "smtp.gmail.com")
|
| 13 |
+
MAIL_PORT = int(os.getenv("MAIL_PORT", 587))
|
| 14 |
+
MAIL_USE_TLS = True
|
| 15 |
+
MAIL_USERNAME = os.getenv("MAIL_USERNAME")
|
| 16 |
+
MAIL_PASSWORD = os.getenv("MAIL_PASSWORD")
|
| 17 |
+
MAIL_DEFAULT_SENDER = os.getenv("MAIL_DEFAULT_SENDER", MAIL_USERNAME)
|
models.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# models.py
|
| 2 |
+
from extensions import mongo
|
| 3 |
+
import datetime
|
| 4 |
+
|
| 5 |
+
def users_col():
|
| 6 |
+
return mongo.db.users
|
| 7 |
+
|
| 8 |
+
def tests_col():
|
| 9 |
+
return mongo.db.tests
|
| 10 |
+
|
| 11 |
+
def responses_col():
|
| 12 |
+
return mongo.db.responses
|
| 13 |
+
|
| 14 |
+
def results_col():
|
| 15 |
+
return mongo.db.results
|
| 16 |
+
|
| 17 |
+
def create_user(name, email, password_hash):
|
| 18 |
+
users_col().insert_one({
|
| 19 |
+
"name": name,
|
| 20 |
+
"email": email,
|
| 21 |
+
"password": password_hash,
|
| 22 |
+
"created_at": datetime.datetime.utcnow()
|
| 23 |
+
})
|
| 24 |
+
|
| 25 |
+
def find_user_by_email(email):
|
| 26 |
+
return users_col().find_one({"email": email})
|
| 27 |
+
|
| 28 |
+
def add_test(test_obj):
|
| 29 |
+
tests_col().insert_one(test_obj)
|
| 30 |
+
|
| 31 |
+
def get_test_by_id(test_id):
|
| 32 |
+
return tests_col().find_one({"id": test_id}, {"_id": 0})
|
| 33 |
+
|
| 34 |
+
def store_response(email, test_id, question_id, question_text, student_answer, score):
|
| 35 |
+
responses_col().insert_one({
|
| 36 |
+
"email": email,
|
| 37 |
+
"test_id": test_id,
|
| 38 |
+
"question_id": question_id,
|
| 39 |
+
"question_text": question_text,
|
| 40 |
+
"student_answer": student_answer,
|
| 41 |
+
"score": score,
|
| 42 |
+
"timestamp": datetime.datetime.utcnow()
|
| 43 |
+
})
|
| 44 |
+
|
| 45 |
+
def store_result(email, test_id, total_score, per_question_scores):
|
| 46 |
+
results_col().insert_one({
|
| 47 |
+
"email": email,
|
| 48 |
+
"test_id": test_id,
|
| 49 |
+
"total_score": total_score,
|
| 50 |
+
"per_question_scores": per_question_scores,
|
| 51 |
+
"timestamp": datetime.datetime.utcnow()
|
| 52 |
+
})
|
| 53 |
+
|
| 54 |
+
def get_user_results(email):
|
| 55 |
+
return list(results_col().find({"email": email}, {"_id": 0}))
|
requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Flask==2.2.5
|
| 2 |
+
Flask-PyMongo==2.3.0
|
| 3 |
+
Flask-Mail==0.9.1
|
| 4 |
+
python-dotenv==1.0.0
|
| 5 |
+
requests==2.31.0
|
| 6 |
+
pymongo[srv]==4.4.0
|
| 7 |
+
numpy==1.26.0
|
| 8 |
+
gunicorn==21.2.0
|
routes/__pycache__/auth.cpython-310.pyc
ADDED
|
Binary file (1.93 kB). View file
|
|
|
routes/__pycache__/test_routes.cpython-310.pyc
ADDED
|
Binary file (2.4 kB). View file
|
|
|
routes/auth.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Blueprint, render_template, request, redirect, url_for, session, flash, current_app
|
| 2 |
+
from werkzeug.security import generate_password_hash, check_password_hash
|
| 3 |
+
import models
|
| 4 |
+
|
| 5 |
+
auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
|
| 6 |
+
|
| 7 |
+
@auth_bp.route("/signup", methods=["GET","POST"])
|
| 8 |
+
def signup():
|
| 9 |
+
if request.method == "POST":
|
| 10 |
+
name = request.form["name"].strip()
|
| 11 |
+
email = request.form["email"].strip().lower()
|
| 12 |
+
password = request.form["password"]
|
| 13 |
+
if models.find_user_by_email(email):
|
| 14 |
+
flash("Email already registered. Please login.", "warning")
|
| 15 |
+
return redirect(url_for("auth.login"))
|
| 16 |
+
hashed = generate_password_hash(password)
|
| 17 |
+
models.create_user(name, email, hashed)
|
| 18 |
+
flash("Signup successful. Please login.", "success")
|
| 19 |
+
return redirect(url_for("auth.login"))
|
| 20 |
+
return render_template("signup.html")
|
| 21 |
+
|
| 22 |
+
@auth_bp.route("/login", methods=["GET","POST"])
|
| 23 |
+
def login():
|
| 24 |
+
if request.method == "POST":
|
| 25 |
+
email = request.form["email"].strip().lower()
|
| 26 |
+
password = request.form["password"]
|
| 27 |
+
user = models.find_user_by_email(email)
|
| 28 |
+
if not user or not check_password_hash(user["password"], password):
|
| 29 |
+
flash("Invalid credentials", "danger")
|
| 30 |
+
return redirect(url_for("auth.login"))
|
| 31 |
+
|
| 32 |
+
# Set session
|
| 33 |
+
session["user"] = {"name": user["name"], "email": user["email"]}
|
| 34 |
+
|
| 35 |
+
# Redirect to the first available test
|
| 36 |
+
first_test = list(models.tests_col().find({}, {"id": 1}, limit=1))
|
| 37 |
+
if first_test:
|
| 38 |
+
test_id = first_test[0]["id"]
|
| 39 |
+
return redirect(url_for("test.start", test_id=test_id))
|
| 40 |
+
else:
|
| 41 |
+
flash("No tests available", "warning")
|
| 42 |
+
return redirect(url_for("home"))
|
| 43 |
+
|
| 44 |
+
return render_template("login.html")
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@auth_bp.route("/logout")
|
| 48 |
+
def logout():
|
| 49 |
+
session.pop("user", None)
|
| 50 |
+
flash("Logged out", "info")
|
| 51 |
+
return redirect(url_for("home"))
|
routes/test_routes.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Blueprint, render_template, request, redirect, url_for, session, flash, current_app
|
| 2 |
+
import models
|
| 3 |
+
|
| 4 |
+
test_bp = Blueprint("test", __name__, url_prefix="/test")
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
@test_bp.before_request
|
| 8 |
+
def require_login():
|
| 9 |
+
# allow access to login/signup endpoints
|
| 10 |
+
if 'user' not in session and request.endpoint not in ("auth.login", "auth.signup", "home"):
|
| 11 |
+
# allow accessing home page / static etc
|
| 12 |
+
allowed = ["home", "auth.login", "auth.signup", "static"]
|
| 13 |
+
# simple guard: allow home and auth pages without login
|
| 14 |
+
if request.endpoint and not request.endpoint.startswith("auth"):
|
| 15 |
+
return redirect(url_for("auth.login"))
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@test_bp.route("/start/<test_id>", methods=["GET"])
|
| 19 |
+
def start(test_id):
|
| 20 |
+
# clear any previous answers in session and load first question
|
| 21 |
+
test = models.get_test_by_id(test_id)
|
| 22 |
+
if not test:
|
| 23 |
+
flash("Test not found", "danger")
|
| 24 |
+
return redirect(url_for("home"))
|
| 25 |
+
session[f"answers_{test_id}"] = {}
|
| 26 |
+
session[f"current_q_{test_id}"] = 0
|
| 27 |
+
return redirect(url_for("test.question", test_id=test_id))
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@test_bp.route("/question/<test_id>", methods=["GET", "POST"])
|
| 31 |
+
def question(test_id):
|
| 32 |
+
test = models.get_test_by_id(test_id)
|
| 33 |
+
if not test:
|
| 34 |
+
flash("Test not found", "danger")
|
| 35 |
+
return redirect(url_for("home"))
|
| 36 |
+
questions = test.get("questions", [])
|
| 37 |
+
current_index = session.get(f"current_q_{test_id}", 0)
|
| 38 |
+
answers = session.get(f"answers_{test_id}", {})
|
| 39 |
+
|
| 40 |
+
if request.method == "POST":
|
| 41 |
+
qid = request.form.get("qid")
|
| 42 |
+
answer_text = request.form.get("answer", "").strip()
|
| 43 |
+
# save answer in session
|
| 44 |
+
answers[qid] = answer_text
|
| 45 |
+
session[f"answers_{test_id}"] = answers
|
| 46 |
+
# move next
|
| 47 |
+
current_index += 1
|
| 48 |
+
session[f"current_q_{test_id}"] = current_index
|
| 49 |
+
|
| 50 |
+
# If finished
|
| 51 |
+
if current_index >= len(questions):
|
| 52 |
+
# All answers collected; run agent to evaluate and email
|
| 53 |
+
user = session.get("user")
|
| 54 |
+
if not user:
|
| 55 |
+
flash("Please login to submit test", "danger")
|
| 56 |
+
return redirect(url_for("auth.login"))
|
| 57 |
+
|
| 58 |
+
# Use LangGraph feedback agent instead of run_feedback_agent
|
| 59 |
+
result = current_app.feedback_agent.invoke({
|
| 60 |
+
"student_name": user["name"],
|
| 61 |
+
"student_email": user["email"],
|
| 62 |
+
"test_id": test_id,
|
| 63 |
+
"answers_map": answers,
|
| 64 |
+
})
|
| 65 |
+
|
| 66 |
+
flash(f"Test submitted. Overall Score: {result['overall']}", "success")
|
| 67 |
+
return redirect(url_for("test.dashboard"))
|
| 68 |
+
|
| 69 |
+
# render current question
|
| 70 |
+
q = questions[current_index]
|
| 71 |
+
return render_template(
|
| 72 |
+
"test_question.html",
|
| 73 |
+
question=q,
|
| 74 |
+
index=current_index + 1,
|
| 75 |
+
total=len(questions),
|
| 76 |
+
test_id=test_id,
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@test_bp.route("/dashboard")
|
| 81 |
+
def dashboard():
|
| 82 |
+
user = session.get("user")
|
| 83 |
+
if not user:
|
| 84 |
+
return redirect(url_for("auth.login"))
|
| 85 |
+
results = models.get_user_results(user["email"])
|
| 86 |
+
return render_template("dashboard.html", results=results, user=user)
|
seed_test.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app import app, mongo
|
| 2 |
+
|
| 3 |
+
with app.app_context():
|
| 4 |
+
try:
|
| 5 |
+
db = mongo.db
|
| 6 |
+
# List collections to verify connection
|
| 7 |
+
collections = db.list_collection_names()
|
| 8 |
+
print("✅ MongoDB connected successfully!")
|
| 9 |
+
print("Existing collections:", collections)
|
| 10 |
+
except Exception as e:
|
| 11 |
+
print("❌ MongoDB connection failed:", str(e))
|
services/__pycache__/email_service.cpython-310.pyc
ADDED
|
Binary file (953 Bytes). View file
|
|
|
services/__pycache__/evaluation.cpython-310.pyc
ADDED
|
Binary file (2.89 kB). View file
|
|
|
services/__pycache__/feedback_agent.cpython-310.pyc
ADDED
|
Binary file (6.37 kB). View file
|
|
|
services/__pycache__/huggingface_api.cpython-310.pyc
ADDED
|
Binary file (4.09 kB). View file
|
|
|
services/email_service.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import current_app
|
| 2 |
+
from flask_mail import Message
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
+
logger = logging.getLogger(__name__)
|
| 6 |
+
|
| 7 |
+
# CHANGE: Added optional html_content parameter
|
| 8 |
+
def send_email(to_email, subject, body, html_content=None):
|
| 9 |
+
"""
|
| 10 |
+
Sends an email using Flask-Mail, supporting both plain text (body)
|
| 11 |
+
and optional HTML content (html_content).
|
| 12 |
+
"""
|
| 13 |
+
try:
|
| 14 |
+
# Get the Mail instance and default sender from the Flask application context
|
| 15 |
+
mail = current_app.extensions.get('mail')
|
| 16 |
+
sender = current_app.config.get("MAIL_DEFAULT_SENDER")
|
| 17 |
+
|
| 18 |
+
# CHANGE: Pass html_content to the 'html' parameter of Message
|
| 19 |
+
# The 'body' argument remains the plain text fallback.
|
| 20 |
+
msg = Message(
|
| 21 |
+
subject=subject,
|
| 22 |
+
recipients=[to_email],
|
| 23 |
+
body=body,
|
| 24 |
+
html=html_content, # NEW: Pass the HTML content here
|
| 25 |
+
sender=sender
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
mail.send(msg)
|
| 29 |
+
logger.info(f"Email sent to {to_email}")
|
| 30 |
+
return True
|
| 31 |
+
except Exception as e:
|
| 32 |
+
logger.exception("Failed to send email")
|
| 33 |
+
return False
|
services/evaluation.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import os
|
| 3 |
+
import nltk
|
| 4 |
+
import torch
|
| 5 |
+
from nltk.corpus import stopwords
|
| 6 |
+
from nltk.stem import WordNetLemmatizer
|
| 7 |
+
from nltk.tokenize import word_tokenize
|
| 8 |
+
from sentence_transformers import SentenceTransformer, util
|
| 9 |
+
from transformers import AutoTokenizer, AutoModelForSequenceClassification
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
# Download necessary NLTK resources
|
| 14 |
+
nltk.download('punkt')
|
| 15 |
+
nltk.download('stopwords')
|
| 16 |
+
nltk.download('wordnet')
|
| 17 |
+
|
| 18 |
+
# Initialize NLP tools
|
| 19 |
+
lemmatizer = WordNetLemmatizer()
|
| 20 |
+
stop_words = set(stopwords.words("english"))
|
| 21 |
+
negation_words = {"not", "never", "no", "none", "cannot", "n't"}
|
| 22 |
+
|
| 23 |
+
# SBERT Model for Similarity
|
| 24 |
+
sbert_model = SentenceTransformer("all-MiniLM-L6-v2")
|
| 25 |
+
|
| 26 |
+
# Cross-Encoder for Contextual Understanding
|
| 27 |
+
cross_encoder_model = AutoModelForSequenceClassification.from_pretrained("cross-encoder/stsb-roberta-large")
|
| 28 |
+
cross_encoder_tokenizer = AutoTokenizer.from_pretrained("cross-encoder/stsb-roberta-large")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# -------------------------------
|
| 32 |
+
# Preprocessing & Negation
|
| 33 |
+
# -------------------------------
|
| 34 |
+
def preprocess_text(text: str):
|
| 35 |
+
tokens = word_tokenize(text.lower()) # Lowercase & tokenize
|
| 36 |
+
tokens = [lemmatizer.lemmatize(word) for word in tokens if word not in stop_words] # Remove stopwords & lemmatize
|
| 37 |
+
return " ".join(tokens)
|
| 38 |
+
|
| 39 |
+
def contains_negation(text: str):
|
| 40 |
+
tokens = set(word_tokenize(text.lower()))
|
| 41 |
+
return any(word in negation_words for word in tokens)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# -------------------------------
|
| 45 |
+
# Evaluation Function
|
| 46 |
+
# -------------------------------
|
| 47 |
+
def evaluate_answer(student_ans: str, teacher_ans: str):
|
| 48 |
+
"""
|
| 49 |
+
Returns score (0-100), and a dict breakdown.
|
| 50 |
+
Uses pre-loaded SBERT and Cross-Encoder models internally.
|
| 51 |
+
"""
|
| 52 |
+
student = (student_ans or "").strip()
|
| 53 |
+
teacher = (teacher_ans or "").strip()
|
| 54 |
+
if not student:
|
| 55 |
+
return 0.0, {"reason": "Empty answer"}
|
| 56 |
+
|
| 57 |
+
try:
|
| 58 |
+
# Preprocess answers
|
| 59 |
+
student_clean = preprocess_text(student)
|
| 60 |
+
teacher_clean = preprocess_text(teacher)
|
| 61 |
+
|
| 62 |
+
# SBERT similarity
|
| 63 |
+
emb_student = sbert_model.encode(student_clean, convert_to_tensor=True)
|
| 64 |
+
emb_teacher = sbert_model.encode(teacher_clean, convert_to_tensor=True)
|
| 65 |
+
sbert_score = util.pytorch_cos_sim(emb_student, emb_teacher).item() # 0..1
|
| 66 |
+
|
| 67 |
+
# Cross-Encoder score
|
| 68 |
+
inputs = cross_encoder_tokenizer(student_clean, teacher_clean, return_tensors="pt", truncation=True)
|
| 69 |
+
with torch.no_grad():
|
| 70 |
+
logits = cross_encoder_model(**inputs).logits
|
| 71 |
+
cross_score = torch.sigmoid(logits).item() # 0..1
|
| 72 |
+
|
| 73 |
+
# Negation handling
|
| 74 |
+
student_neg = contains_negation(student)
|
| 75 |
+
teacher_neg = contains_negation(teacher)
|
| 76 |
+
if student_neg != teacher_neg:
|
| 77 |
+
sbert_score *= 0.5
|
| 78 |
+
cross_score *= 0.5
|
| 79 |
+
negation_penalty = 0.5
|
| 80 |
+
else:
|
| 81 |
+
negation_penalty = 0.0
|
| 82 |
+
|
| 83 |
+
# Weighted final score
|
| 84 |
+
final = 0.4 * sbert_score + 0.6 * cross_score
|
| 85 |
+
final = final * (1.0 - negation_penalty)
|
| 86 |
+
final_pct = round(final * 100, 2)
|
| 87 |
+
|
| 88 |
+
breakdown = {
|
| 89 |
+
"sbert_score": round(sbert_score * 100, 2),
|
| 90 |
+
"cross_score": round(cross_score * 100, 2),
|
| 91 |
+
"negation_penalty": negation_penalty,
|
| 92 |
+
"final_pct": final_pct
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
return final_pct, breakdown
|
| 96 |
+
|
| 97 |
+
except Exception as e:
|
| 98 |
+
logger.exception("Evaluation failed")
|
| 99 |
+
return 0.0, {"error": str(e)}
|
services/feedback_agent.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# services/feedback_agent.py
|
| 2 |
+
|
| 3 |
+
from langgraph.graph import StateGraph, END
|
| 4 |
+
from typing import Dict, Any, List
|
| 5 |
+
import models
|
| 6 |
+
from services.evaluation import evaluate_answer
|
| 7 |
+
# NOTE: You MUST update services/email_service.py to accept and use the html_content argument
|
| 8 |
+
from services.email_service import send_email
|
| 9 |
+
from services.huggingface_api import generate_feedback
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
# ----------------------
|
| 13 |
+
# Define State (Updated)
|
| 14 |
+
# ----------------------
|
| 15 |
+
class AgentState(dict):
|
| 16 |
+
student_name: str
|
| 17 |
+
student_email: str
|
| 18 |
+
test_id: str
|
| 19 |
+
answers_map: Dict[str, str]
|
| 20 |
+
per_question_scores: List[Dict[str, Any]]
|
| 21 |
+
overall: float
|
| 22 |
+
email_body: str
|
| 23 |
+
html_email_body: str # NEW: Field for the HTML content
|
| 24 |
+
test: Dict[str, Any]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# ----------------------
|
| 28 |
+
# Agent Nodes
|
| 29 |
+
# ----------------------
|
| 30 |
+
def fetch_test(state: AgentState) -> AgentState:
|
| 31 |
+
"""Fetch test data from DB."""
|
| 32 |
+
test = models.get_test_by_id(state["test_id"])
|
| 33 |
+
if not test:
|
| 34 |
+
raise ValueError("Test not found")
|
| 35 |
+
state["test"] = test
|
| 36 |
+
return state
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def evaluate_answers(state: AgentState) -> AgentState:
|
| 40 |
+
"""Evaluate answers using evaluator + llama feedback and generate HTML body."""
|
| 41 |
+
test = state["test"]
|
| 42 |
+
student_email = state["student_email"]
|
| 43 |
+
test_id = state["test_id"]
|
| 44 |
+
answers_map = state["answers_map"]
|
| 45 |
+
|
| 46 |
+
questions = test.get("questions", [])
|
| 47 |
+
per_question_scores = []
|
| 48 |
+
|
| 49 |
+
# OLD: email_parts will remain for the plain text version
|
| 50 |
+
email_parts = []
|
| 51 |
+
# NEW: List to collect HTML snippets for each question
|
| 52 |
+
question_html_parts = []
|
| 53 |
+
total = 0.0
|
| 54 |
+
|
| 55 |
+
# CHANGE: Use enumerate to get the question number
|
| 56 |
+
for idx, q in enumerate(questions):
|
| 57 |
+
q_num = idx + 1 # Calculate the question number
|
| 58 |
+
qid = q["id"]
|
| 59 |
+
qtext = q["text"]
|
| 60 |
+
ideal = q.get("ideal_answer", "")
|
| 61 |
+
student_ans = answers_map.get(qid, "")
|
| 62 |
+
|
| 63 |
+
# Step 1: Rule-based or ML evaluation
|
| 64 |
+
score, breakdown = evaluate_answer(student_ans, ideal)
|
| 65 |
+
|
| 66 |
+
# Step 2: Store raw response
|
| 67 |
+
models.store_response(student_email, test_id, qid, qtext, student_ans, score)
|
| 68 |
+
|
| 69 |
+
# Step 3: Generate AI feedback via LLaMA (Ensure services/huggingface_api.py is FIXED!)
|
| 70 |
+
feedback_text = generate_feedback(qtext, student_ans, score)
|
| 71 |
+
|
| 72 |
+
# Prepare feedback for HTML (replace newlines with <br/>)
|
| 73 |
+
html_feedback = feedback_text.replace('\n', '<br/>')
|
| 74 |
+
|
| 75 |
+
per_question_scores.append({
|
| 76 |
+
"question_id": qid,
|
| 77 |
+
"question_text": qtext,
|
| 78 |
+
"student_answer": student_ans,
|
| 79 |
+
"score": score,
|
| 80 |
+
"breakdown": breakdown,
|
| 81 |
+
"feedback": feedback_text,
|
| 82 |
+
})
|
| 83 |
+
|
| 84 |
+
# Plain Text Part (Updated to include question number)
|
| 85 |
+
email_parts.append(
|
| 86 |
+
f"Q{q_num}: {qtext}\n"
|
| 87 |
+
f"A: {student_ans}\n"
|
| 88 |
+
f"Score: {score}/100\n"
|
| 89 |
+
f"Feedback: {feedback_text}\n\n"
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
# NEW: Generate the HTML snippet for the current question
|
| 93 |
+
question_html_parts.append(
|
| 94 |
+
f"""
|
| 95 |
+
<div class="question-block">
|
| 96 |
+
<p style="font-size: 1.1em; font-weight: 600;">Q{q_num}: {qtext}</p>
|
| 97 |
+
<p style="margin-left: 10px;"><strong>Your Answer:</strong> {student_ans}</p>
|
| 98 |
+
<p style="margin-left: 10px;"><strong>Score:</strong> <span style="color: #007bff; font-weight: bold;">{score}/100</span></p>
|
| 99 |
+
<p style="margin-top: 10px;"><strong>Feedback:</strong></p>
|
| 100 |
+
<div class="feedback-box">
|
| 101 |
+
{html_feedback}
|
| 102 |
+
</div>
|
| 103 |
+
</div>
|
| 104 |
+
"""
|
| 105 |
+
)
|
| 106 |
+
total += score
|
| 107 |
+
|
| 108 |
+
# Compute overall
|
| 109 |
+
overall = round(total / max(1, len(questions)), 2)
|
| 110 |
+
models.store_result(student_email, test_id, overall, per_question_scores)
|
| 111 |
+
|
| 112 |
+
# Save results in state
|
| 113 |
+
state["per_question_scores"] = per_question_scores
|
| 114 |
+
state["overall"] = overall
|
| 115 |
+
|
| 116 |
+
# Variables for templating
|
| 117 |
+
TEST_TITLE = test.get('title', 'Assessment')
|
| 118 |
+
STUDENT_NAME = state['student_name']
|
| 119 |
+
OVERALL_SCORE = str(overall)
|
| 120 |
+
QUESTION_FEEDBACK_HTML = "".join(question_html_parts)
|
| 121 |
+
|
| 122 |
+
# Construct the Plain Text Body
|
| 123 |
+
state["email_body"] = (
|
| 124 |
+
f"Dear {STUDENT_NAME},\n\n"
|
| 125 |
+
f"Here is your assessment feedback for test: {TEST_TITLE}\n\n"
|
| 126 |
+
+ "".join(email_parts)
|
| 127 |
+
+ f"\nOverall Score: {overall}/100\n\nBest regards,\nAssessment Team"
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
# NEW: Construct the HTML Body
|
| 131 |
+
state["html_email_body"] = HTML_EMAIL_TEMPLATE.replace(
|
| 132 |
+
"{{ STUDENT_NAME }}", STUDENT_NAME
|
| 133 |
+
).replace(
|
| 134 |
+
"{{ TEST_TITLE }}", TEST_TITLE
|
| 135 |
+
).replace(
|
| 136 |
+
"{{ OVERALL_SCORE }}", OVERALL_SCORE
|
| 137 |
+
).replace(
|
| 138 |
+
"{{ QUESTION_FEEDBACK_HTML }}", QUESTION_FEEDBACK_HTML
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
return state
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def send_feedback_email(state: AgentState) -> AgentState:
|
| 145 |
+
"""Send feedback email with results (passing HTML body)."""
|
| 146 |
+
# CHANGE: Pass the new html_email_body to the send_email function
|
| 147 |
+
send_email(
|
| 148 |
+
state["student_email"],
|
| 149 |
+
f"Assessment Feedback - {state['test'].get('title','')}",
|
| 150 |
+
state["email_body"], # Plain text content
|
| 151 |
+
html_content=state["html_email_body"] # HTML content
|
| 152 |
+
)
|
| 153 |
+
return state
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
# ----------------------
|
| 157 |
+
# Build LangGraph
|
| 158 |
+
# ----------------------
|
| 159 |
+
def build_feedback_agent():
|
| 160 |
+
workflow = StateGraph(AgentState)
|
| 161 |
+
|
| 162 |
+
workflow.add_node("fetch_test", fetch_test)
|
| 163 |
+
workflow.add_node("evaluate_answers", evaluate_answers)
|
| 164 |
+
workflow.add_node("send_feedback_email", send_feedback_email)
|
| 165 |
+
|
| 166 |
+
workflow.set_entry_point("fetch_test")
|
| 167 |
+
workflow.add_edge("fetch_test", "evaluate_answers")
|
| 168 |
+
workflow.add_edge("evaluate_answers", "send_feedback_email")
|
| 169 |
+
workflow.add_edge("send_feedback_email", END)
|
| 170 |
+
|
| 171 |
+
return workflow.compile()
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
# ----------------------
|
| 175 |
+
# HTML Email Template (Move this to a services/email_template.py file if possible)
|
| 176 |
+
# ----------------------
|
| 177 |
+
HTML_EMAIL_TEMPLATE = """
|
| 178 |
+
<!DOCTYPE html>
|
| 179 |
+
<html lang="en">
|
| 180 |
+
<head>
|
| 181 |
+
<meta charset="UTF-8">
|
| 182 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 183 |
+
<title>Assessment Feedback</title>
|
| 184 |
+
<style>
|
| 185 |
+
/* CSS for the email template */
|
| 186 |
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background-color: #f4f4f4; margin: 0; padding: 0; }
|
| 187 |
+
.container { max-width: 600px; margin: 20px auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05); overflow: hidden; }
|
| 188 |
+
.header { background-color: #007bff; color: #ffffff; padding: 20px; text-align: center; }
|
| 189 |
+
.header h1 { margin: 0; font-size: 24px; }
|
| 190 |
+
.content { padding: 20px; color: #333333; }
|
| 191 |
+
.overall-score { text-align: center; margin: 20px 0; padding: 15px; border: 2px solid #28a745; background-color: #e9f7ef; border-radius: 5px; }
|
| 192 |
+
.overall-score h2 { margin: 0 0 5px 0; color: #28a745; }
|
| 193 |
+
.overall-score p { font-size: 2em; font-weight: bold; color: #28a745; margin: 0; }
|
| 194 |
+
.question-block { margin-bottom: 25px; padding: 15px; border: 1px solid #e0e0e0; border-radius: 5px; }
|
| 195 |
+
.question-block strong { color: #007bff; }
|
| 196 |
+
.feedback-box { margin-top: 10px; padding: 12px; background-color: #f8f9fa; border-left: 4px solid #007bff; border-radius: 3px; white-space: pre-wrap; }
|
| 197 |
+
.footer { padding: 20px; text-align: center; font-size: 0.8em; color: #999999; border-top: 1px solid #eeeeee; margin-top: 20px; }
|
| 198 |
+
</style>
|
| 199 |
+
</head>
|
| 200 |
+
<body>
|
| 201 |
+
<div class="container">
|
| 202 |
+
<div class="header">
|
| 203 |
+
<h1>Assessment Feedback: {{ TEST_TITLE }}</h1>
|
| 204 |
+
</div>
|
| 205 |
+
<div class="content">
|
| 206 |
+
<p>Dear {{ STUDENT_NAME }},</p>
|
| 207 |
+
<p>Please find your detailed assessment feedback below:</p>
|
| 208 |
+
|
| 209 |
+
<div class="overall-score">
|
| 210 |
+
<h2>Overall Score</h2>
|
| 211 |
+
<p>{{ OVERALL_SCORE }}/100</p>
|
| 212 |
+
</div>
|
| 213 |
+
|
| 214 |
+
{{ QUESTION_FEEDBACK_HTML }}
|
| 215 |
+
|
| 216 |
+
<p>We encourage you to review the suggested improvements to enhance your understanding of the material.</p>
|
| 217 |
+
</div>
|
| 218 |
+
<div class="footer">
|
| 219 |
+
Best regards,<br>
|
| 220 |
+
The Assessment Team
|
| 221 |
+
</div>
|
| 222 |
+
</div>
|
| 223 |
+
</body>
|
| 224 |
+
</html>
|
| 225 |
+
"""
|
services/huggingface_api.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import time
|
| 3 |
+
import logging
|
| 4 |
+
import requests
|
| 5 |
+
from openai import OpenAI
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
HF_HEADERS = {"Authorization": f"Bearer {os.getenv('HF_API_KEY', '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')}"}
|
| 10 |
+
HF_BASE = "https://api-inference.huggingface.co/models"
|
| 11 |
+
|
| 12 |
+
def hf_post(model, payload, retry=2):
|
| 13 |
+
url = f"{HF_BASE}/{model}"
|
| 14 |
+
for attempt in range(retry + 1):
|
| 15 |
+
resp = requests.post(url, headers=HF_HEADERS, json=payload, timeout=60)
|
| 16 |
+
if resp.status_code == 200:
|
| 17 |
+
return resp.json()
|
| 18 |
+
else:
|
| 19 |
+
logger.warning(f"HF call {model} status {resp.status_code} attempt {attempt}: {resp.text}")
|
| 20 |
+
time.sleep(1 + attempt)
|
| 21 |
+
raise Exception(f"Hugging Face API failed for {model}: {resp.status_code} {resp.text}")
|
| 22 |
+
|
| 23 |
+
# -------------------------
|
| 24 |
+
# Embeddings
|
| 25 |
+
# -------------------------
|
| 26 |
+
def get_embeddings(model, text):
|
| 27 |
+
"""
|
| 28 |
+
Calls HF feature-extraction to get embeddings for a single text.
|
| 29 |
+
Returns 1D list of floats.
|
| 30 |
+
"""
|
| 31 |
+
payload = {"inputs": text}
|
| 32 |
+
out = hf_post(model, payload)
|
| 33 |
+
|
| 34 |
+
if isinstance(out, list):
|
| 35 |
+
candidate = out[0]
|
| 36 |
+
if candidate and isinstance(candidate[0], list):
|
| 37 |
+
# Average token embeddings
|
| 38 |
+
import numpy as np
|
| 39 |
+
arr = np.array(candidate)
|
| 40 |
+
vec = arr.mean(axis=0).tolist()
|
| 41 |
+
return vec
|
| 42 |
+
elif candidate and isinstance(candidate[0], (float, int)):
|
| 43 |
+
return candidate
|
| 44 |
+
raise Exception("Unexpected HF embeddings response format")
|
| 45 |
+
|
| 46 |
+
# -------------------------
|
| 47 |
+
# Cross Encoder
|
| 48 |
+
# -------------------------
|
| 49 |
+
def get_cross_encoder_score(model, student, teacher):
|
| 50 |
+
"""
|
| 51 |
+
Try different input formats to obtain a similarity/score between student and teacher.
|
| 52 |
+
Returns float between 0 and 1 (or raises).
|
| 53 |
+
"""
|
| 54 |
+
try:
|
| 55 |
+
payload = {"inputs": [student, teacher]}
|
| 56 |
+
out = hf_post(model, payload)
|
| 57 |
+
if isinstance(out, list) and len(out) and isinstance(out[0], dict) and "score" in out[0]:
|
| 58 |
+
return float(out[0]["score"])
|
| 59 |
+
if isinstance(out, list) and len(out) and isinstance(out[0], (float, int)):
|
| 60 |
+
return float(out[0])
|
| 61 |
+
except Exception:
|
| 62 |
+
logger.exception("Format [student, teacher] failed")
|
| 63 |
+
|
| 64 |
+
try:
|
| 65 |
+
payload = {"inputs": {"text_pair": [student, teacher]}}
|
| 66 |
+
out = hf_post(model, payload)
|
| 67 |
+
if isinstance(out, dict) and "score" in out:
|
| 68 |
+
return float(out["score"])
|
| 69 |
+
except Exception:
|
| 70 |
+
logger.exception("Format text_pair failed")
|
| 71 |
+
|
| 72 |
+
try:
|
| 73 |
+
payload = {"inputs": f"{student}\n\n===\n\n{teacher}"}
|
| 74 |
+
out = hf_post(model, payload)
|
| 75 |
+
if isinstance(out, list) and len(out) and isinstance(out[0], dict) and "score" in out[0]:
|
| 76 |
+
top = max(out, key=lambda x: x.get("score", 0.0))
|
| 77 |
+
return float(top.get("score", 0.0))
|
| 78 |
+
except Exception:
|
| 79 |
+
logger.exception("Concatenation fallback failed")
|
| 80 |
+
|
| 81 |
+
raise Exception("Unable to parse cross-encoder output")
|
| 82 |
+
|
| 83 |
+
# -------------------------
|
| 84 |
+
# Feedback Generation
|
| 85 |
+
# -------------------------
|
| 86 |
+
def generate_feedback(question, answer, score, model="meta-llama/Llama-3.2-1B-Instruct:novita"):
|
| 87 |
+
try:
|
| 88 |
+
client = OpenAI(
|
| 89 |
+
base_url="https://router.huggingface.co/v1",
|
| 90 |
+
api_key=os.getenv("HF_API_KEY", "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"),
|
| 91 |
+
)
|
| 92 |
+
completion = client.chat.completions.create(
|
| 93 |
+
model=model,
|
| 94 |
+
messages=[
|
| 95 |
+
{
|
| 96 |
+
"role": "user",
|
| 97 |
+
"content": f"""
|
| 98 |
+
You are an educational assistant. Your task is to help students improve their understanding.
|
| 99 |
+
Given a question, a student’s answer, and the score, generate constructive, encouraging feedback.
|
| 100 |
+
|
| 101 |
+
Follow this format:
|
| 102 |
+
Positive: <what the student did well>
|
| 103 |
+
Improvement: <what is missing or could be improved>
|
| 104 |
+
Suggestion: <what to study or focus on next>
|
| 105 |
+
|
| 106 |
+
Question: {question}
|
| 107 |
+
Student Answer: {answer}
|
| 108 |
+
Score: {score}/100
|
| 109 |
+
"""
|
| 110 |
+
}
|
| 111 |
+
],
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
return completion.choices[0].message.content.strip()
|
| 115 |
+
except Exception as e:
|
| 116 |
+
print("error", e)
|
| 117 |
+
logger.warning(f"Feedback generation failed: {e}")
|
| 118 |
+
return f"Good attempt! You scored {score}/100."
|
static/style.css
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* style.css (mostly for elements not styled by Tailwind or for overrides) */
|
| 2 |
+
|
| 3 |
+
/* The flashes classes are now handled directly in base.html using Tailwind's utility classes. */
|
| 4 |
+
/* The body and nav styles are also handled by Tailwind in base.html. */
|
| 5 |
+
|
| 6 |
+
/* Keeping it almost empty as Tailwind is doing the heavy lifting */
|
templates/base.html
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html>
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<title>{{ title or "Assessment" }}</title>
|
| 6 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 7 |
+
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
| 8 |
+
</head>
|
| 9 |
+
<body class="bg-gray-50 min-h-screen">
|
| 10 |
+
<nav class="bg-white shadow-md p-4 flex justify-between items-center">
|
| 11 |
+
<a href="{{ url_for('home') }}" class="text-xl font-bold text-indigo-600 hover:text-indigo-800 transition duration-300">Assessment Platform</a>
|
| 12 |
+
<div class="flex items-center space-x-4">
|
| 13 |
+
{% if session.user %}
|
| 14 |
+
<span class="text-gray-600 font-medium">Welcome, {{ session.user.name }}</span>
|
| 15 |
+
<a href="{{ url_for('test.dashboard') }}" class="text-gray-600 hover:text-indigo-600 font-medium transition duration-300">Dashboard</a>
|
| 16 |
+
<a href="{{ url_for('auth.logout') }}" class="px-3 py-1 bg-red-500 text-white rounded-md hover:bg-red-600 transition duration-300">Logout</a>
|
| 17 |
+
{% else %}
|
| 18 |
+
<a href="{{ url_for('auth.login') }}" class="text-gray-600 hover:text-indigo-600 font-medium transition duration-300">Login</a>
|
| 19 |
+
<a href="{{ url_for('auth.signup') }}" class="px-3 py-1 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 transition duration-300">Signup</a>
|
| 20 |
+
{% endif %}
|
| 21 |
+
</div>
|
| 22 |
+
</nav>
|
| 23 |
+
|
| 24 |
+
<main class="container mx-auto mt-8 p-4">
|
| 25 |
+
{# Flash Messages with Tailwind classes #}
|
| 26 |
+
{% with messages = get_flashed_messages(with_categories=true) %}
|
| 27 |
+
{% if messages %}
|
| 28 |
+
<ul class="mb-6 space-y-2">
|
| 29 |
+
{% for category, msg in messages %}
|
| 30 |
+
{% set color_class = 'bg-green-100 border-green-400 text-green-700' if category == 'success' else 'bg-red-100 border-red-400 text-red-700' %}
|
| 31 |
+
<li class="{{ color_class }} border-l-4 p-4 rounded-md font-medium" role="alert">
|
| 32 |
+
{{ msg }}
|
| 33 |
+
</li>
|
| 34 |
+
{% endfor %}
|
| 35 |
+
</ul>
|
| 36 |
+
{% endif %}
|
| 37 |
+
{% endwith %}
|
| 38 |
+
|
| 39 |
+
{% block content %}{% endblock %}
|
| 40 |
+
</main>
|
| 41 |
+
</body>
|
| 42 |
+
</html>
|
templates/dashboard.html
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block content %}
|
| 3 |
+
<div class="bg-white p-6 rounded-lg shadow-xl">
|
| 4 |
+
<h2 class="text-3xl font-extrabold text-gray-800 mb-4 border-b pb-2">Dashboard — {{ user.name }}</h2>
|
| 5 |
+
<h3 class="text-2xl font-semibold text-gray-700 mt-6 mb-4">Your Test Results</h3>
|
| 6 |
+
|
| 7 |
+
<ul class="space-y-4">
|
| 8 |
+
{# The key change is here: sorting 'results' by 'timestamp' in reverse (descending) order #}
|
| 9 |
+
{% for r in results | sort(attribute='timestamp', reverse=True) %}
|
| 10 |
+
<li class="p-4 bg-gray-50 rounded-lg border border-gray-200 hover:shadow-md transition duration-300 flex justify-between items-center">
|
| 11 |
+
<div class="flex-grow">
|
| 12 |
+
<span class="font-bold text-indigo-600">Test: {{ r.test_id }}</span>
|
| 13 |
+
<span class="ml-4 text-gray-600">Score: <span class="font-extrabold text-xl">{{ r.total_score }}</span></span>
|
| 14 |
+
</div>
|
| 15 |
+
<span class="text-sm text-gray-500">{{ r.timestamp }}</span>
|
| 16 |
+
</li>
|
| 17 |
+
{% else %}
|
| 18 |
+
<li class="p-4 text-gray-500 italic bg-gray-100 rounded-lg">No results yet. Start a test today!</li>
|
| 19 |
+
{% endfor %}
|
| 20 |
+
</ul>
|
| 21 |
+
</div>
|
| 22 |
+
{% endblock %}
|
templates/home.html
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block content %}
|
| 3 |
+
<div class="bg-white p-6 rounded-lg shadow-xl">
|
| 4 |
+
<h1 class="text-3xl font-extrabold text-gray-800 mb-6">Available Tests</h1>
|
| 5 |
+
|
| 6 |
+
<ul class="space-y-4">
|
| 7 |
+
{% for t in tests %}
|
| 8 |
+
<li class="p-5 bg-gray-50 rounded-lg border border-gray-200 shadow-sm flex flex-col sm:flex-row justify-between items-start sm:items-center hover:bg-gray-100 transition duration-300">
|
| 9 |
+
<div class="mb-2 sm:mb-0">
|
| 10 |
+
<strong class="text-xl font-semibold text-indigo-700">{{ t.title }}</strong>
|
| 11 |
+
<p class="text-gray-600 mt-1">{{ t.description or 'No description provided.' }}</p>
|
| 12 |
+
</div>
|
| 13 |
+
<a href="{{ url_for('test.start', test_id=t.id) }}" class="px-4 py-2 bg-green-500 text-white font-medium rounded-md shadow-md hover:bg-green-600 transition duration-300 whitespace-nowrap">
|
| 14 |
+
Take test
|
| 15 |
+
</a>
|
| 16 |
+
</li>
|
| 17 |
+
{% else %}
|
| 18 |
+
<li class="p-4 text-gray-500 italic bg-gray-100 rounded-lg">No tests available yet. Please check back later!</li>
|
| 19 |
+
{% endfor %}
|
| 20 |
+
</ul>
|
| 21 |
+
</div>
|
| 22 |
+
{% endblock %}
|
templates/login.html
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block content %}
|
| 3 |
+
<div class="max-w-md mx-auto bg-white p-8 rounded-xl shadow-2xl">
|
| 4 |
+
<h2 class="text-3xl font-extrabold text-gray-800 mb-6 text-center">Login</h2>
|
| 5 |
+
<form method="post" class="space-y-6">
|
| 6 |
+
<div>
|
| 7 |
+
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
| 8 |
+
<input id="email" name="email" type="email" required
|
| 9 |
+
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-indigo-500 focus:border-indigo-500 transition duration-150"
|
| 10 |
+
placeholder="you@example.com" />
|
| 11 |
+
</div>
|
| 12 |
+
|
| 13 |
+
<div>
|
| 14 |
+
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
| 15 |
+
<input id="password" name="password" type="password" required
|
| 16 |
+
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-indigo-500 focus:border-indigo-500 transition duration-150"
|
| 17 |
+
placeholder="••••••••" />
|
| 18 |
+
</div>
|
| 19 |
+
|
| 20 |
+
<button type="submit" class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-lg font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition duration-300">
|
| 21 |
+
Login
|
| 22 |
+
</button>
|
| 23 |
+
</form>
|
| 24 |
+
</div>
|
| 25 |
+
{% endblock %}
|
templates/signup.html
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block content %}
|
| 3 |
+
<div class="max-w-md mx-auto bg-white p-8 rounded-xl shadow-2xl">
|
| 4 |
+
<h2 class="text-3xl font-extrabold text-gray-800 mb-6 text-center">Signup</h2>
|
| 5 |
+
<form method="post" class="space-y-6">
|
| 6 |
+
<div>
|
| 7 |
+
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
| 8 |
+
<input id="name" name="name" required
|
| 9 |
+
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-indigo-500 focus:border-indigo-500 transition duration-150"
|
| 10 |
+
placeholder="Your Full Name" />
|
| 11 |
+
</div>
|
| 12 |
+
|
| 13 |
+
<div>
|
| 14 |
+
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
| 15 |
+
<input id="email" name="email" type="email" required
|
| 16 |
+
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-indigo-500 focus:border-indigo-500 transition duration-150"
|
| 17 |
+
placeholder="you@example.com" />
|
| 18 |
+
</div>
|
| 19 |
+
|
| 20 |
+
<div>
|
| 21 |
+
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
| 22 |
+
<input id="password" name="password" type="password" required
|
| 23 |
+
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-indigo-500 focus:border-indigo-500 transition duration-150"
|
| 24 |
+
placeholder="Create a strong password" />
|
| 25 |
+
</div>
|
| 26 |
+
|
| 27 |
+
<button type="submit" class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-lg font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition duration-300">
|
| 28 |
+
Signup
|
| 29 |
+
</button>
|
| 30 |
+
</form>
|
| 31 |
+
</div>
|
| 32 |
+
{% endblock %}
|
templates/test_question.html
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block content %}
|
| 3 |
+
<div class="bg-white p-8 rounded-xl shadow-2xl max-w-2xl mx-auto">
|
| 4 |
+
<h2 class="text-2xl font-extrabold text-gray-700 mb-4">Question <span class="text-indigo-600">{{ index }}</span> / {{ total }}</h2>
|
| 5 |
+
|
| 6 |
+
<div class="bg-indigo-50 p-6 rounded-lg border-l-4 border-indigo-500 mb-6">
|
| 7 |
+
<p class="text-xl font-semibold text-gray-800">{{ question.text }}</p>
|
| 8 |
+
</div>
|
| 9 |
+
|
| 10 |
+
<form method="post" class="space-y-4">
|
| 11 |
+
<input type="hidden" name="qid" value="{{ question.id }}" />
|
| 12 |
+
|
| 13 |
+
<div>
|
| 14 |
+
<label for="answer" class="block text-sm font-medium text-gray-700 mb-1">Your Descriptive Answer:</label>
|
| 15 |
+
<textarea id="answer" name="answer" rows="8" required
|
| 16 |
+
class="w-full p-3 border border-gray-300 rounded-lg focus:ring-indigo-500 focus:border-indigo-500 transition duration-150"
|
| 17 |
+
placeholder="Write your descriptive answer here..."></textarea>
|
| 18 |
+
</div>
|
| 19 |
+
|
| 20 |
+
<div class="pt-4">
|
| 21 |
+
<button type="submit" class="w-full sm:w-auto px-6 py-3 bg-indigo-600 text-white font-semibold rounded-lg shadow-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition duration-300">
|
| 22 |
+
Submit & Next
|
| 23 |
+
</button>
|
| 24 |
+
</div>
|
| 25 |
+
</form>
|
| 26 |
+
</div>
|
| 27 |
+
{% endblock %}
|