sailajaai commited on
Commit
7688281
·
verified ·
1 Parent(s): 437b424

Upload 25 files

Browse files
.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 %}