Spaces:
Sleeping
Sleeping
t commited on
Commit ·
d3d811e
1
Parent(s): 24388f8
new features
Browse files- app.py +2 -0
- dashboard.py +65 -14
- data.db +0 -0
- database.py +20 -0
- neetprep.py +854 -29
- nvidia_prompts.py +41 -58
- routes/library.py +5 -1
- routes/pdf_ops.py +1 -1
- routes/questions.py +264 -19
- templates/collection_view.html +310 -0
- templates/dashboard.html +69 -2
- templates/neetprep.html +390 -141
- templates/neetprep_edit.html +985 -14
- templates/pdf_manager.html +13 -20
- templates/question_entry_v2.html +659 -116
- templates/quiz_v2.html +360 -22
app.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import os
|
| 2 |
import sys
|
|
|
|
| 3 |
from flask import Flask
|
| 4 |
from flask_cors import CORS
|
| 5 |
from flask_socketio import SocketIO
|
|
@@ -41,6 +42,7 @@ def create_app():
|
|
| 41 |
# Register custom Jinja2 filter
|
| 42 |
app.jinja_env.filters['humanize'] = humanize_datetime
|
| 43 |
app.jinja_env.filters['chr'] = chr
|
|
|
|
| 44 |
|
| 45 |
# Configuration
|
| 46 |
app.config['SECRET_KEY'] = os.urandom(24)
|
|
|
|
| 1 |
import os
|
| 2 |
import sys
|
| 3 |
+
import json
|
| 4 |
from flask import Flask
|
| 5 |
from flask_cors import CORS
|
| 6 |
from flask_socketio import SocketIO
|
|
|
|
| 42 |
# Register custom Jinja2 filter
|
| 43 |
app.jinja_env.filters['humanize'] = humanize_datetime
|
| 44 |
app.jinja_env.filters['chr'] = chr
|
| 45 |
+
app.jinja_env.filters['from_json'] = lambda x: json.loads(x) if x else []
|
| 46 |
|
| 47 |
# Configuration
|
| 48 |
app.config['SECRET_KEY'] = os.urandom(24)
|
dashboard.py
CHANGED
|
@@ -132,18 +132,63 @@ def format_file_size(size_bytes):
|
|
| 132 |
def dashboard():
|
| 133 |
# Check if size parameter is passed
|
| 134 |
show_size = request.args.get('size', type=int)
|
|
|
|
|
|
|
| 135 |
|
| 136 |
conn = get_db_connection()
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
|
| 148 |
sessions = []
|
| 149 |
for session in sessions_rows:
|
|
@@ -159,7 +204,7 @@ def dashboard():
|
|
| 159 |
|
| 160 |
conn.close()
|
| 161 |
|
| 162 |
-
return render_template('dashboard.html', sessions=sessions, show_size=bool(show_size))
|
| 163 |
|
| 164 |
@dashboard_bp.route('/sessions/batch_delete', methods=['POST'])
|
| 165 |
@login_required
|
|
@@ -174,13 +219,19 @@ def batch_delete_sessions():
|
|
| 174 |
conn = get_db_connection()
|
| 175 |
for session_id in session_ids:
|
| 176 |
# Security Check: Ensure the session belongs to the current user
|
| 177 |
-
|
| 178 |
-
if not
|
| 179 |
# Silently skip or log an error, but don't delete
|
| 180 |
current_app.logger.warning(f"User {current_user.id} attempted to delete unauthorized session {session_id}.")
|
| 181 |
continue
|
| 182 |
|
| 183 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
images_to_delete = conn.execute('SELECT filename, processed_filename FROM images WHERE session_id = ?', (session_id,)).fetchall()
|
| 185 |
for img in images_to_delete:
|
| 186 |
if img['filename']:
|
|
|
|
| 132 |
def dashboard():
|
| 133 |
# Check if size parameter is passed
|
| 134 |
show_size = request.args.get('size', type=int)
|
| 135 |
+
# Check filter parameter
|
| 136 |
+
filter_type = request.args.get('filter', 'all') # 'all', 'standard', 'collections'
|
| 137 |
|
| 138 |
conn = get_db_connection()
|
| 139 |
+
|
| 140 |
+
# Build the base query
|
| 141 |
+
if filter_type == 'collections':
|
| 142 |
+
# Only show neetprep collections
|
| 143 |
+
sessions_rows = conn.execute("""
|
| 144 |
+
SELECT s.id, s.created_at, s.original_filename, s.persist, s.name, s.session_type,
|
| 145 |
+
0 as page_count,
|
| 146 |
+
COUNT(nb.id) as question_count
|
| 147 |
+
FROM sessions s
|
| 148 |
+
LEFT JOIN neetprep_bookmarks nb ON s.id = nb.session_id
|
| 149 |
+
WHERE s.user_id = ? AND s.session_type = 'neetprep_collection'
|
| 150 |
+
GROUP BY s.id, s.created_at, s.original_filename, s.persist, s.name, s.session_type
|
| 151 |
+
ORDER BY s.created_at DESC
|
| 152 |
+
""", (current_user.id,)).fetchall()
|
| 153 |
+
elif filter_type == 'standard':
|
| 154 |
+
# Only show standard sessions (exclude collections and final_pdf)
|
| 155 |
+
sessions_rows = conn.execute("""
|
| 156 |
+
SELECT s.id, s.created_at, s.original_filename, s.persist, s.name, s.session_type,
|
| 157 |
+
COUNT(CASE WHEN i.image_type = 'original' THEN 1 END) as page_count,
|
| 158 |
+
COUNT(CASE WHEN i.image_type = 'cropped' THEN 1 END) as question_count
|
| 159 |
+
FROM sessions s
|
| 160 |
+
LEFT JOIN images i ON s.id = i.session_id
|
| 161 |
+
WHERE s.user_id = ? AND (s.session_type IS NULL OR s.session_type NOT IN ('final_pdf', 'neetprep_collection'))
|
| 162 |
+
GROUP BY s.id, s.created_at, s.original_filename, s.persist, s.name, s.session_type
|
| 163 |
+
ORDER BY s.created_at DESC
|
| 164 |
+
""", (current_user.id,)).fetchall()
|
| 165 |
+
else:
|
| 166 |
+
# Show all (both standard and collections, but not final_pdf)
|
| 167 |
+
# First get standard sessions
|
| 168 |
+
standard_sessions = conn.execute("""
|
| 169 |
+
SELECT s.id, s.created_at, s.original_filename, s.persist, s.name, s.session_type,
|
| 170 |
+
COUNT(CASE WHEN i.image_type = 'original' THEN 1 END) as page_count,
|
| 171 |
+
COUNT(CASE WHEN i.image_type = 'cropped' THEN 1 END) as question_count
|
| 172 |
+
FROM sessions s
|
| 173 |
+
LEFT JOIN images i ON s.id = i.session_id
|
| 174 |
+
WHERE s.user_id = ? AND (s.session_type IS NULL OR s.session_type NOT IN ('final_pdf', 'neetprep_collection'))
|
| 175 |
+
GROUP BY s.id, s.created_at, s.original_filename, s.persist, s.name, s.session_type
|
| 176 |
+
""", (current_user.id,)).fetchall()
|
| 177 |
+
|
| 178 |
+
# Then get neetprep collections
|
| 179 |
+
collection_sessions = conn.execute("""
|
| 180 |
+
SELECT s.id, s.created_at, s.original_filename, s.persist, s.name, s.session_type,
|
| 181 |
+
0 as page_count,
|
| 182 |
+
COUNT(nb.id) as question_count
|
| 183 |
+
FROM sessions s
|
| 184 |
+
LEFT JOIN neetprep_bookmarks nb ON s.id = nb.session_id
|
| 185 |
+
WHERE s.user_id = ? AND s.session_type = 'neetprep_collection'
|
| 186 |
+
GROUP BY s.id, s.created_at, s.original_filename, s.persist, s.name, s.session_type
|
| 187 |
+
""", (current_user.id,)).fetchall()
|
| 188 |
+
|
| 189 |
+
# Combine and sort by created_at
|
| 190 |
+
all_sessions = list(standard_sessions) + list(collection_sessions)
|
| 191 |
+
sessions_rows = sorted(all_sessions, key=lambda x: x['created_at'], reverse=True)
|
| 192 |
|
| 193 |
sessions = []
|
| 194 |
for session in sessions_rows:
|
|
|
|
| 204 |
|
| 205 |
conn.close()
|
| 206 |
|
| 207 |
+
return render_template('dashboard.html', sessions=sessions, show_size=bool(show_size), filter_type=filter_type)
|
| 208 |
|
| 209 |
@dashboard_bp.route('/sessions/batch_delete', methods=['POST'])
|
| 210 |
@login_required
|
|
|
|
| 219 |
conn = get_db_connection()
|
| 220 |
for session_id in session_ids:
|
| 221 |
# Security Check: Ensure the session belongs to the current user
|
| 222 |
+
session_info = conn.execute('SELECT user_id, session_type FROM sessions WHERE id = ?', (session_id,)).fetchone()
|
| 223 |
+
if not session_info or session_info['user_id'] != current_user.id:
|
| 224 |
# Silently skip or log an error, but don't delete
|
| 225 |
current_app.logger.warning(f"User {current_user.id} attempted to delete unauthorized session {session_id}.")
|
| 226 |
continue
|
| 227 |
|
| 228 |
+
# For bookmark collections, only delete bookmarks (not original images)
|
| 229 |
+
if session_info['session_type'] == 'neetprep_collection':
|
| 230 |
+
conn.execute('DELETE FROM neetprep_bookmarks WHERE session_id = ? AND user_id = ?', (session_id, current_user.id))
|
| 231 |
+
conn.execute('DELETE FROM sessions WHERE id = ?', (session_id,))
|
| 232 |
+
continue
|
| 233 |
+
|
| 234 |
+
# For regular sessions, delete associated files
|
| 235 |
images_to_delete = conn.execute('SELECT filename, processed_filename FROM images WHERE session_id = ?', (session_id,)).fetchall()
|
| 236 |
for img in images_to_delete:
|
| 237 |
if img['filename']:
|
data.db
ADDED
|
File without changes
|
database.py
CHANGED
|
@@ -117,6 +117,21 @@ def setup_database():
|
|
| 117 |
);
|
| 118 |
""")
|
| 119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
# Create subjective_folders table
|
| 121 |
cursor.execute("""
|
| 122 |
CREATE TABLE IF NOT EXISTS subjective_folders (
|
|
@@ -349,6 +364,11 @@ def setup_database():
|
|
| 349 |
except sqlite3.OperationalError:
|
| 350 |
cursor.execute("ALTER TABLE users ADD COLUMN classifier_model TEXT DEFAULT 'gemini'")
|
| 351 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
conn.commit()
|
| 353 |
conn.close()
|
| 354 |
|
|
|
|
| 117 |
);
|
| 118 |
""")
|
| 119 |
|
| 120 |
+
# Create neetprep_bookmarks table for saving questions to collections
|
| 121 |
+
cursor.execute("""
|
| 122 |
+
CREATE TABLE IF NOT EXISTS neetprep_bookmarks (
|
| 123 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 124 |
+
user_id INTEGER NOT NULL,
|
| 125 |
+
neetprep_question_id TEXT NOT NULL,
|
| 126 |
+
session_id TEXT,
|
| 127 |
+
question_type TEXT DEFAULT 'neetprep',
|
| 128 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 129 |
+
FOREIGN KEY (user_id) REFERENCES users (id),
|
| 130 |
+
FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE,
|
| 131 |
+
UNIQUE(user_id, neetprep_question_id, session_id, question_type)
|
| 132 |
+
);
|
| 133 |
+
""")
|
| 134 |
+
|
| 135 |
# Create subjective_folders table
|
| 136 |
cursor.execute("""
|
| 137 |
CREATE TABLE IF NOT EXISTS subjective_folders (
|
|
|
|
| 364 |
except sqlite3.OperationalError:
|
| 365 |
cursor.execute("ALTER TABLE users ADD COLUMN classifier_model TEXT DEFAULT 'gemini'")
|
| 366 |
|
| 367 |
+
try:
|
| 368 |
+
cursor.execute("SELECT question_type FROM neetprep_bookmarks LIMIT 1")
|
| 369 |
+
except sqlite3.OperationalError:
|
| 370 |
+
cursor.execute("ALTER TABLE neetprep_bookmarks ADD COLUMN question_type TEXT DEFAULT 'neetprep'")
|
| 371 |
+
|
| 372 |
conn.commit()
|
| 373 |
conn.close()
|
| 374 |
|
neetprep.py
CHANGED
|
@@ -308,51 +308,50 @@ def generate_neetprep_pdf():
|
|
| 308 |
data = request.json
|
| 309 |
else:
|
| 310 |
data = request.form
|
| 311 |
-
|
| 312 |
pdf_type = data.get('type')
|
| 313 |
topics_str = data.get('topics')
|
| 314 |
topics = json.loads(topics_str) if topics_str and topics_str != '[]' else []
|
|
|
|
| 315 |
|
| 316 |
conn = get_db_connection()
|
| 317 |
all_questions = []
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
|
|
|
|
|
|
|
|
|
| 321 |
if pdf_type == 'quiz' and topics:
|
| 322 |
placeholders = ', '.join('?' for _ in topics)
|
| 323 |
neetprep_questions_from_db = conn.execute(f"SELECT * FROM neetprep_questions WHERE topic IN ({placeholders})", topics).fetchall()
|
| 324 |
for q in neetprep_questions_from_db:
|
| 325 |
try:
|
| 326 |
html_content = f"""<html><head><meta charset="utf-8"></head><body>{q['question_text']}</body></html>"""
|
| 327 |
-
|
|
|
|
| 328 |
imgkit.from_string(html_content, img_path, options={'width': 800})
|
| 329 |
all_questions.append({
|
| 330 |
-
'image_path':
|
| 331 |
'details': {'id': q['id'], 'options': json.loads(q['options']), 'correct_answer_index': q['correct_answer_index'], 'user_answer_index': None, 'source': 'neetprep', 'topic': q['topic'], 'subject': q['subject']}
|
| 332 |
})
|
| 333 |
except Exception as e:
|
| 334 |
current_app.logger.error(f"Failed to convert NeetPrep question {q['id']} to image: {e}")
|
| 335 |
-
|
| 336 |
elif pdf_type == 'all':
|
| 337 |
neetprep_questions_from_db = conn.execute("SELECT * FROM neetprep_questions").fetchall()
|
| 338 |
for q in neetprep_questions_from_db:
|
| 339 |
all_questions.append({"id": q['id'], "question_text": q['question_text'], "options": json.loads(q['options']), "correct_answer_index": q['correct_answer_index'], "user_answer_index": None, "status": "wrong", "source": "neetprep", "custom_fields": {"difficulty": q['level'], "topic": q['topic'], "subject": q['subject']}})
|
| 340 |
-
|
| 341 |
elif pdf_type == 'selected' and topics:
|
| 342 |
placeholders = ', '.join('?' for _ in topics)
|
| 343 |
neetprep_questions_from_db = conn.execute(f"SELECT * FROM neetprep_questions WHERE topic IN ({placeholders})", topics).fetchall()
|
| 344 |
for q in neetprep_questions_from_db:
|
| 345 |
all_questions.append({"id": q['id'], "question_text": q['question_text'], "options": json.loads(q['options']), "correct_answer_index": q['correct_answer_index'], "user_answer_index": None, "status": "wrong", "source": "neetprep", "custom_fields": {"difficulty": q['level'], "topic": q['topic'], "subject": q['subject']}})
|
| 346 |
|
| 347 |
-
#
|
| 348 |
-
if
|
| 349 |
-
|
| 350 |
-
if not topics:
|
| 351 |
-
# For a quiz, topics are mandatory. For 'selected', topics are mandatory.
|
| 352 |
-
if pdf_type in ['quiz', 'selected']:
|
| 353 |
-
conn.close()
|
| 354 |
-
return jsonify({'error': 'No topics selected.'}), 400
|
| 355 |
-
else:
|
| 356 |
placeholders = ', '.join('?' for _ in topics)
|
| 357 |
classified_questions_from_db = conn.execute(f"""
|
| 358 |
SELECT q.* FROM questions q JOIN sessions s ON q.session_id = s.id
|
|
@@ -362,23 +361,29 @@ def generate_neetprep_pdf():
|
|
| 362 |
image_info = conn.execute("SELECT processed_filename FROM images WHERE id = ?", (q['image_id'],)).fetchone()
|
| 363 |
if image_info and image_info['processed_filename']:
|
| 364 |
if pdf_type == 'quiz':
|
| 365 |
-
all_questions.append({
|
|
|
|
|
|
|
|
|
|
| 366 |
else:
|
| 367 |
all_questions.append({"id": q['id'], "question_text": f"<img src=\"{os.path.join(current_app.config['PROCESSED_FOLDER'], image_info['processed_filename'])}\" />", "options": [], "correct_answer_index": q['actual_solution'], "user_answer_index": q['marked_solution'], "status": q['status'], "source": "classified", "custom_fields": {"subject": q['subject'], "chapter": q['chapter'], "question_number": q['question_number']}})
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
all_questions.append({"id": q['id'], "question_text": f"<img src=\"{os.path.join(current_app.config['PROCESSED_FOLDER'], image_info['processed_filename'])}\" />", "options": [], "correct_answer_index": q['actual_solution'], "user_answer_index": q['marked_solution'], "status": q['status'], "source": "classified", "custom_fields": {"subject": q['subject'], "chapter": q['chapter'], "question_number": q['question_number']}})
|
| 379 |
|
| 380 |
conn.close()
|
| 381 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
if not all_questions:
|
| 383 |
return jsonify({'error': 'No questions found for the selected criteria.'}), 404
|
| 384 |
|
|
@@ -454,6 +459,826 @@ def update_neetprep_question(question_id):
|
|
| 454 |
current_app.logger.error(f"Error updating question {question_id}: {repr(e)}")
|
| 455 |
return jsonify({'error': str(e)}), 500
|
| 456 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 457 |
def run_hardcoded_query(query_template, **kwargs):
|
| 458 |
"""Helper function to run a GraphQL query."""
|
| 459 |
final_query = query_template.format(**kwargs)
|
|
|
|
| 308 |
data = request.json
|
| 309 |
else:
|
| 310 |
data = request.form
|
| 311 |
+
|
| 312 |
pdf_type = data.get('type')
|
| 313 |
topics_str = data.get('topics')
|
| 314 |
topics = json.loads(topics_str) if topics_str and topics_str != '[]' else []
|
| 315 |
+
source_filter = data.get('source', 'all') # 'all', 'neetprep', or 'classified'
|
| 316 |
|
| 317 |
conn = get_db_connection()
|
| 318 |
all_questions = []
|
| 319 |
+
|
| 320 |
+
include_neetprep = source_filter in ['all', 'neetprep'] and current_user.neetprep_enabled
|
| 321 |
+
include_classified = source_filter in ['all', 'classified']
|
| 322 |
+
|
| 323 |
+
# Fetch NeetPrep questions if enabled and filter allows
|
| 324 |
+
if include_neetprep:
|
| 325 |
if pdf_type == 'quiz' and topics:
|
| 326 |
placeholders = ', '.join('?' for _ in topics)
|
| 327 |
neetprep_questions_from_db = conn.execute(f"SELECT * FROM neetprep_questions WHERE topic IN ({placeholders})", topics).fetchall()
|
| 328 |
for q in neetprep_questions_from_db:
|
| 329 |
try:
|
| 330 |
html_content = f"""<html><head><meta charset="utf-8"></head><body>{q['question_text']}</body></html>"""
|
| 331 |
+
img_filename = f"neetprep_{q['id']}.jpg"
|
| 332 |
+
img_path = os.path.join(current_app.config['TEMP_FOLDER'], img_filename)
|
| 333 |
imgkit.from_string(html_content, img_path, options={'width': 800})
|
| 334 |
all_questions.append({
|
| 335 |
+
'image_path': f"/tmp/{img_filename}",
|
| 336 |
'details': {'id': q['id'], 'options': json.loads(q['options']), 'correct_answer_index': q['correct_answer_index'], 'user_answer_index': None, 'source': 'neetprep', 'topic': q['topic'], 'subject': q['subject']}
|
| 337 |
})
|
| 338 |
except Exception as e:
|
| 339 |
current_app.logger.error(f"Failed to convert NeetPrep question {q['id']} to image: {e}")
|
| 340 |
+
|
| 341 |
elif pdf_type == 'all':
|
| 342 |
neetprep_questions_from_db = conn.execute("SELECT * FROM neetprep_questions").fetchall()
|
| 343 |
for q in neetprep_questions_from_db:
|
| 344 |
all_questions.append({"id": q['id'], "question_text": q['question_text'], "options": json.loads(q['options']), "correct_answer_index": q['correct_answer_index'], "user_answer_index": None, "status": "wrong", "source": "neetprep", "custom_fields": {"difficulty": q['level'], "topic": q['topic'], "subject": q['subject']}})
|
| 345 |
+
|
| 346 |
elif pdf_type == 'selected' and topics:
|
| 347 |
placeholders = ', '.join('?' for _ in topics)
|
| 348 |
neetprep_questions_from_db = conn.execute(f"SELECT * FROM neetprep_questions WHERE topic IN ({placeholders})", topics).fetchall()
|
| 349 |
for q in neetprep_questions_from_db:
|
| 350 |
all_questions.append({"id": q['id'], "question_text": q['question_text'], "options": json.loads(q['options']), "correct_answer_index": q['correct_answer_index'], "user_answer_index": None, "status": "wrong", "source": "neetprep", "custom_fields": {"difficulty": q['level'], "topic": q['topic'], "subject": q['subject']}})
|
| 351 |
|
| 352 |
+
# Fetch classified questions if filter allows
|
| 353 |
+
if include_classified:
|
| 354 |
+
if topics and pdf_type in ['quiz', 'selected']:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
placeholders = ', '.join('?' for _ in topics)
|
| 356 |
classified_questions_from_db = conn.execute(f"""
|
| 357 |
SELECT q.* FROM questions q JOIN sessions s ON q.session_id = s.id
|
|
|
|
| 361 |
image_info = conn.execute("SELECT processed_filename FROM images WHERE id = ?", (q['image_id'],)).fetchone()
|
| 362 |
if image_info and image_info['processed_filename']:
|
| 363 |
if pdf_type == 'quiz':
|
| 364 |
+
all_questions.append({
|
| 365 |
+
'image_path': f"/processed/{image_info['processed_filename']}",
|
| 366 |
+
'details': {'id': q['id'], 'options': [], 'correct_answer_index': q['actual_solution'], 'user_answer_index': q['marked_solution'], 'source': 'classified', 'topic': q['chapter'], 'subject': q['subject']}
|
| 367 |
+
})
|
| 368 |
else:
|
| 369 |
all_questions.append({"id": q['id'], "question_text": f"<img src=\"{os.path.join(current_app.config['PROCESSED_FOLDER'], image_info['processed_filename'])}\" />", "options": [], "correct_answer_index": q['actual_solution'], "user_answer_index": q['marked_solution'], "status": q['status'], "source": "classified", "custom_fields": {"subject": q['subject'], "chapter": q['chapter'], "question_number": q['question_number']}})
|
| 370 |
+
|
| 371 |
+
elif pdf_type == 'all':
|
| 372 |
+
classified_questions_from_db = conn.execute("""
|
| 373 |
+
SELECT q.* FROM questions q JOIN sessions s ON q.session_id = s.id
|
| 374 |
+
WHERE s.user_id = ? AND q.subject IS NOT NULL AND q.chapter IS NOT NULL
|
| 375 |
+
""", (current_user.id,)).fetchall()
|
| 376 |
+
for q in classified_questions_from_db:
|
| 377 |
+
image_info = conn.execute("SELECT processed_filename FROM images WHERE id = ?", (q['image_id'],)).fetchone()
|
| 378 |
+
if image_info and image_info['processed_filename']:
|
| 379 |
+
all_questions.append({"id": q['id'], "question_text": f"<img src=\"{os.path.join(current_app.config['PROCESSED_FOLDER'], image_info['processed_filename'])}\" />", "options": [], "correct_answer_index": q['actual_solution'], "user_answer_index": q['marked_solution'], "status": q['status'], "source": "classified", "custom_fields": {"subject": q['subject'], "chapter": q['chapter'], "question_number": q['question_number']}})
|
|
|
|
| 380 |
|
| 381 |
conn.close()
|
| 382 |
|
| 383 |
+
# Check if topics are required but not provided
|
| 384 |
+
if pdf_type in ['quiz', 'selected'] and not topics:
|
| 385 |
+
return jsonify({'error': 'No topics selected.'}), 400
|
| 386 |
+
|
| 387 |
if not all_questions:
|
| 388 |
return jsonify({'error': 'No questions found for the selected criteria.'}), 404
|
| 389 |
|
|
|
|
| 459 |
current_app.logger.error(f"Error updating question {question_id}: {repr(e)}")
|
| 460 |
return jsonify({'error': str(e)}), 500
|
| 461 |
|
| 462 |
+
@neetprep_bp.route('/neetprep/get_suggestions/<question_id>', methods=['POST'])
|
| 463 |
+
@login_required
|
| 464 |
+
def get_neetprep_suggestions(question_id):
|
| 465 |
+
"""Get AI classification suggestions for a NeetPrep question using NVIDIA NIM."""
|
| 466 |
+
import os
|
| 467 |
+
from nvidia_prompts import BIOLOGY_PROMPT_TEMPLATE, CHEMISTRY_PROMPT_TEMPLATE, PHYSICS_PROMPT_TEMPLATE, MATHEMATICS_PROMPT_TEMPLATE, GENERAL_CLASSIFICATION_PROMPT
|
| 468 |
+
|
| 469 |
+
data = request.json or {}
|
| 470 |
+
subject = data.get('subject') # Can be None for auto-detection
|
| 471 |
+
|
| 472 |
+
conn = get_db_connection()
|
| 473 |
+
question = conn.execute('SELECT question_text FROM neetprep_questions WHERE id = ?', (question_id,)).fetchone()
|
| 474 |
+
conn.close()
|
| 475 |
+
|
| 476 |
+
if not question:
|
| 477 |
+
return jsonify({'success': True, 'suggestions': ['Unclassified'], 'subject': 'Biology', 'warning': 'Question not found'})
|
| 478 |
+
|
| 479 |
+
# Strip HTML from question text for classification
|
| 480 |
+
question_text = question['question_text'] or ''
|
| 481 |
+
if not question_text:
|
| 482 |
+
return jsonify({'success': True, 'suggestions': ['Unclassified'], 'subject': 'Biology', 'warning': 'No question text'})
|
| 483 |
+
|
| 484 |
+
soup = BeautifulSoup(question_text, 'html.parser')
|
| 485 |
+
plain_text = soup.get_text(strip=True)
|
| 486 |
+
|
| 487 |
+
if not plain_text:
|
| 488 |
+
return jsonify({'success': True, 'suggestions': ['Unclassified'], 'subject': 'Biology', 'warning': 'Empty question text'})
|
| 489 |
+
|
| 490 |
+
NVIDIA_API_KEY = os.getenv("NVIDIA_API_KEY")
|
| 491 |
+
if not NVIDIA_API_KEY:
|
| 492 |
+
return jsonify({'error': 'NVIDIA_API_KEY not set', 'suggestions': ['Unclassified'], 'subject': 'Biology'}), 200
|
| 493 |
+
|
| 494 |
+
# Get the appropriate prompt template
|
| 495 |
+
def get_nvidia_prompt(subj, input_questions):
|
| 496 |
+
if not subj or subj.lower() == 'auto':
|
| 497 |
+
return GENERAL_CLASSIFICATION_PROMPT.format(input_questions=input_questions)
|
| 498 |
+
if subj.lower() == 'biology': return BIOLOGY_PROMPT_TEMPLATE.format(input_questions=input_questions)
|
| 499 |
+
if subj.lower() == 'chemistry': return CHEMISTRY_PROMPT_TEMPLATE.format(input_questions=input_questions)
|
| 500 |
+
if subj.lower() == 'physics': return PHYSICS_PROMPT_TEMPLATE.format(input_questions=input_questions)
|
| 501 |
+
if subj.lower() == 'mathematics': return MATHEMATICS_PROMPT_TEMPLATE.format(input_questions=input_questions)
|
| 502 |
+
return GENERAL_CLASSIFICATION_PROMPT.format(input_questions=input_questions)
|
| 503 |
+
|
| 504 |
+
prompt_content = get_nvidia_prompt(subject, f"1. {plain_text[:500]}") # Limit text length
|
| 505 |
+
|
| 506 |
+
try:
|
| 507 |
+
res = requests.post(
|
| 508 |
+
'https://integrate.api.nvidia.com/v1/chat/completions',
|
| 509 |
+
headers={'Authorization': f'Bearer {NVIDIA_API_KEY}', 'Accept': 'application/json', 'Content-Type': 'application/json'},
|
| 510 |
+
json={"model": "nvidia/nemotron-3-nano-30b-a3b", "messages": [{"content": prompt_content, "role": "user"}], "temperature": 0.2, "top_p": 1, "max_tokens": 1024, "stream": False},
|
| 511 |
+
timeout=30
|
| 512 |
+
)
|
| 513 |
+
res.raise_for_status()
|
| 514 |
+
content = res.json()['choices'][0]['message']['content']
|
| 515 |
+
|
| 516 |
+
# Parse JSON from response
|
| 517 |
+
if "```json" in content: content = content.split("```json")[1].split("```")[0].strip()
|
| 518 |
+
elif "```" in content: content = content.split("```")[1].split("```")[0].strip()
|
| 519 |
+
|
| 520 |
+
result_data = json.loads(content)
|
| 521 |
+
suggestions = []
|
| 522 |
+
detected_subject = subject or 'Biology' # Default
|
| 523 |
+
other_subjects = []
|
| 524 |
+
|
| 525 |
+
if result_data.get('data'):
|
| 526 |
+
item = result_data['data'][0]
|
| 527 |
+
# Extract detected subject from AI response
|
| 528 |
+
detected_subject = item.get('subject', subject) or 'Biology'
|
| 529 |
+
# Extract other possible subjects
|
| 530 |
+
other_subjects = item.get('other_possible_subjects', [])
|
| 531 |
+
if isinstance(other_subjects, str):
|
| 532 |
+
other_subjects = [other_subjects]
|
| 533 |
+
|
| 534 |
+
primary = item.get('chapter_title')
|
| 535 |
+
if primary and primary != 'Unclassified':
|
| 536 |
+
suggestions.append(primary)
|
| 537 |
+
others = item.get('other_possible_chapters', [])
|
| 538 |
+
if isinstance(others, list):
|
| 539 |
+
suggestions.extend([c for c in others if c and c != 'Unclassified'])
|
| 540 |
+
|
| 541 |
+
# Always return at least one suggestion
|
| 542 |
+
if not suggestions:
|
| 543 |
+
suggestions = ['Unclassified']
|
| 544 |
+
|
| 545 |
+
return jsonify({
|
| 546 |
+
'success': True,
|
| 547 |
+
'suggestions': suggestions[:5],
|
| 548 |
+
'subject': detected_subject,
|
| 549 |
+
'other_possible_subjects': other_subjects
|
| 550 |
+
})
|
| 551 |
+
|
| 552 |
+
except Exception as e:
|
| 553 |
+
current_app.logger.error(f"Error getting suggestions for {question_id}: {repr(e)}")
|
| 554 |
+
return jsonify({
|
| 555 |
+
'success': True,
|
| 556 |
+
'suggestions': ['Unclassified'],
|
| 557 |
+
'subject': subject or 'Biology',
|
| 558 |
+
'warning': str(e)
|
| 559 |
+
})
|
| 560 |
+
|
| 561 |
+
@neetprep_bp.route('/neetprep/get_suggestions_batch', methods=['POST'])
|
| 562 |
+
@login_required
|
| 563 |
+
def get_neetprep_suggestions_batch():
|
| 564 |
+
"""Batch endpoint for getting topic suggestions for multiple NeetPrep questions at once.
|
| 565 |
+
Requires a subject to be specified. Processes up to 8 questions in a single API call."""
|
| 566 |
+
import os
|
| 567 |
+
from nvidia_prompts import BIOLOGY_PROMPT_TEMPLATE, CHEMISTRY_PROMPT_TEMPLATE, PHYSICS_PROMPT_TEMPLATE, MATHEMATICS_PROMPT_TEMPLATE
|
| 568 |
+
|
| 569 |
+
data = request.json
|
| 570 |
+
question_ids = data.get('question_ids', [])
|
| 571 |
+
subject = data.get('subject')
|
| 572 |
+
|
| 573 |
+
current_app.logger.info(f"[BATCH-NEETPREP] Received request for {len(question_ids)} questions, subject={subject}")
|
| 574 |
+
|
| 575 |
+
if not question_ids:
|
| 576 |
+
return jsonify({'error': 'No question_ids provided'}), 400
|
| 577 |
+
if not subject or subject.lower() == 'auto':
|
| 578 |
+
return jsonify({'error': 'Subject must be specified for batch requests'}), 400
|
| 579 |
+
if len(question_ids) > 8:
|
| 580 |
+
return jsonify({'error': 'Maximum 8 questions per batch'}), 400
|
| 581 |
+
|
| 582 |
+
NVIDIA_API_KEY = os.getenv("NVIDIA_API_KEY")
|
| 583 |
+
if not NVIDIA_API_KEY:
|
| 584 |
+
return jsonify({'error': 'NVIDIA_API_KEY not set'}), 500
|
| 585 |
+
|
| 586 |
+
def get_nvidia_prompt(subj, input_questions):
|
| 587 |
+
if subj.lower() == 'biology': return BIOLOGY_PROMPT_TEMPLATE.format(input_questions=input_questions)
|
| 588 |
+
if subj.lower() == 'chemistry': return CHEMISTRY_PROMPT_TEMPLATE.format(input_questions=input_questions)
|
| 589 |
+
if subj.lower() == 'physics': return PHYSICS_PROMPT_TEMPLATE.format(input_questions=input_questions)
|
| 590 |
+
if subj.lower() == 'mathematics': return MATHEMATICS_PROMPT_TEMPLATE.format(input_questions=input_questions)
|
| 591 |
+
return BIOLOGY_PROMPT_TEMPLATE.format(input_questions=input_questions)
|
| 592 |
+
|
| 593 |
+
try:
|
| 594 |
+
conn = get_db_connection()
|
| 595 |
+
questions_data = []
|
| 596 |
+
|
| 597 |
+
for idx, qid in enumerate(question_ids):
|
| 598 |
+
question = conn.execute('SELECT question_text FROM neetprep_questions WHERE id = ?', (qid,)).fetchone()
|
| 599 |
+
if question and question['question_text']:
|
| 600 |
+
soup = BeautifulSoup(question['question_text'], 'html.parser')
|
| 601 |
+
plain_text = soup.get_text(strip=True)
|
| 602 |
+
if plain_text:
|
| 603 |
+
questions_data.append({'id': qid, 'index': idx + 1, 'text': plain_text[:500]})
|
| 604 |
+
current_app.logger.debug(f"[BATCH-NEETPREP] Question {qid}: got text ({len(plain_text)} chars)")
|
| 605 |
+
else:
|
| 606 |
+
current_app.logger.warning(f"[BATCH-NEETPREP] Question {qid}: empty plain text after HTML strip")
|
| 607 |
+
else:
|
| 608 |
+
current_app.logger.warning(f"[BATCH-NEETPREP] Question {qid}: not found or no question_text")
|
| 609 |
+
|
| 610 |
+
conn.close()
|
| 611 |
+
|
| 612 |
+
current_app.logger.info(f"[BATCH-NEETPREP] Got question text for {len(questions_data)}/{len(question_ids)} questions")
|
| 613 |
+
|
| 614 |
+
if not questions_data:
|
| 615 |
+
return jsonify({'error': 'Could not obtain question text for any questions'}), 400
|
| 616 |
+
|
| 617 |
+
# Build multi-question prompt
|
| 618 |
+
input_questions = "\n".join(f"{q['index']}. {q['text']}" for q in questions_data)
|
| 619 |
+
prompt_content = get_nvidia_prompt(subject, input_questions)
|
| 620 |
+
|
| 621 |
+
current_app.logger.info(f"[BATCH-NEETPREP] Sending {len(questions_data)} questions to NVIDIA API")
|
| 622 |
+
current_app.logger.debug(f"[BATCH-NEETPREP] Prompt preview: {input_questions[:500]}...")
|
| 623 |
+
|
| 624 |
+
# Make single API call for all questions
|
| 625 |
+
res = requests.post(
|
| 626 |
+
'https://integrate.api.nvidia.com/v1/chat/completions',
|
| 627 |
+
headers={'Authorization': f'Bearer {NVIDIA_API_KEY}', 'Accept': 'application/json', 'Content-Type': 'application/json'},
|
| 628 |
+
json={"model": "nvidia/nemotron-3-nano-30b-a3b", "messages": [{"content": prompt_content, "role": "user"}], "temperature": 0.2, "top_p": 1, "max_tokens": 4096, "stream": False},
|
| 629 |
+
timeout=60
|
| 630 |
+
)
|
| 631 |
+
res.raise_for_status()
|
| 632 |
+
content = res.json()['choices'][0]['message']['content']
|
| 633 |
+
|
| 634 |
+
current_app.logger.info(f"[BATCH-NEETPREP] NVIDIA API response length: {len(content)} chars")
|
| 635 |
+
current_app.logger.debug(f"[BATCH-NEETPREP] Raw response: {content[:1000]}...")
|
| 636 |
+
|
| 637 |
+
# Parse JSON from response
|
| 638 |
+
if "```json" in content:
|
| 639 |
+
content = content.split("```json")[1].split("```")[0].strip()
|
| 640 |
+
elif "```" in content:
|
| 641 |
+
content = content.split("```")[1].split("```")[0].strip()
|
| 642 |
+
parsed_data = json.loads(content)
|
| 643 |
+
|
| 644 |
+
current_app.logger.info(f"[BATCH-NEETPREP] Parsed data has {len(parsed_data.get('data', []))} items")
|
| 645 |
+
|
| 646 |
+
# Build results for each question
|
| 647 |
+
results = {}
|
| 648 |
+
fallback_topics = {
|
| 649 |
+
'biology': ['Cell: The Unit of Life', 'Biomolecules', 'Human Reproduction'],
|
| 650 |
+
'chemistry': ['Organic Chemistry – Some Basic Principles and Techniques (GOC)', 'Chemical Bonding and Molecular Structure'],
|
| 651 |
+
'physics': ['Laws of Motion', 'Work, Energy and Power', 'Current Electricity'],
|
| 652 |
+
'mathematics': ['Calculus', 'Algebra', 'Coordinate Geometry']
|
| 653 |
+
}
|
| 654 |
+
|
| 655 |
+
if parsed_data.get('data'):
|
| 656 |
+
data_items = parsed_data['data']
|
| 657 |
+
current_app.logger.info(f"[BATCH-NEETPREP] AI returned {len(data_items)} items, we have {len(questions_data)} questions")
|
| 658 |
+
|
| 659 |
+
# Helper to extract suggestions from item (handles both old and new format)
|
| 660 |
+
def extract_suggestions(item):
|
| 661 |
+
suggestions = []
|
| 662 |
+
# Try new compact format first, then old format
|
| 663 |
+
primary = item.get('ch') or item.get('chapter_title')
|
| 664 |
+
if primary and primary != 'Unclassified':
|
| 665 |
+
suggestions.append(primary)
|
| 666 |
+
# Handle alternatives - new format uses 'alt' (string), old uses 'other_possible_chapters' (list)
|
| 667 |
+
alt = item.get('alt')
|
| 668 |
+
if alt and alt != 'Unclassified' and alt != 'null':
|
| 669 |
+
suggestions.append(alt)
|
| 670 |
+
others = item.get('other_possible_chapters')
|
| 671 |
+
if isinstance(others, list):
|
| 672 |
+
suggestions.extend([c for c in others if c and c != 'Unclassified'])
|
| 673 |
+
return suggestions
|
| 674 |
+
|
| 675 |
+
# Helper to get index from item
|
| 676 |
+
def get_item_index(item):
|
| 677 |
+
return item.get('i') or item.get('index') or 0
|
| 678 |
+
|
| 679 |
+
# Try to match by index first, then fall back to order-based matching
|
| 680 |
+
matched_by_index = 0
|
| 681 |
+
for item in data_items:
|
| 682 |
+
item_index = get_item_index(item)
|
| 683 |
+
current_app.logger.debug(f"[BATCH-NEETPREP] Processing item with index={item_index}: {item}")
|
| 684 |
+
matching_q = next((q for q in questions_data if q['index'] == item_index), None)
|
| 685 |
+
if matching_q:
|
| 686 |
+
matched_by_index += 1
|
| 687 |
+
suggestions = extract_suggestions(item)
|
| 688 |
+
|
| 689 |
+
if not suggestions:
|
| 690 |
+
suggestions = fallback_topics.get(subject.lower(), ['Unclassified'])
|
| 691 |
+
|
| 692 |
+
results[matching_q['id']] = {
|
| 693 |
+
'success': True,
|
| 694 |
+
'suggestions': suggestions[:5],
|
| 695 |
+
'subject': subject,
|
| 696 |
+
'other_possible_subjects': []
|
| 697 |
+
}
|
| 698 |
+
current_app.logger.info(f"[BATCH-NEETPREP] Question {matching_q['id']}: matched by index, suggestions={suggestions[:3]}")
|
| 699 |
+
|
| 700 |
+
# If index matching failed, try order-based matching
|
| 701 |
+
if matched_by_index == 0 and len(data_items) > 0:
|
| 702 |
+
current_app.logger.warning(f"[BATCH-NEETPREP] Index matching failed, trying order-based matching")
|
| 703 |
+
for i, item in enumerate(data_items):
|
| 704 |
+
if i < len(questions_data):
|
| 705 |
+
q = questions_data[i]
|
| 706 |
+
if q['id'] not in results:
|
| 707 |
+
suggestions = extract_suggestions(item)
|
| 708 |
+
|
| 709 |
+
if not suggestions:
|
| 710 |
+
suggestions = fallback_topics.get(subject.lower(), ['Unclassified'])
|
| 711 |
+
|
| 712 |
+
results[q['id']] = {
|
| 713 |
+
'success': True,
|
| 714 |
+
'suggestions': suggestions[:5],
|
| 715 |
+
'subject': subject,
|
| 716 |
+
'other_possible_subjects': []
|
| 717 |
+
}
|
| 718 |
+
current_app.logger.info(f"[BATCH-NEETPREP] Question {q['id']}: matched by order, suggestions={suggestions[:3]}")
|
| 719 |
+
|
| 720 |
+
# Fill in any missing results
|
| 721 |
+
for q in questions_data:
|
| 722 |
+
if q['id'] not in results:
|
| 723 |
+
current_app.logger.warning(f"[BATCH-NEETPREP] Question {q['id']}: using fallback (no match in API response)")
|
| 724 |
+
results[q['id']] = {
|
| 725 |
+
'success': True,
|
| 726 |
+
'suggestions': fallback_topics.get(subject.lower(), ['Unclassified']),
|
| 727 |
+
'subject': subject,
|
| 728 |
+
'other_possible_subjects': []
|
| 729 |
+
}
|
| 730 |
+
|
| 731 |
+
current_app.logger.info(f"[BATCH-NEETPREP] Returning results for {len(results)} questions")
|
| 732 |
+
return jsonify({'success': True, 'results': results})
|
| 733 |
+
|
| 734 |
+
except Exception as e:
|
| 735 |
+
current_app.logger.error(f"Error in batch suggestions: {repr(e)}")
|
| 736 |
+
fallback_result = {
|
| 737 |
+
'success': True,
|
| 738 |
+
'suggestions': ['Unclassified'],
|
| 739 |
+
'subject': subject,
|
| 740 |
+
'other_possible_subjects': []
|
| 741 |
+
}
|
| 742 |
+
return jsonify({
|
| 743 |
+
'success': False,
|
| 744 |
+
'error': str(e),
|
| 745 |
+
'results': {qid: fallback_result for qid in question_ids}
|
| 746 |
+
})
|
| 747 |
+
|
| 748 |
+
# ============== BOOKMARK FEATURE ==============
|
| 749 |
+
|
| 750 |
+
@neetprep_bp.route('/neetprep/collections')
|
| 751 |
+
@login_required
|
| 752 |
+
def get_bookmark_collections():
|
| 753 |
+
"""Get all bookmark collections (sessions of type neetprep_collection) for the user."""
|
| 754 |
+
conn = get_db_connection()
|
| 755 |
+
collections = conn.execute("""
|
| 756 |
+
SELECT s.id, s.name, s.subject, s.tags, s.notes, s.created_at,
|
| 757 |
+
COUNT(b.id) as question_count
|
| 758 |
+
FROM sessions s
|
| 759 |
+
LEFT JOIN neetprep_bookmarks b ON s.id = b.session_id AND b.user_id = ?
|
| 760 |
+
WHERE s.user_id = ? AND s.session_type = 'neetprep_collection'
|
| 761 |
+
GROUP BY s.id
|
| 762 |
+
ORDER BY s.created_at DESC
|
| 763 |
+
""", (current_user.id, current_user.id)).fetchall()
|
| 764 |
+
conn.close()
|
| 765 |
+
return jsonify({'success': True, 'collections': [dict(c) for c in collections]})
|
| 766 |
+
|
| 767 |
+
@neetprep_bp.route('/neetprep/collections/create', methods=['POST'])
|
| 768 |
+
@login_required
|
| 769 |
+
def create_bookmark_collection():
|
| 770 |
+
"""Create a new bookmark collection (session)."""
|
| 771 |
+
import uuid
|
| 772 |
+
data = request.json
|
| 773 |
+
name = data.get('name', 'New Collection')
|
| 774 |
+
subject = data.get('subject', '')
|
| 775 |
+
tags = data.get('tags', '')
|
| 776 |
+
notes = data.get('notes', '')
|
| 777 |
+
|
| 778 |
+
session_id = str(uuid.uuid4())
|
| 779 |
+
conn = get_db_connection()
|
| 780 |
+
conn.execute("""
|
| 781 |
+
INSERT INTO sessions (id, name, subject, tags, notes, user_id, session_type, persist)
|
| 782 |
+
VALUES (?, ?, ?, ?, ?, ?, 'neetprep_collection', 1)
|
| 783 |
+
""", (session_id, name, subject, tags, notes, current_user.id))
|
| 784 |
+
conn.commit()
|
| 785 |
+
conn.close()
|
| 786 |
+
|
| 787 |
+
return jsonify({'success': True, 'session_id': session_id, 'name': name})
|
| 788 |
+
|
| 789 |
+
@neetprep_bp.route('/neetprep/collections/<session_id>', methods=['DELETE'])
|
| 790 |
+
@login_required
|
| 791 |
+
def delete_bookmark_collection(session_id):
|
| 792 |
+
"""Delete a bookmark collection and all its bookmarks."""
|
| 793 |
+
conn = get_db_connection()
|
| 794 |
+
# Verify ownership
|
| 795 |
+
session = conn.execute('SELECT id FROM sessions WHERE id = ? AND user_id = ?', (session_id, current_user.id)).fetchone()
|
| 796 |
+
if not session:
|
| 797 |
+
conn.close()
|
| 798 |
+
return jsonify({'error': 'Collection not found'}), 404
|
| 799 |
+
|
| 800 |
+
conn.execute('DELETE FROM neetprep_bookmarks WHERE session_id = ? AND user_id = ?', (session_id, current_user.id))
|
| 801 |
+
conn.execute('DELETE FROM sessions WHERE id = ?', (session_id,))
|
| 802 |
+
conn.commit()
|
| 803 |
+
conn.close()
|
| 804 |
+
|
| 805 |
+
return jsonify({'success': True})
|
| 806 |
+
|
| 807 |
+
@neetprep_bp.route('/neetprep/collections/<session_id>/update', methods=['POST'])
|
| 808 |
+
@login_required
|
| 809 |
+
def update_bookmark_collection(session_id):
|
| 810 |
+
"""Update collection metadata."""
|
| 811 |
+
data = request.json
|
| 812 |
+
conn = get_db_connection()
|
| 813 |
+
|
| 814 |
+
# Verify ownership
|
| 815 |
+
session = conn.execute('SELECT id FROM sessions WHERE id = ? AND user_id = ?', (session_id, current_user.id)).fetchone()
|
| 816 |
+
if not session:
|
| 817 |
+
conn.close()
|
| 818 |
+
return jsonify({'error': 'Collection not found'}), 404
|
| 819 |
+
|
| 820 |
+
updates = []
|
| 821 |
+
params = []
|
| 822 |
+
if 'name' in data:
|
| 823 |
+
updates.append('name = ?')
|
| 824 |
+
params.append(data['name'])
|
| 825 |
+
if 'subject' in data:
|
| 826 |
+
updates.append('subject = ?')
|
| 827 |
+
params.append(data['subject'])
|
| 828 |
+
if 'tags' in data:
|
| 829 |
+
updates.append('tags = ?')
|
| 830 |
+
params.append(data['tags'])
|
| 831 |
+
if 'notes' in data:
|
| 832 |
+
updates.append('notes = ?')
|
| 833 |
+
params.append(data['notes'])
|
| 834 |
+
|
| 835 |
+
if updates:
|
| 836 |
+
params.append(session_id)
|
| 837 |
+
conn.execute(f"UPDATE sessions SET {', '.join(updates)} WHERE id = ?", params)
|
| 838 |
+
conn.commit()
|
| 839 |
+
|
| 840 |
+
conn.close()
|
| 841 |
+
return jsonify({'success': True})
|
| 842 |
+
|
| 843 |
+
@neetprep_bp.route('/neetprep/bookmark', methods=['POST'])
|
| 844 |
+
@login_required
|
| 845 |
+
def add_bookmark():
|
| 846 |
+
"""Add a question to a collection."""
|
| 847 |
+
data = request.json
|
| 848 |
+
question_id = data.get('question_id')
|
| 849 |
+
session_id = data.get('session_id')
|
| 850 |
+
question_type = data.get('question_type', 'neetprep') # 'neetprep' or 'classified'
|
| 851 |
+
|
| 852 |
+
if not question_id or not session_id:
|
| 853 |
+
return jsonify({'error': 'question_id and session_id are required'}), 400
|
| 854 |
+
|
| 855 |
+
conn = get_db_connection()
|
| 856 |
+
|
| 857 |
+
# Verify session ownership
|
| 858 |
+
session = conn.execute('SELECT id FROM sessions WHERE id = ? AND user_id = ?', (session_id, current_user.id)).fetchone()
|
| 859 |
+
if not session:
|
| 860 |
+
conn.close()
|
| 861 |
+
return jsonify({'error': 'Collection not found'}), 404
|
| 862 |
+
|
| 863 |
+
# Check if already bookmarked
|
| 864 |
+
existing = conn.execute(
|
| 865 |
+
'SELECT id FROM neetprep_bookmarks WHERE user_id = ? AND neetprep_question_id = ? AND session_id = ? AND question_type = ?',
|
| 866 |
+
(current_user.id, str(question_id), session_id, question_type)
|
| 867 |
+
).fetchone()
|
| 868 |
+
|
| 869 |
+
if existing:
|
| 870 |
+
conn.close()
|
| 871 |
+
return jsonify({'success': True, 'message': 'Already bookmarked'})
|
| 872 |
+
|
| 873 |
+
conn.execute(
|
| 874 |
+
'INSERT INTO neetprep_bookmarks (user_id, neetprep_question_id, session_id, question_type) VALUES (?, ?, ?, ?)',
|
| 875 |
+
(current_user.id, str(question_id), session_id, question_type)
|
| 876 |
+
)
|
| 877 |
+
conn.commit()
|
| 878 |
+
conn.close()
|
| 879 |
+
|
| 880 |
+
return jsonify({'success': True})
|
| 881 |
+
|
| 882 |
+
@neetprep_bp.route('/neetprep/bookmark', methods=['DELETE'])
|
| 883 |
+
@login_required
|
| 884 |
+
def remove_bookmark():
|
| 885 |
+
"""Remove a question from a collection."""
|
| 886 |
+
data = request.json
|
| 887 |
+
question_id = data.get('question_id')
|
| 888 |
+
session_id = data.get('session_id')
|
| 889 |
+
question_type = data.get('question_type', 'neetprep')
|
| 890 |
+
|
| 891 |
+
if not question_id or not session_id:
|
| 892 |
+
return jsonify({'error': 'question_id and session_id are required'}), 400
|
| 893 |
+
|
| 894 |
+
conn = get_db_connection()
|
| 895 |
+
|
| 896 |
+
# Verify session ownership
|
| 897 |
+
session = conn.execute('SELECT id FROM sessions WHERE id = ? AND user_id = ?', (session_id, current_user.id)).fetchone()
|
| 898 |
+
if not session:
|
| 899 |
+
conn.close()
|
| 900 |
+
return jsonify({'error': 'Collection not found'}), 404
|
| 901 |
+
|
| 902 |
+
conn.execute(
|
| 903 |
+
'DELETE FROM neetprep_bookmarks WHERE user_id = ? AND neetprep_question_id = ? AND session_id = ? AND question_type = ?',
|
| 904 |
+
(current_user.id, str(question_id), session_id, question_type)
|
| 905 |
+
)
|
| 906 |
+
conn.commit()
|
| 907 |
+
conn.close()
|
| 908 |
+
|
| 909 |
+
return jsonify({'success': True})
|
| 910 |
+
|
| 911 |
+
@neetprep_bp.route('/neetprep/bookmark/bulk', methods=['POST'])
|
| 912 |
+
@login_required
|
| 913 |
+
def bulk_bookmark():
|
| 914 |
+
"""Add multiple neetprep questions to a collection at once."""
|
| 915 |
+
data = request.json
|
| 916 |
+
question_ids = data.get('question_ids', [])
|
| 917 |
+
session_id = data.get('session_id')
|
| 918 |
+
|
| 919 |
+
if not question_ids or not session_id:
|
| 920 |
+
return jsonify({'error': 'question_ids and session_id are required'}), 400
|
| 921 |
+
|
| 922 |
+
conn = get_db_connection()
|
| 923 |
+
|
| 924 |
+
# Verify session ownership
|
| 925 |
+
session = conn.execute('SELECT id FROM sessions WHERE id = ? AND user_id = ?', (session_id, current_user.id)).fetchone()
|
| 926 |
+
if not session:
|
| 927 |
+
conn.close()
|
| 928 |
+
return jsonify({'error': 'Collection not found'}), 404
|
| 929 |
+
|
| 930 |
+
added_count = 0
|
| 931 |
+
for qid in question_ids:
|
| 932 |
+
existing = conn.execute(
|
| 933 |
+
'SELECT id FROM neetprep_bookmarks WHERE user_id = ? AND neetprep_question_id = ? AND session_id = ?',
|
| 934 |
+
(current_user.id, qid, session_id)
|
| 935 |
+
).fetchone()
|
| 936 |
+
|
| 937 |
+
if not existing:
|
| 938 |
+
conn.execute(
|
| 939 |
+
'INSERT INTO neetprep_bookmarks (user_id, neetprep_question_id, session_id) VALUES (?, ?, ?)',
|
| 940 |
+
(current_user.id, qid, session_id)
|
| 941 |
+
)
|
| 942 |
+
added_count += 1
|
| 943 |
+
|
| 944 |
+
conn.commit()
|
| 945 |
+
conn.close()
|
| 946 |
+
|
| 947 |
+
return jsonify({'success': True, 'added_count': added_count})
|
| 948 |
+
|
| 949 |
+
@neetprep_bp.route('/neetprep/collections/<session_id>/questions')
|
| 950 |
+
@login_required
|
| 951 |
+
def get_collection_questions(session_id):
|
| 952 |
+
"""Get all questions in a bookmark collection."""
|
| 953 |
+
conn = get_db_connection()
|
| 954 |
+
|
| 955 |
+
# Verify ownership
|
| 956 |
+
session = conn.execute('SELECT id, name, subject, tags, notes FROM sessions WHERE id = ? AND user_id = ?',
|
| 957 |
+
(session_id, current_user.id)).fetchone()
|
| 958 |
+
if not session:
|
| 959 |
+
conn.close()
|
| 960 |
+
return jsonify({'error': 'Collection not found'}), 404
|
| 961 |
+
|
| 962 |
+
questions = conn.execute("""
|
| 963 |
+
SELECT nq.id, nq.question_text, nq.options, nq.correct_answer_index,
|
| 964 |
+
nq.level, nq.topic, nq.subject, b.created_at as bookmarked_at
|
| 965 |
+
FROM neetprep_bookmarks b
|
| 966 |
+
JOIN neetprep_questions nq ON b.neetprep_question_id = nq.id
|
| 967 |
+
WHERE b.session_id = ? AND b.user_id = ?
|
| 968 |
+
ORDER BY b.created_at DESC
|
| 969 |
+
""", (session_id, current_user.id)).fetchall()
|
| 970 |
+
|
| 971 |
+
conn.close()
|
| 972 |
+
|
| 973 |
+
return jsonify({
|
| 974 |
+
'success': True,
|
| 975 |
+
'collection': dict(session),
|
| 976 |
+
'questions': [dict(q) for q in questions]
|
| 977 |
+
})
|
| 978 |
+
|
| 979 |
+
@neetprep_bp.route('/neetprep/collections/<session_id>/view')
|
| 980 |
+
@login_required
|
| 981 |
+
def view_collection(session_id):
|
| 982 |
+
"""View a bookmark collection with its questions."""
|
| 983 |
+
conn = get_db_connection()
|
| 984 |
+
|
| 985 |
+
# Verify ownership
|
| 986 |
+
session = conn.execute('SELECT id, name, subject, tags, notes, created_at FROM sessions WHERE id = ? AND user_id = ?',
|
| 987 |
+
(session_id, current_user.id)).fetchone()
|
| 988 |
+
if not session:
|
| 989 |
+
conn.close()
|
| 990 |
+
from flask import redirect, flash
|
| 991 |
+
flash('Collection not found', 'danger')
|
| 992 |
+
return redirect(url_for('dashboard.dashboard', filter='collections'))
|
| 993 |
+
|
| 994 |
+
# Fetch neetprep questions
|
| 995 |
+
neetprep_questions = conn.execute("""
|
| 996 |
+
SELECT nq.id, nq.question_text, nq.options, nq.correct_answer_index,
|
| 997 |
+
nq.level, nq.topic, nq.subject, b.created_at as bookmarked_at, 'neetprep' as question_type
|
| 998 |
+
FROM neetprep_bookmarks b
|
| 999 |
+
JOIN neetprep_questions nq ON b.neetprep_question_id = nq.id
|
| 1000 |
+
WHERE b.session_id = ? AND b.user_id = ? AND b.question_type = 'neetprep'
|
| 1001 |
+
ORDER BY nq.topic, b.created_at
|
| 1002 |
+
""", (session_id, current_user.id)).fetchall()
|
| 1003 |
+
|
| 1004 |
+
# Fetch classified questions
|
| 1005 |
+
classified_questions = conn.execute("""
|
| 1006 |
+
SELECT q.id, q.question_text, NULL as options, q.actual_solution as correct_answer_index,
|
| 1007 |
+
NULL as level, q.chapter as topic, q.subject, b.created_at as bookmarked_at, 'classified' as question_type,
|
| 1008 |
+
i.processed_filename as image_filename, q.question_number
|
| 1009 |
+
FROM neetprep_bookmarks b
|
| 1010 |
+
JOIN questions q ON CAST(b.neetprep_question_id AS INTEGER) = q.id
|
| 1011 |
+
LEFT JOIN images i ON q.image_id = i.id
|
| 1012 |
+
WHERE b.session_id = ? AND b.user_id = ? AND b.question_type = 'classified'
|
| 1013 |
+
ORDER BY q.chapter, b.created_at
|
| 1014 |
+
""", (session_id, current_user.id)).fetchall()
|
| 1015 |
+
|
| 1016 |
+
conn.close()
|
| 1017 |
+
|
| 1018 |
+
# Combine all questions
|
| 1019 |
+
all_questions = []
|
| 1020 |
+
for q in neetprep_questions:
|
| 1021 |
+
qd = dict(q)
|
| 1022 |
+
qd['image_filename'] = None
|
| 1023 |
+
all_questions.append(qd)
|
| 1024 |
+
for q in classified_questions:
|
| 1025 |
+
all_questions.append(dict(q))
|
| 1026 |
+
|
| 1027 |
+
# Group questions by topic
|
| 1028 |
+
topics = {}
|
| 1029 |
+
for q in all_questions:
|
| 1030 |
+
topic = q['topic'] or 'Unclassified'
|
| 1031 |
+
if topic not in topics:
|
| 1032 |
+
topics[topic] = []
|
| 1033 |
+
topics[topic].append(q)
|
| 1034 |
+
|
| 1035 |
+
return render_template('collection_view.html',
|
| 1036 |
+
collection=dict(session),
|
| 1037 |
+
questions=all_questions,
|
| 1038 |
+
topics=topics,
|
| 1039 |
+
question_count=len(all_questions))
|
| 1040 |
+
|
| 1041 |
+
@neetprep_bp.route('/neetprep/question/<question_id>/collections')
|
| 1042 |
+
@login_required
|
| 1043 |
+
def get_question_collections(question_id):
|
| 1044 |
+
"""Get which collections a question is bookmarked in."""
|
| 1045 |
+
conn = get_db_connection()
|
| 1046 |
+
|
| 1047 |
+
collections = conn.execute("""
|
| 1048 |
+
SELECT s.id, s.name
|
| 1049 |
+
FROM neetprep_bookmarks b
|
| 1050 |
+
JOIN sessions s ON b.session_id = s.id
|
| 1051 |
+
WHERE b.neetprep_question_id = ? AND b.user_id = ?
|
| 1052 |
+
""", (question_id, current_user.id)).fetchall()
|
| 1053 |
+
|
| 1054 |
+
conn.close()
|
| 1055 |
+
|
| 1056 |
+
return jsonify({
|
| 1057 |
+
'success': True,
|
| 1058 |
+
'collections': [dict(c) for c in collections]
|
| 1059 |
+
})
|
| 1060 |
+
|
| 1061 |
+
@neetprep_bp.route('/neetprep/bookmarks/batch', methods=['POST'])
|
| 1062 |
+
@login_required
|
| 1063 |
+
def get_batch_bookmark_statuses():
|
| 1064 |
+
"""Get bookmark statuses for multiple questions at once."""
|
| 1065 |
+
data = request.get_json()
|
| 1066 |
+
question_ids = data.get('question_ids', [])
|
| 1067 |
+
|
| 1068 |
+
if not question_ids:
|
| 1069 |
+
return jsonify({'success': True, 'bookmarks': {}})
|
| 1070 |
+
|
| 1071 |
+
conn = get_db_connection()
|
| 1072 |
+
|
| 1073 |
+
# Build query for all question IDs
|
| 1074 |
+
placeholders = ','.join(['?' for _ in question_ids])
|
| 1075 |
+
query = f"""
|
| 1076 |
+
SELECT neetprep_question_id, session_id
|
| 1077 |
+
FROM neetprep_bookmarks
|
| 1078 |
+
WHERE neetprep_question_id IN ({placeholders}) AND user_id = ?
|
| 1079 |
+
"""
|
| 1080 |
+
|
| 1081 |
+
bookmarks = conn.execute(query, question_ids + [current_user.id]).fetchall()
|
| 1082 |
+
conn.close()
|
| 1083 |
+
|
| 1084 |
+
# Group by question_id
|
| 1085 |
+
result = {}
|
| 1086 |
+
for b in bookmarks:
|
| 1087 |
+
qid = str(b['neetprep_question_id'])
|
| 1088 |
+
if qid not in result:
|
| 1089 |
+
result[qid] = []
|
| 1090 |
+
result[qid].append(b['session_id'])
|
| 1091 |
+
|
| 1092 |
+
return jsonify({
|
| 1093 |
+
'success': True,
|
| 1094 |
+
'bookmarks': result
|
| 1095 |
+
})
|
| 1096 |
+
|
| 1097 |
+
@neetprep_bp.route('/neetprep/collections/<session_id>/quiz')
|
| 1098 |
+
@login_required
|
| 1099 |
+
def collection_quiz(session_id):
|
| 1100 |
+
"""Start a quiz from a bookmark collection."""
|
| 1101 |
+
conn = get_db_connection()
|
| 1102 |
+
|
| 1103 |
+
# Verify ownership
|
| 1104 |
+
session = conn.execute('SELECT id, name FROM sessions WHERE id = ? AND user_id = ?',
|
| 1105 |
+
(session_id, current_user.id)).fetchone()
|
| 1106 |
+
if not session:
|
| 1107 |
+
conn.close()
|
| 1108 |
+
from flask import redirect, flash
|
| 1109 |
+
flash('Collection not found', 'danger')
|
| 1110 |
+
return redirect(url_for('dashboard.dashboard', filter='collections'))
|
| 1111 |
+
|
| 1112 |
+
all_questions = []
|
| 1113 |
+
|
| 1114 |
+
# Get neetprep bookmarked questions
|
| 1115 |
+
neetprep_questions = conn.execute("""
|
| 1116 |
+
SELECT nq.id, nq.question_text, nq.options, nq.correct_answer_index,
|
| 1117 |
+
nq.level, nq.topic, nq.subject
|
| 1118 |
+
FROM neetprep_bookmarks b
|
| 1119 |
+
JOIN neetprep_questions nq ON b.neetprep_question_id = nq.id
|
| 1120 |
+
WHERE b.session_id = ? AND b.user_id = ? AND b.question_type = 'neetprep'
|
| 1121 |
+
ORDER BY b.created_at
|
| 1122 |
+
""", (session_id, current_user.id)).fetchall()
|
| 1123 |
+
|
| 1124 |
+
for q in neetprep_questions:
|
| 1125 |
+
try:
|
| 1126 |
+
html_content = f"""<html><head><meta charset="utf-8"></head><body>{q['question_text']}</body></html>"""
|
| 1127 |
+
img_filename = f"neetprep_{q['id']}.jpg"
|
| 1128 |
+
img_path = os.path.join(current_app.config['TEMP_FOLDER'], img_filename)
|
| 1129 |
+
imgkit.from_string(html_content, img_path, options={'width': 800})
|
| 1130 |
+
all_questions.append({
|
| 1131 |
+
'image_path': f"/tmp/{img_filename}",
|
| 1132 |
+
'details': {
|
| 1133 |
+
'id': q['id'],
|
| 1134 |
+
'options': json.loads(q['options']) if q['options'] else [],
|
| 1135 |
+
'correct_answer_index': q['correct_answer_index'],
|
| 1136 |
+
'user_answer_index': None,
|
| 1137 |
+
'source': 'neetprep',
|
| 1138 |
+
'topic': q['topic'],
|
| 1139 |
+
'subject': q['subject']
|
| 1140 |
+
}
|
| 1141 |
+
})
|
| 1142 |
+
except Exception as e:
|
| 1143 |
+
current_app.logger.error(f"Failed to convert question {q['id']} to image: {e}")
|
| 1144 |
+
|
| 1145 |
+
# Get classified bookmarked questions
|
| 1146 |
+
classified_questions = conn.execute("""
|
| 1147 |
+
SELECT q.id, q.actual_solution as correct_answer_index, q.marked_solution as user_answer_index,
|
| 1148 |
+
q.chapter as topic, q.subject, i.processed_filename
|
| 1149 |
+
FROM neetprep_bookmarks b
|
| 1150 |
+
JOIN questions q ON CAST(b.neetprep_question_id AS INTEGER) = q.id
|
| 1151 |
+
LEFT JOIN images i ON q.image_id = i.id
|
| 1152 |
+
WHERE b.session_id = ? AND b.user_id = ? AND b.question_type = 'classified'
|
| 1153 |
+
ORDER BY b.created_at
|
| 1154 |
+
""", (session_id, current_user.id)).fetchall()
|
| 1155 |
+
|
| 1156 |
+
for q in classified_questions:
|
| 1157 |
+
if q['processed_filename']:
|
| 1158 |
+
all_questions.append({
|
| 1159 |
+
'image_path': f"/processed/{q['processed_filename']}",
|
| 1160 |
+
'details': {
|
| 1161 |
+
'id': q['id'],
|
| 1162 |
+
'options': [],
|
| 1163 |
+
'correct_answer_index': q['correct_answer_index'],
|
| 1164 |
+
'user_answer_index': q['user_answer_index'],
|
| 1165 |
+
'source': 'classified',
|
| 1166 |
+
'topic': q['topic'],
|
| 1167 |
+
'subject': q['subject']
|
| 1168 |
+
}
|
| 1169 |
+
})
|
| 1170 |
+
|
| 1171 |
+
conn.close()
|
| 1172 |
+
|
| 1173 |
+
if not all_questions:
|
| 1174 |
+
from flask import redirect, flash
|
| 1175 |
+
flash('No questions in this collection', 'warning')
|
| 1176 |
+
return redirect(url_for('neetprep_bp.view_collection', session_id=session_id))
|
| 1177 |
+
|
| 1178 |
+
return render_template('quiz_v2.html', questions=all_questions)
|
| 1179 |
+
|
| 1180 |
+
@neetprep_bp.route('/neetprep/collections/<session_id>/generate', methods=['POST'])
|
| 1181 |
+
@login_required
|
| 1182 |
+
def generate_collection_pdf(session_id):
|
| 1183 |
+
"""Generate a PDF from a bookmark collection."""
|
| 1184 |
+
conn = get_db_connection()
|
| 1185 |
+
|
| 1186 |
+
# Verify ownership
|
| 1187 |
+
session = conn.execute('SELECT id, name, subject FROM sessions WHERE id = ? AND user_id = ?',
|
| 1188 |
+
(session_id, current_user.id)).fetchone()
|
| 1189 |
+
if not session:
|
| 1190 |
+
conn.close()
|
| 1191 |
+
return jsonify({'error': 'Collection not found'}), 404
|
| 1192 |
+
|
| 1193 |
+
# Get neetprep bookmarked questions
|
| 1194 |
+
neetprep_questions = conn.execute("""
|
| 1195 |
+
SELECT nq.id, nq.question_text, nq.options, nq.correct_answer_index,
|
| 1196 |
+
nq.level, nq.topic, nq.subject
|
| 1197 |
+
FROM neetprep_bookmarks b
|
| 1198 |
+
JOIN neetprep_questions nq ON b.neetprep_question_id = nq.id
|
| 1199 |
+
WHERE b.session_id = ? AND b.user_id = ? AND b.question_type = 'neetprep'
|
| 1200 |
+
ORDER BY nq.topic, b.created_at
|
| 1201 |
+
""", (session_id, current_user.id)).fetchall()
|
| 1202 |
+
|
| 1203 |
+
# Get classified bookmarked questions
|
| 1204 |
+
classified_questions = conn.execute("""
|
| 1205 |
+
SELECT q.id, q.question_text, q.actual_solution as correct_answer_index,
|
| 1206 |
+
q.chapter as topic, q.subject, i.processed_filename
|
| 1207 |
+
FROM neetprep_bookmarks b
|
| 1208 |
+
JOIN questions q ON CAST(b.neetprep_question_id AS INTEGER) = q.id
|
| 1209 |
+
LEFT JOIN images i ON q.image_id = i.id
|
| 1210 |
+
WHERE b.session_id = ? AND b.user_id = ? AND b.question_type = 'classified'
|
| 1211 |
+
ORDER BY q.chapter, b.created_at
|
| 1212 |
+
""", (session_id, current_user.id)).fetchall()
|
| 1213 |
+
|
| 1214 |
+
conn.close()
|
| 1215 |
+
|
| 1216 |
+
if not neetprep_questions and not classified_questions:
|
| 1217 |
+
return jsonify({'error': 'No questions in this collection'}), 400
|
| 1218 |
+
|
| 1219 |
+
data = request.json or {}
|
| 1220 |
+
all_questions = []
|
| 1221 |
+
|
| 1222 |
+
for q in neetprep_questions:
|
| 1223 |
+
all_questions.append({
|
| 1224 |
+
"id": q['id'],
|
| 1225 |
+
"question_text": q['question_text'],
|
| 1226 |
+
"options": json.loads(q['options']) if q['options'] else [],
|
| 1227 |
+
"correct_answer_index": q['correct_answer_index'],
|
| 1228 |
+
"user_answer_index": None,
|
| 1229 |
+
"status": "wrong",
|
| 1230 |
+
"source": "neetprep",
|
| 1231 |
+
"custom_fields": {
|
| 1232 |
+
"difficulty": q['level'],
|
| 1233 |
+
"topic": q['topic'],
|
| 1234 |
+
"subject": q['subject']
|
| 1235 |
+
}
|
| 1236 |
+
})
|
| 1237 |
+
|
| 1238 |
+
for q in classified_questions:
|
| 1239 |
+
if q['processed_filename']:
|
| 1240 |
+
# Use absolute path for PDF generation
|
| 1241 |
+
abs_img_path = os.path.abspath(os.path.join(current_app.config['PROCESSED_FOLDER'], q['processed_filename']))
|
| 1242 |
+
all_questions.append({
|
| 1243 |
+
"id": q['id'],
|
| 1244 |
+
"question_text": f"<img src=\"{abs_img_path}\" style=\"max-width:100%;\" />",
|
| 1245 |
+
"options": [],
|
| 1246 |
+
"correct_answer_index": q['correct_answer_index'],
|
| 1247 |
+
"user_answer_index": None,
|
| 1248 |
+
"status": "wrong",
|
| 1249 |
+
"source": "classified",
|
| 1250 |
+
"custom_fields": {
|
| 1251 |
+
"topic": q['topic'],
|
| 1252 |
+
"subject": q['subject']
|
| 1253 |
+
}
|
| 1254 |
+
})
|
| 1255 |
+
|
| 1256 |
+
topics = list(set(q['custom_fields']['topic'] for q in all_questions if q['custom_fields'].get('topic')))
|
| 1257 |
+
|
| 1258 |
+
final_json_output = {
|
| 1259 |
+
"version": "2.1",
|
| 1260 |
+
"test_name": session['name'] or "Bookmark Collection",
|
| 1261 |
+
"config": {"font_size": 22, "auto_generate_pdf": False, "layout": data.get('layout', {})},
|
| 1262 |
+
"metadata": {"source_book": "NeetPrep Collection", "tags": ", ".join(topics)},
|
| 1263 |
+
"questions": all_questions,
|
| 1264 |
+
"view": True
|
| 1265 |
+
}
|
| 1266 |
+
|
| 1267 |
+
try:
|
| 1268 |
+
result, status_code = _process_json_and_generate_pdf(final_json_output, current_user.id)
|
| 1269 |
+
if status_code != 200:
|
| 1270 |
+
return jsonify(result), status_code
|
| 1271 |
+
|
| 1272 |
+
if result.get('success'):
|
| 1273 |
+
return jsonify({'success': True, 'pdf_url': result.get('view_url')})
|
| 1274 |
+
else:
|
| 1275 |
+
return jsonify({'error': result.get('error', 'Failed to generate PDF')}), 500
|
| 1276 |
+
except Exception as e:
|
| 1277 |
+
current_app.logger.error(f"Failed to generate collection PDF: {repr(e)}")
|
| 1278 |
+
return jsonify({'error': str(e)}), 500
|
| 1279 |
+
|
| 1280 |
+
# ============== END BOOKMARK FEATURE ==============
|
| 1281 |
+
|
| 1282 |
def run_hardcoded_query(query_template, **kwargs):
|
| 1283 |
"""Helper function to run a GraphQL query."""
|
| 1284 |
final_query = query_template.format(**kwargs)
|
nvidia_prompts.py
CHANGED
|
@@ -43,8 +43,34 @@ BIOLOGY_PROMPT_TEMPLATE = """**System Role:** You are a specialized Biology ques
|
|
| 43 |
|
| 44 |
1. **Scope**: Analyze the input question. If the question is NOT related to Biology, mark the chapter as 'Unclassified'.
|
| 45 |
2. **Mapping**: Identify the single most relevant chapter from the list above.
|
| 46 |
-
3. **Ambiguity**: If
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
**Output JSON Schema:**
|
| 50 |
|
|
@@ -52,17 +78,16 @@ BIOLOGY_PROMPT_TEMPLATE = """**System Role:** You are a specialized Biology ques
|
|
| 52 |
{{
|
| 53 |
"data": [
|
| 54 |
{{
|
| 55 |
-
"index":
|
| 56 |
-
"subject": "Biology",
|
| 57 |
-
"
|
| 58 |
-
"chapter_title": "<
|
| 59 |
"other_possible_chapters": ["<alternative chapter 1>", "<alternative chapter 2>"],
|
| 60 |
"confidence": <0.0 to 1.0>
|
| 61 |
}}
|
| 62 |
],
|
| 63 |
"success": [true]
|
| 64 |
}}
|
| 65 |
-
|
| 66 |
```
|
| 67 |
|
| 68 |
**Input Questions:**
|
|
@@ -114,26 +139,12 @@ syllabus.
|
|
| 114 |
|
| 115 |
1. **Scope**: Analyze the input question. If the question is NOT related to Chemistry, mark the chapter as 'Unclassified'.
|
| 116 |
2. **Mapping**: Identify the single most relevant chapter from the list above.
|
| 117 |
-
3. **Ambiguity**: If
|
| 118 |
-
4. **Multi-Chapter**: If a question explicitly spans 2 distinct chapters, include the primary one in 'chapter_title' and the secondary in 'other_possible_chapters'.
|
| 119 |
|
| 120 |
-
**Output JSON Schema:**
|
| 121 |
|
| 122 |
```json
|
| 123 |
-
{{
|
| 124 |
-
"data": [
|
| 125 |
-
{{
|
| 126 |
-
"index": <integer matching question number>,
|
| 127 |
-
"subject": "Chemistry",
|
| 128 |
-
"chapter_index": <chapter number or 0>,
|
| 129 |
-
"chapter_title": "<exact chapter title from list or 'Unclassified'>",
|
| 130 |
-
"other_possible_chapters": ["<alternative chapter 1>", "<alternative chapter 2>"],
|
| 131 |
-
"confidence": <0.0 to 1.0>
|
| 132 |
-
}}
|
| 133 |
-
],
|
| 134 |
-
"success": [true]
|
| 135 |
-
}}
|
| 136 |
-
|
| 137 |
```
|
| 138 |
|
| 139 |
**Input Questions:**
|
|
@@ -183,26 +194,12 @@ PHYSICS_PROMPT_TEMPLATE = """**System Role:** You are a specialized Physics ques
|
|
| 183 |
|
| 184 |
1. **Scope**: Analyze the input question. If the question is NOT related to Physics, mark the chapter as 'Unclassified'.
|
| 185 |
2. **Mapping**: Identify the single most relevant chapter from the list above.
|
| 186 |
-
3. **Ambiguity**: If
|
| 187 |
-
4. **Multi-Chapter**: If a question explicitly spans 2 distinct chapters, include the primary one in 'chapter_title' and the secondary in 'other_possible_chapters'.
|
| 188 |
|
| 189 |
-
**Output JSON Schema:**
|
| 190 |
|
| 191 |
```json
|
| 192 |
-
{{
|
| 193 |
-
"data": [
|
| 194 |
-
{{
|
| 195 |
-
"index": <integer matching question number>,
|
| 196 |
-
"subject": "Physics",
|
| 197 |
-
"chapter_index": <chapter number or 0>,
|
| 198 |
-
"chapter_title": "<exact chapter title from list or 'Unclassified'>",
|
| 199 |
-
"other_possible_chapters": ["<alternative chapter 1>", "<alternative chapter 2>"],
|
| 200 |
-
"confidence": <0.0 to 1.0>
|
| 201 |
-
}}
|
| 202 |
-
],
|
| 203 |
-
"success": [true]
|
| 204 |
-
}}
|
| 205 |
-
|
| 206 |
```
|
| 207 |
|
| 208 |
**Input Questions:**
|
|
@@ -253,26 +250,12 @@ syllabus.
|
|
| 253 |
|
| 254 |
1. **Scope**: Analyze the input question. If the question is NOT related to Mathematics, mark the chapter as 'Unclassified'.
|
| 255 |
2. **Mapping**: Identify the single most relevant chapter from the list above.
|
| 256 |
-
3. **Ambiguity**: If
|
| 257 |
-
4. **Multi-Chapter**: If a question explicitly spans 2 distinct chapters, include the primary one in 'chapter_title' and the secondary in 'other_possible_chapters'.
|
| 258 |
|
| 259 |
-
**Output JSON Schema:**
|
| 260 |
|
| 261 |
```json
|
| 262 |
-
{{
|
| 263 |
-
"data": [
|
| 264 |
-
{{
|
| 265 |
-
"index": <integer matching question number>,
|
| 266 |
-
"subject": "Mathematics",
|
| 267 |
-
"chapter_index": <chapter number or 0>,
|
| 268 |
-
"chapter_title": "<exact chapter title from list or 'Unclassified'>",
|
| 269 |
-
"other_possible_chapters": ["<alternative chapter 1>", "<alternative chapter 2>"],
|
| 270 |
-
"confidence": <0.0 to 1.0>
|
| 271 |
-
}}
|
| 272 |
-
],
|
| 273 |
-
"success": [true]
|
| 274 |
-
}}
|
| 275 |
-
|
| 276 |
```
|
| 277 |
|
| 278 |
**Input Questions:**
|
|
|
|
| 43 |
|
| 44 |
1. **Scope**: Analyze the input question. If the question is NOT related to Biology, mark the chapter as 'Unclassified'.
|
| 45 |
2. **Mapping**: Identify the single most relevant chapter from the list above.
|
| 46 |
+
3. **Ambiguity**: If another chapter is also plausible, include it in 'alt' (max 1 alternative).
|
| 47 |
+
|
| 48 |
+
**Output JSON Schema (keep response compact):**
|
| 49 |
+
|
| 50 |
+
```json
|
| 51 |
+
{{"data": [{{"i": <question number>, "ch": "<chapter title>", "alt": "<alternative or null>", "c": <0.0-1.0>}}]}}
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
**Input Questions:**
|
| 55 |
+
{input_questions}
|
| 56 |
+
"""
|
| 57 |
+
|
| 58 |
+
# General prompt that detects subject AND chapter
|
| 59 |
+
GENERAL_CLASSIFICATION_PROMPT = """**System Role:** You are an expert question classifier for NEET/JEE exams. Your task is to:
|
| 60 |
+
1. First identify the SUBJECT (Biology, Chemistry, Physics, or Mathematics)
|
| 61 |
+
2. Then map the question to its corresponding NCERT chapter
|
| 62 |
+
|
| 63 |
+
**Subject Detection:**
|
| 64 |
+
- Biology: Questions about living organisms, cells, genetics, ecology, human body systems, plants, animals, evolution, biotechnology
|
| 65 |
+
- Chemistry: Questions about atoms, molecules, chemical reactions, organic/inorganic compounds, thermodynamics, electrochemistry, periodic table
|
| 66 |
+
- Physics: Questions about motion, forces, energy, waves, electricity, magnetism, optics, modern physics, thermodynamics
|
| 67 |
+
- Mathematics: Questions about algebra, calculus, geometry, trigonometry, probability, statistics, matrices, vectors
|
| 68 |
+
|
| 69 |
+
**Classification Guidelines:**
|
| 70 |
+
1. First determine the subject based on the question content
|
| 71 |
+
2. Then identify the most relevant NCERT chapter for that subject
|
| 72 |
+
3. If ambiguous, list alternatives in 'other_possible_chapters'
|
| 73 |
+
4. Include 'other_possible_subjects' if the question could belong to multiple subjects
|
| 74 |
|
| 75 |
**Output JSON Schema:**
|
| 76 |
|
|
|
|
| 78 |
{{
|
| 79 |
"data": [
|
| 80 |
{{
|
| 81 |
+
"index": 1,
|
| 82 |
+
"subject": "<Biology|Chemistry|Physics|Mathematics>",
|
| 83 |
+
"other_possible_subjects": ["<alternative subject if applicable>"],
|
| 84 |
+
"chapter_title": "<most relevant chapter title>",
|
| 85 |
"other_possible_chapters": ["<alternative chapter 1>", "<alternative chapter 2>"],
|
| 86 |
"confidence": <0.0 to 1.0>
|
| 87 |
}}
|
| 88 |
],
|
| 89 |
"success": [true]
|
| 90 |
}}
|
|
|
|
| 91 |
```
|
| 92 |
|
| 93 |
**Input Questions:**
|
|
|
|
| 139 |
|
| 140 |
1. **Scope**: Analyze the input question. If the question is NOT related to Chemistry, mark the chapter as 'Unclassified'.
|
| 141 |
2. **Mapping**: Identify the single most relevant chapter from the list above.
|
| 142 |
+
3. **Ambiguity**: If another chapter is also plausible, include it in 'alt' (max 1 alternative).
|
|
|
|
| 143 |
|
| 144 |
+
**Output JSON Schema (keep response compact):**
|
| 145 |
|
| 146 |
```json
|
| 147 |
+
{{"data": [{{"i": <question number>, "ch": "<chapter title>", "alt": "<alternative or null>", "c": <0.0-1.0>}}]}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
```
|
| 149 |
|
| 150 |
**Input Questions:**
|
|
|
|
| 194 |
|
| 195 |
1. **Scope**: Analyze the input question. If the question is NOT related to Physics, mark the chapter as 'Unclassified'.
|
| 196 |
2. **Mapping**: Identify the single most relevant chapter from the list above.
|
| 197 |
+
3. **Ambiguity**: If another chapter is also plausible, include it in 'alt' (max 1 alternative).
|
|
|
|
| 198 |
|
| 199 |
+
**Output JSON Schema (keep response compact):**
|
| 200 |
|
| 201 |
```json
|
| 202 |
+
{{"data": [{{"i": <question number>, "ch": "<chapter title>", "alt": "<alternative or null>", "c": <0.0-1.0>}}]}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
```
|
| 204 |
|
| 205 |
**Input Questions:**
|
|
|
|
| 250 |
|
| 251 |
1. **Scope**: Analyze the input question. If the question is NOT related to Mathematics, mark the chapter as 'Unclassified'.
|
| 252 |
2. **Mapping**: Identify the single most relevant chapter from the list above.
|
| 253 |
+
3. **Ambiguity**: If another chapter is also plausible, include it in 'alt' (max 1 alternative).
|
|
|
|
| 254 |
|
| 255 |
+
**Output JSON Schema (keep response compact):**
|
| 256 |
|
| 257 |
```json
|
| 258 |
+
{{"data": [{{"i": <question number>, "ch": "<chapter title>", "alt": "<alternative or null>", "c": <0.0-1.0>}}]}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
```
|
| 260 |
|
| 261 |
**Input Questions:**
|
routes/library.py
CHANGED
|
@@ -56,9 +56,13 @@ def pdf_manager(folder_path=''):
|
|
| 56 |
@login_required
|
| 57 |
def get_pdf_details(pdf_id):
|
| 58 |
conn = get_db_connection()
|
|
|
|
| 59 |
pdf = conn.execute('SELECT * FROM generated_pdfs WHERE id = ? AND user_id = ?', (pdf_id, current_user.id)).fetchone()
|
| 60 |
conn.close()
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
@main_bp.route('/update_pdf_details/<int:pdf_id>', methods=[METHOD_POST])
|
| 64 |
@login_required
|
|
|
|
| 56 |
@login_required
|
| 57 |
def get_pdf_details(pdf_id):
|
| 58 |
conn = get_db_connection()
|
| 59 |
+
current_app.logger.info(f"Fetching details for PDF {pdf_id} for User {current_user.id}")
|
| 60 |
pdf = conn.execute('SELECT * FROM generated_pdfs WHERE id = ? AND user_id = ?', (pdf_id, current_user.id)).fetchone()
|
| 61 |
conn.close()
|
| 62 |
+
if pdf:
|
| 63 |
+
return jsonify(dict(pdf))
|
| 64 |
+
current_app.logger.warning(f"PDF {pdf_id} not found for User {current_user.id}")
|
| 65 |
+
return jsonify({'error': 'PDF not found'}), 404
|
| 66 |
|
| 67 |
@main_bp.route('/update_pdf_details/<int:pdf_id>', methods=[METHOD_POST])
|
| 68 |
@login_required
|
routes/pdf_ops.py
CHANGED
|
@@ -65,7 +65,7 @@ def generate_preview():
|
|
| 65 |
ipp, orientation = int(data.get('images_per_page', 4)), data.get('orientation', 'portrait')
|
| 66 |
rows = int(data['grid_rows']) if data.get('grid_rows') else None
|
| 67 |
cols = int(data['grid_cols']) if data.get('grid_cols') else None
|
| 68 |
-
pdf_bytes = create_a4_pdf_from_images(filtered[:ipp], current_app.config['PROCESSED_FOLDER'],
|
| 69 |
if pdf_bytes:
|
| 70 |
try:
|
| 71 |
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
|
|
|
|
| 65 |
ipp, orientation = int(data.get('images_per_page', 4)), data.get('orientation', 'portrait')
|
| 66 |
rows = int(data['grid_rows']) if data.get('grid_rows') else None
|
| 67 |
cols = int(data['grid_cols']) if data.get('grid_cols') else None
|
| 68 |
+
pdf_bytes = create_a4_pdf_from_images(filtered[:ipp], current_app.config['PROCESSED_FOLDER'], None, ipp, output_folder=None, orientation=orientation, grid_rows=rows, grid_cols=cols, practice_mode=practice_mode, return_bytes=True, font_size_scale=float(data.get('font_size_scale', 1.0)))
|
| 69 |
if pdf_bytes:
|
| 70 |
try:
|
| 71 |
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
|
routes/questions.py
CHANGED
|
@@ -5,57 +5,302 @@ from processing import resize_image_if_needed, call_nim_ocr_api, extract_questio
|
|
| 5 |
from strings import ROUTE_SAVE_QUESTIONS, ROUTE_EXTRACT_QUESTION_NUMBER, ROUTE_EXTRACT_ALL_QUESTION_NUMBERS, METHOD_POST
|
| 6 |
import requests
|
| 7 |
import json
|
| 8 |
-
from nvidia_prompts import BIOLOGY_PROMPT_TEMPLATE, CHEMISTRY_PROMPT_TEMPLATE, PHYSICS_PROMPT_TEMPLATE, MATHEMATICS_PROMPT_TEMPLATE
|
| 9 |
|
| 10 |
NVIDIA_API_KEY = os.getenv("NVIDIA_API_KEY")
|
| 11 |
NVIDIA_NIM_AVAILABLE = bool(NVIDIA_API_KEY)
|
| 12 |
|
| 13 |
def get_nvidia_prompt(subject, input_questions):
|
|
|
|
|
|
|
| 14 |
if subject.lower() == 'biology': return BIOLOGY_PROMPT_TEMPLATE.format(input_questions=input_questions)
|
| 15 |
if subject.lower() == 'chemistry': return CHEMISTRY_PROMPT_TEMPLATE.format(input_questions=input_questions)
|
| 16 |
if subject.lower() == 'physics': return PHYSICS_PROMPT_TEMPLATE.format(input_questions=input_questions)
|
| 17 |
if subject.lower() == 'mathematics': return MATHEMATICS_PROMPT_TEMPLATE.format(input_questions=input_questions)
|
| 18 |
-
|
|
|
|
| 19 |
|
| 20 |
@main_bp.route('/get_topic_suggestions', methods=['POST'])
|
| 21 |
@login_required
|
| 22 |
def get_topic_suggestions():
|
| 23 |
data = request.json
|
| 24 |
question_text, image_id, subject = data.get('question_text'), data.get('image_id'), data.get('subject')
|
| 25 |
-
|
| 26 |
if not question_text and image_id:
|
| 27 |
try:
|
| 28 |
conn = get_db_connection()
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
if os.path.exists(image_path):
|
| 35 |
question_text = " ".join(item['text_prediction']['text'] for item in call_nim_ocr_api(resize_image_if_needed(image_path))['data'][0]['text_detections'])
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
| 38 |
conn.close()
|
| 39 |
except Exception as e: return jsonify({'error': f"OCR failed: {str(e)}"}), 500
|
| 40 |
if not question_text: return jsonify({'error': 'Could not obtain question text.'}), 400
|
|
|
|
| 41 |
prompt_content = get_nvidia_prompt(subject, f"1. {question_text}")
|
| 42 |
-
if not prompt_content: return jsonify({'error': f'Unsupported subject: {subject}'}), 400
|
| 43 |
if not NVIDIA_API_KEY: return jsonify({'error': 'NVIDIA_API_KEY not set'}), 500
|
|
|
|
| 44 |
try:
|
| 45 |
res = requests.post('https://integrate.api.nvidia.com/v1/chat/completions', headers={'Authorization': f'Bearer {NVIDIA_API_KEY}', 'Accept': 'application/json', 'Content-Type': 'application/json'}, json={"model": "nvidia/nemotron-3-nano-30b-a3b", "messages": [{"content": prompt_content, "role": "user"}], "temperature": 0.2, "top_p": 1, "max_tokens": 1024, "stream": False}, timeout=30)
|
| 46 |
res.raise_for_status()
|
| 47 |
content = res.json()['choices'][0]['message']['content']
|
| 48 |
if "```json" in content: content = content.split("```json")[1].split("```")[0].strip()
|
| 49 |
elif "```" in content: content = content.split("```")[1].split("```")[0].strip()
|
| 50 |
-
|
|
|
|
| 51 |
suggestions = []
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
|
| 60 |
@main_bp.route('/classified/update_single', methods=['POST'])
|
| 61 |
@login_required
|
|
|
|
| 5 |
from strings import ROUTE_SAVE_QUESTIONS, ROUTE_EXTRACT_QUESTION_NUMBER, ROUTE_EXTRACT_ALL_QUESTION_NUMBERS, METHOD_POST
|
| 6 |
import requests
|
| 7 |
import json
|
| 8 |
+
from nvidia_prompts import BIOLOGY_PROMPT_TEMPLATE, CHEMISTRY_PROMPT_TEMPLATE, PHYSICS_PROMPT_TEMPLATE, MATHEMATICS_PROMPT_TEMPLATE, GENERAL_CLASSIFICATION_PROMPT
|
| 9 |
|
| 10 |
NVIDIA_API_KEY = os.getenv("NVIDIA_API_KEY")
|
| 11 |
NVIDIA_NIM_AVAILABLE = bool(NVIDIA_API_KEY)
|
| 12 |
|
| 13 |
def get_nvidia_prompt(subject, input_questions):
|
| 14 |
+
if not subject or subject.lower() == 'auto':
|
| 15 |
+
return GENERAL_CLASSIFICATION_PROMPT.format(input_questions=input_questions)
|
| 16 |
if subject.lower() == 'biology': return BIOLOGY_PROMPT_TEMPLATE.format(input_questions=input_questions)
|
| 17 |
if subject.lower() == 'chemistry': return CHEMISTRY_PROMPT_TEMPLATE.format(input_questions=input_questions)
|
| 18 |
if subject.lower() == 'physics': return PHYSICS_PROMPT_TEMPLATE.format(input_questions=input_questions)
|
| 19 |
if subject.lower() == 'mathematics': return MATHEMATICS_PROMPT_TEMPLATE.format(input_questions=input_questions)
|
| 20 |
+
# Default to general prompt for unknown subjects
|
| 21 |
+
return GENERAL_CLASSIFICATION_PROMPT.format(input_questions=input_questions)
|
| 22 |
|
| 23 |
@main_bp.route('/get_topic_suggestions', methods=['POST'])
|
| 24 |
@login_required
|
| 25 |
def get_topic_suggestions():
|
| 26 |
data = request.json
|
| 27 |
question_text, image_id, subject = data.get('question_text'), data.get('image_id'), data.get('subject')
|
| 28 |
+
# Subject is now optional - will use general prompt if not provided
|
| 29 |
if not question_text and image_id:
|
| 30 |
try:
|
| 31 |
conn = get_db_connection()
|
| 32 |
+
# First check if we already have question_text cached
|
| 33 |
+
q_row = conn.execute('SELECT question_text FROM questions WHERE image_id = ?', (image_id,)).fetchone()
|
| 34 |
+
if q_row and q_row['question_text']:
|
| 35 |
+
question_text = q_row['question_text']
|
| 36 |
+
else:
|
| 37 |
+
# Get image directly (works even if questions not saved yet)
|
| 38 |
+
img_row = conn.execute('SELECT processed_filename, session_id FROM images WHERE id = ?', (image_id,)).fetchone()
|
| 39 |
+
if img_row and img_row['processed_filename']:
|
| 40 |
+
image_path = os.path.join(current_app.config['PROCESSED_FOLDER'], img_row['processed_filename'])
|
| 41 |
if os.path.exists(image_path):
|
| 42 |
question_text = " ".join(item['text_prediction']['text'] for item in call_nim_ocr_api(resize_image_if_needed(image_path))['data'][0]['text_detections'])
|
| 43 |
+
# Cache the OCR result if question record exists
|
| 44 |
+
if q_row:
|
| 45 |
+
conn.execute('UPDATE questions SET question_text = ? WHERE image_id = ?', (question_text, image_id))
|
| 46 |
+
conn.commit()
|
| 47 |
conn.close()
|
| 48 |
except Exception as e: return jsonify({'error': f"OCR failed: {str(e)}"}), 500
|
| 49 |
if not question_text: return jsonify({'error': 'Could not obtain question text.'}), 400
|
| 50 |
+
|
| 51 |
prompt_content = get_nvidia_prompt(subject, f"1. {question_text}")
|
|
|
|
| 52 |
if not NVIDIA_API_KEY: return jsonify({'error': 'NVIDIA_API_KEY not set'}), 500
|
| 53 |
+
|
| 54 |
try:
|
| 55 |
res = requests.post('https://integrate.api.nvidia.com/v1/chat/completions', headers={'Authorization': f'Bearer {NVIDIA_API_KEY}', 'Accept': 'application/json', 'Content-Type': 'application/json'}, json={"model": "nvidia/nemotron-3-nano-30b-a3b", "messages": [{"content": prompt_content, "role": "user"}], "temperature": 0.2, "top_p": 1, "max_tokens": 1024, "stream": False}, timeout=30)
|
| 56 |
res.raise_for_status()
|
| 57 |
content = res.json()['choices'][0]['message']['content']
|
| 58 |
if "```json" in content: content = content.split("```json")[1].split("```")[0].strip()
|
| 59 |
elif "```" in content: content = content.split("```")[1].split("```")[0].strip()
|
| 60 |
+
parsed_data = json.loads(content)
|
| 61 |
+
|
| 62 |
suggestions = []
|
| 63 |
+
detected_subject = subject # Default to input subject
|
| 64 |
+
other_subjects = []
|
| 65 |
+
|
| 66 |
+
if parsed_data.get('data'):
|
| 67 |
+
item = parsed_data['data'][0]
|
| 68 |
+
# Extract detected subject from AI response
|
| 69 |
+
detected_subject = item.get('subject', subject) or subject
|
| 70 |
+
# Extract other possible subjects
|
| 71 |
+
other_subjects = item.get('other_possible_subjects', [])
|
| 72 |
+
if isinstance(other_subjects, str):
|
| 73 |
+
other_subjects = [other_subjects]
|
| 74 |
+
|
| 75 |
+
primary = item.get('chapter_title')
|
| 76 |
+
if primary and primary != 'Unclassified':
|
| 77 |
+
suggestions.append(primary)
|
| 78 |
+
if 'other_possible_chapters' in item:
|
| 79 |
+
others = item['other_possible_chapters']
|
| 80 |
+
if isinstance(others, list): suggestions.extend([c for c in others if c and c != 'Unclassified'])
|
| 81 |
+
|
| 82 |
+
# Always return at least one suggestion with fallback
|
| 83 |
+
if not suggestions:
|
| 84 |
+
fallback_topics = {
|
| 85 |
+
'biology': ['Cell: The Unit of Life', 'Biomolecules', 'Human Reproduction', 'Molecular Basis of Inheritance', 'Ecology'],
|
| 86 |
+
'chemistry': ['Organic Chemistry – Some Basic Principles and Techniques (GOC)', 'Chemical Bonding and Molecular Structure', 'Thermodynamics', 'Electrochemistry'],
|
| 87 |
+
'physics': ['Laws of Motion', 'Work, Energy and Power', 'Current Electricity', 'Electromagnetic Induction'],
|
| 88 |
+
'mathematics': ['Calculus', 'Algebra', 'Coordinate Geometry', 'Probability']
|
| 89 |
+
}
|
| 90 |
+
subj_key = (detected_subject or 'biology').lower()
|
| 91 |
+
suggestions = fallback_topics.get(subj_key, ['Unclassified'])
|
| 92 |
+
|
| 93 |
+
return jsonify({
|
| 94 |
+
'success': True,
|
| 95 |
+
'suggestions': suggestions[:5],
|
| 96 |
+
'subject': detected_subject,
|
| 97 |
+
'other_possible_subjects': other_subjects,
|
| 98 |
+
'full_response': parsed_data
|
| 99 |
+
})
|
| 100 |
+
except Exception as e:
|
| 101 |
+
return jsonify({
|
| 102 |
+
'success': True,
|
| 103 |
+
'suggestions': ['Unclassified'],
|
| 104 |
+
'subject': subject or 'Biology',
|
| 105 |
+
'other_possible_subjects': [],
|
| 106 |
+
'error': str(e)
|
| 107 |
+
})
|
| 108 |
+
|
| 109 |
+
@main_bp.route('/get_topic_suggestions_batch', methods=['POST'])
|
| 110 |
+
@login_required
|
| 111 |
+
def get_topic_suggestions_batch():
|
| 112 |
+
"""Batch endpoint for getting topic suggestions for multiple images at once.
|
| 113 |
+
Requires a subject to be specified. Processes up to 8 questions in a single API call."""
|
| 114 |
+
data = request.json
|
| 115 |
+
image_ids = data.get('image_ids', [])
|
| 116 |
+
subject = data.get('subject')
|
| 117 |
+
|
| 118 |
+
current_app.logger.info(f"[BATCH] Received request for {len(image_ids)} images, subject={subject}")
|
| 119 |
+
|
| 120 |
+
if not image_ids:
|
| 121 |
+
return jsonify({'error': 'No image_ids provided'}), 400
|
| 122 |
+
if not subject or subject.lower() == 'auto':
|
| 123 |
+
return jsonify({'error': 'Subject must be specified for batch requests'}), 400
|
| 124 |
+
if len(image_ids) > 8:
|
| 125 |
+
return jsonify({'error': 'Maximum 8 images per batch'}), 400
|
| 126 |
+
|
| 127 |
+
if not NVIDIA_API_KEY:
|
| 128 |
+
return jsonify({'error': 'NVIDIA_API_KEY not set'}), 500
|
| 129 |
+
|
| 130 |
+
try:
|
| 131 |
+
conn = get_db_connection()
|
| 132 |
+
questions_data = [] # List of {image_id, index, question_text}
|
| 133 |
+
|
| 134 |
+
for idx, image_id in enumerate(image_ids):
|
| 135 |
+
# Verify ownership
|
| 136 |
+
owner = conn.execute("SELECT s.user_id FROM images i JOIN sessions s ON i.session_id = s.id WHERE i.id = ?", (image_id,)).fetchone()
|
| 137 |
+
if not owner or owner['user_id'] != current_user.id:
|
| 138 |
+
current_app.logger.warning(f"[BATCH] Skipping image {image_id}: unauthorized")
|
| 139 |
+
continue # Skip unauthorized images
|
| 140 |
+
|
| 141 |
+
question_text = None
|
| 142 |
+
# First check if we already have question_text cached
|
| 143 |
+
q_row = conn.execute('SELECT question_text FROM questions WHERE image_id = ?', (image_id,)).fetchone()
|
| 144 |
+
if q_row and q_row['question_text']:
|
| 145 |
+
question_text = q_row['question_text']
|
| 146 |
+
current_app.logger.debug(f"[BATCH] Image {image_id}: using cached question_text")
|
| 147 |
+
else:
|
| 148 |
+
# Get image directly
|
| 149 |
+
img_row = conn.execute('SELECT processed_filename FROM images WHERE id = ?', (image_id,)).fetchone()
|
| 150 |
+
if img_row and img_row['processed_filename']:
|
| 151 |
+
image_path = os.path.join(current_app.config['PROCESSED_FOLDER'], img_row['processed_filename'])
|
| 152 |
+
if os.path.exists(image_path):
|
| 153 |
+
try:
|
| 154 |
+
current_app.logger.info(f"[BATCH] Image {image_id}: running OCR on {img_row['processed_filename']}")
|
| 155 |
+
question_text = " ".join(item['text_prediction']['text'] for item in call_nim_ocr_api(resize_image_if_needed(image_path))['data'][0]['text_detections'])
|
| 156 |
+
# Cache the OCR result if question record exists
|
| 157 |
+
if q_row:
|
| 158 |
+
conn.execute('UPDATE questions SET question_text = ? WHERE image_id = ?', (question_text, image_id))
|
| 159 |
+
conn.commit()
|
| 160 |
+
except Exception as ocr_err:
|
| 161 |
+
current_app.logger.error(f"[BATCH] Image {image_id}: OCR failed - {ocr_err}")
|
| 162 |
+
else:
|
| 163 |
+
current_app.logger.warning(f"[BATCH] Image {image_id}: file not found at {image_path}")
|
| 164 |
+
else:
|
| 165 |
+
current_app.logger.warning(f"[BATCH] Image {image_id}: no processed_filename in DB")
|
| 166 |
+
|
| 167 |
+
if question_text:
|
| 168 |
+
questions_data.append({'image_id': image_id, 'index': idx + 1, 'question_text': question_text})
|
| 169 |
+
else:
|
| 170 |
+
current_app.logger.warning(f"[BATCH] Image {image_id}: no question_text obtained")
|
| 171 |
+
|
| 172 |
+
conn.close()
|
| 173 |
+
|
| 174 |
+
current_app.logger.info(f"[BATCH] Got question text for {len(questions_data)}/{len(image_ids)} images")
|
| 175 |
+
|
| 176 |
+
if not questions_data:
|
| 177 |
+
return jsonify({'error': 'Could not obtain question text for any images'}), 400
|
| 178 |
+
|
| 179 |
+
# Build multi-question prompt
|
| 180 |
+
input_questions = "\n".join(f"{q['index']}. {q['question_text']}" for q in questions_data)
|
| 181 |
+
prompt_content = get_nvidia_prompt(subject, input_questions)
|
| 182 |
+
|
| 183 |
+
current_app.logger.info(f"[BATCH] Sending {len(questions_data)} questions to NVIDIA API")
|
| 184 |
+
current_app.logger.debug(f"[BATCH] Prompt preview: {input_questions[:500]}...")
|
| 185 |
+
|
| 186 |
+
# Make single API call for all questions
|
| 187 |
+
res = requests.post(
|
| 188 |
+
'https://integrate.api.nvidia.com/v1/chat/completions',
|
| 189 |
+
headers={'Authorization': f'Bearer {NVIDIA_API_KEY}', 'Accept': 'application/json', 'Content-Type': 'application/json'},
|
| 190 |
+
json={"model": "nvidia/nemotron-3-nano-30b-a3b", "messages": [{"content": prompt_content, "role": "user"}], "temperature": 0.2, "top_p": 1, "max_tokens": 2048, "stream": False},
|
| 191 |
+
timeout=60
|
| 192 |
+
)
|
| 193 |
+
res.raise_for_status()
|
| 194 |
+
content = res.json()['choices'][0]['message']['content']
|
| 195 |
+
|
| 196 |
+
current_app.logger.info(f"[BATCH] NVIDIA API response length: {len(content)} chars")
|
| 197 |
+
current_app.logger.debug(f"[BATCH] Raw response: {content[:1000]}...")
|
| 198 |
+
|
| 199 |
+
# Parse JSON from response
|
| 200 |
+
if "```json" in content:
|
| 201 |
+
content = content.split("```json")[1].split("```")[0].strip()
|
| 202 |
+
elif "```" in content:
|
| 203 |
+
content = content.split("```")[1].split("```")[0].strip()
|
| 204 |
+
parsed_data = json.loads(content)
|
| 205 |
+
|
| 206 |
+
current_app.logger.info(f"[BATCH] Parsed data has {len(parsed_data.get('data', []))} items")
|
| 207 |
+
|
| 208 |
+
# Build results for each image
|
| 209 |
+
results = {}
|
| 210 |
+
fallback_topics = {
|
| 211 |
+
'biology': ['Cell: The Unit of Life', 'Biomolecules', 'Human Reproduction'],
|
| 212 |
+
'chemistry': ['Organic Chemistry – Some Basic Principles and Techniques (GOC)', 'Chemical Bonding and Molecular Structure'],
|
| 213 |
+
'physics': ['Laws of Motion', 'Work, Energy and Power', 'Current Electricity'],
|
| 214 |
+
'mathematics': ['Calculus', 'Algebra', 'Coordinate Geometry']
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
if parsed_data.get('data'):
|
| 218 |
+
data_items = parsed_data['data']
|
| 219 |
+
current_app.logger.info(f"[BATCH] AI returned {len(data_items)} items, we have {len(questions_data)} questions")
|
| 220 |
+
|
| 221 |
+
# Try to match by index first, then fall back to order-based matching
|
| 222 |
+
matched_by_index = 0
|
| 223 |
+
for item in data_items:
|
| 224 |
+
item_index = item.get('index', 0)
|
| 225 |
+
current_app.logger.debug(f"[BATCH] Processing item with index={item_index}: {item}")
|
| 226 |
+
# Find the matching question by our sequential index
|
| 227 |
+
matching_q = next((q for q in questions_data if q['index'] == item_index), None)
|
| 228 |
+
if matching_q:
|
| 229 |
+
matched_by_index += 1
|
| 230 |
+
suggestions = []
|
| 231 |
+
primary = item.get('chapter_title')
|
| 232 |
+
if primary and primary != 'Unclassified':
|
| 233 |
+
suggestions.append(primary)
|
| 234 |
+
if 'other_possible_chapters' in item:
|
| 235 |
+
others = item['other_possible_chapters']
|
| 236 |
+
if isinstance(others, list):
|
| 237 |
+
suggestions.extend([c for c in others if c and c != 'Unclassified'])
|
| 238 |
+
|
| 239 |
+
if not suggestions:
|
| 240 |
+
suggestions = fallback_topics.get(subject.lower(), ['Unclassified'])
|
| 241 |
+
|
| 242 |
+
results[matching_q['image_id']] = {
|
| 243 |
+
'success': True,
|
| 244 |
+
'suggestions': suggestions[:5],
|
| 245 |
+
'subject': subject,
|
| 246 |
+
'other_possible_subjects': []
|
| 247 |
+
}
|
| 248 |
+
current_app.logger.info(f"[BATCH] Image {matching_q['image_id']}: matched by index, suggestions={suggestions[:3]}")
|
| 249 |
+
|
| 250 |
+
# If index matching failed, try order-based matching
|
| 251 |
+
if matched_by_index == 0 and len(data_items) > 0:
|
| 252 |
+
current_app.logger.warning(f"[BATCH] Index matching failed, trying order-based matching")
|
| 253 |
+
for i, item in enumerate(data_items):
|
| 254 |
+
if i < len(questions_data):
|
| 255 |
+
q = questions_data[i]
|
| 256 |
+
if q['image_id'] not in results:
|
| 257 |
+
suggestions = []
|
| 258 |
+
primary = item.get('chapter_title')
|
| 259 |
+
if primary and primary != 'Unclassified':
|
| 260 |
+
suggestions.append(primary)
|
| 261 |
+
if 'other_possible_chapters' in item:
|
| 262 |
+
others = item['other_possible_chapters']
|
| 263 |
+
if isinstance(others, list):
|
| 264 |
+
suggestions.extend([c for c in others if c and c != 'Unclassified'])
|
| 265 |
+
|
| 266 |
+
if not suggestions:
|
| 267 |
+
suggestions = fallback_topics.get(subject.lower(), ['Unclassified'])
|
| 268 |
+
|
| 269 |
+
results[q['image_id']] = {
|
| 270 |
+
'success': True,
|
| 271 |
+
'suggestions': suggestions[:5],
|
| 272 |
+
'subject': subject,
|
| 273 |
+
'other_possible_subjects': []
|
| 274 |
+
}
|
| 275 |
+
current_app.logger.info(f"[BATCH] Image {q['image_id']}: matched by order, suggestions={suggestions[:3]}")
|
| 276 |
+
|
| 277 |
+
# Fill in any missing results
|
| 278 |
+
for q in questions_data:
|
| 279 |
+
if q['image_id'] not in results:
|
| 280 |
+
current_app.logger.warning(f"[BATCH] Image {q['image_id']}: using fallback (no match in API response)")
|
| 281 |
+
results[q['image_id']] = {
|
| 282 |
+
'success': True,
|
| 283 |
+
'suggestions': fallback_topics.get(subject.lower(), ['Unclassified']),
|
| 284 |
+
'subject': subject,
|
| 285 |
+
'other_possible_subjects': []
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
return jsonify({'success': True, 'results': results})
|
| 289 |
+
|
| 290 |
+
except Exception as e:
|
| 291 |
+
# Return error but with fallback results for each image
|
| 292 |
+
fallback_result = {
|
| 293 |
+
'success': True,
|
| 294 |
+
'suggestions': ['Unclassified'],
|
| 295 |
+
'subject': subject,
|
| 296 |
+
'other_possible_subjects': [],
|
| 297 |
+
'error': str(e)
|
| 298 |
+
}
|
| 299 |
+
return jsonify({
|
| 300 |
+
'success': False,
|
| 301 |
+
'error': str(e),
|
| 302 |
+
'results': {img_id: fallback_result for img_id in image_ids}
|
| 303 |
+
})
|
| 304 |
|
| 305 |
@main_bp.route('/classified/update_single', methods=['POST'])
|
| 306 |
@login_required
|
templates/collection_view.html
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}{{ collection.name or 'Collection' }}{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block head %}
|
| 6 |
+
<style>
|
| 7 |
+
body, html {
|
| 8 |
+
background-color: #212529;
|
| 9 |
+
}
|
| 10 |
+
.collection-header {
|
| 11 |
+
background-color: #2b3035;
|
| 12 |
+
border-radius: 8px;
|
| 13 |
+
padding: 20px;
|
| 14 |
+
margin-bottom: 20px;
|
| 15 |
+
}
|
| 16 |
+
.topic-section {
|
| 17 |
+
background-color: #2b3035;
|
| 18 |
+
border-radius: 8px;
|
| 19 |
+
margin-bottom: 15px;
|
| 20 |
+
}
|
| 21 |
+
.topic-header {
|
| 22 |
+
background-color: #343a40;
|
| 23 |
+
padding: 12px 15px;
|
| 24 |
+
border-radius: 8px 8px 0 0;
|
| 25 |
+
cursor: pointer;
|
| 26 |
+
}
|
| 27 |
+
.topic-header:hover {
|
| 28 |
+
background-color: #3d444b;
|
| 29 |
+
}
|
| 30 |
+
.topic-body {
|
| 31 |
+
padding: 15px;
|
| 32 |
+
}
|
| 33 |
+
.question-card {
|
| 34 |
+
background-color: #343a40;
|
| 35 |
+
border-radius: 6px;
|
| 36 |
+
padding: 15px;
|
| 37 |
+
margin-bottom: 10px;
|
| 38 |
+
position: relative;
|
| 39 |
+
}
|
| 40 |
+
.question-card:last-child {
|
| 41 |
+
margin-bottom: 0;
|
| 42 |
+
}
|
| 43 |
+
.question-text {
|
| 44 |
+
font-size: 0.95rem;
|
| 45 |
+
color: #e9ecef;
|
| 46 |
+
margin-bottom: 10px;
|
| 47 |
+
}
|
| 48 |
+
.option-list {
|
| 49 |
+
list-style-type: upper-alpha;
|
| 50 |
+
padding-left: 25px;
|
| 51 |
+
margin-bottom: 10px;
|
| 52 |
+
}
|
| 53 |
+
.option-list li {
|
| 54 |
+
padding: 4px 0;
|
| 55 |
+
color: #adb5bd;
|
| 56 |
+
}
|
| 57 |
+
.option-list li.correct {
|
| 58 |
+
color: #28a745;
|
| 59 |
+
font-weight: 600;
|
| 60 |
+
}
|
| 61 |
+
.question-meta {
|
| 62 |
+
font-size: 0.8rem;
|
| 63 |
+
color: #6c757d;
|
| 64 |
+
}
|
| 65 |
+
.remove-btn {
|
| 66 |
+
position: absolute;
|
| 67 |
+
top: 10px;
|
| 68 |
+
right: 10px;
|
| 69 |
+
opacity: 0.5;
|
| 70 |
+
}
|
| 71 |
+
.remove-btn:hover {
|
| 72 |
+
opacity: 1;
|
| 73 |
+
}
|
| 74 |
+
.empty-state {
|
| 75 |
+
text-align: center;
|
| 76 |
+
padding: 60px 20px;
|
| 77 |
+
color: #6c757d;
|
| 78 |
+
}
|
| 79 |
+
.empty-state i {
|
| 80 |
+
font-size: 4rem;
|
| 81 |
+
margin-bottom: 20px;
|
| 82 |
+
}
|
| 83 |
+
</style>
|
| 84 |
+
{% endblock %}
|
| 85 |
+
|
| 86 |
+
{% block content %}
|
| 87 |
+
<div class="container-fluid mt-4" style="width: 90%; margin: auto;">
|
| 88 |
+
<!-- Header -->
|
| 89 |
+
<div class="collection-header">
|
| 90 |
+
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2">
|
| 91 |
+
<div>
|
| 92 |
+
<h2 class="mb-2">
|
| 93 |
+
<i class="bi bi-bookmark-fill text-warning me-2"></i>
|
| 94 |
+
<span id="collection-name">{{ collection.name or 'Untitled Collection' }}</span>
|
| 95 |
+
<button class="btn btn-link text-secondary btn-sm" id="edit-name-btn" title="Edit name">
|
| 96 |
+
<i class="bi bi-pencil"></i>
|
| 97 |
+
</button>
|
| 98 |
+
</h2>
|
| 99 |
+
<p class="text-muted mb-0">
|
| 100 |
+
<span class="badge bg-secondary">{{ question_count }} questions</span>
|
| 101 |
+
{% if collection.subject %}
|
| 102 |
+
<span class="badge bg-info">{{ collection.subject }}</span>
|
| 103 |
+
{% endif %}
|
| 104 |
+
{% if collection.tags %}
|
| 105 |
+
<span class="badge bg-primary">{{ collection.tags }}</span>
|
| 106 |
+
{% endif %}
|
| 107 |
+
</p>
|
| 108 |
+
</div>
|
| 109 |
+
<div class="d-flex gap-2">
|
| 110 |
+
{% if question_count > 0 %}
|
| 111 |
+
<button class="btn btn-primary" id="start-quiz-btn">
|
| 112 |
+
<i class="bi bi-play-fill me-1"></i>Start Quiz
|
| 113 |
+
</button>
|
| 114 |
+
{% endif %}
|
| 115 |
+
<button class="btn btn-success" id="generate-pdf-btn">
|
| 116 |
+
<i class="bi bi-file-pdf me-1"></i>Generate PDF
|
| 117 |
+
</button>
|
| 118 |
+
<a href="{{ url_for('dashboard.dashboard', filter='collections') }}" class="btn btn-outline-secondary">
|
| 119 |
+
<i class="bi bi-arrow-left me-1"></i>Back
|
| 120 |
+
</a>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
|
| 125 |
+
<!-- Questions by Topic -->
|
| 126 |
+
{% if topics %}
|
| 127 |
+
{% for topic, questions_list in topics.items() %}
|
| 128 |
+
<div class="topic-section">
|
| 129 |
+
<div class="topic-header d-flex justify-content-between align-items-center" data-bs-toggle="collapse" data-bs-target="#topic-{{ loop.index }}">
|
| 130 |
+
<div>
|
| 131 |
+
<i class="bi bi-folder-fill text-warning me-2"></i>
|
| 132 |
+
<strong>{{ topic }}</strong>
|
| 133 |
+
<span class="badge bg-secondary ms-2">{{ questions_list|length }}</span>
|
| 134 |
+
</div>
|
| 135 |
+
<i class="bi bi-chevron-down"></i>
|
| 136 |
+
</div>
|
| 137 |
+
<div class="collapse show topic-body" id="topic-{{ loop.index }}">
|
| 138 |
+
{% for q in questions_list %}
|
| 139 |
+
<div class="question-card" data-question-id="{{ q.id }}" data-question-type="{{ q.question_type }}">
|
| 140 |
+
<button class="btn btn-outline-danger btn-sm remove-btn" title="Remove from collection">
|
| 141 |
+
<i class="bi bi-x-lg"></i>
|
| 142 |
+
</button>
|
| 143 |
+
|
| 144 |
+
<!-- Question Number Badge -->
|
| 145 |
+
<div class="d-flex justify-content-between align-items-start mb-2">
|
| 146 |
+
<span class="badge bg-dark">#{{ loop.index }}</span>
|
| 147 |
+
{% if q.correct_answer_index is not none %}
|
| 148 |
+
<span class="badge bg-success">Answer: {{ 'ABCDEFGH'[q.correct_answer_index|int] if q.correct_answer_index|int < 8 else q.correct_answer_index }}</span>
|
| 149 |
+
{% endif %}
|
| 150 |
+
</div>
|
| 151 |
+
|
| 152 |
+
{% if q.image_filename %}
|
| 153 |
+
<div class="question-image-container mb-2">
|
| 154 |
+
<img src="/processed/{{ q.image_filename }}" class="img-fluid rounded" alt="Question" style="max-height: 300px;">
|
| 155 |
+
</div>
|
| 156 |
+
{% else %}
|
| 157 |
+
<div class="question-text">{{ q.question_text or 'No question text available' }}</div>
|
| 158 |
+
{% endif %}
|
| 159 |
+
{% if q.options %}
|
| 160 |
+
<ol class="option-list">
|
| 161 |
+
{% for opt in q.options|from_json %}
|
| 162 |
+
<li class="{{ 'correct' if loop.index0 == q.correct_answer_index else '' }}">{{ opt }}</li>
|
| 163 |
+
{% endfor %}
|
| 164 |
+
</ol>
|
| 165 |
+
{% endif %}
|
| 166 |
+
<div class="question-meta d-flex flex-wrap gap-2 align-items-center">
|
| 167 |
+
{% if q.level %}
|
| 168 |
+
<span><i class="bi bi-speedometer2 me-1"></i>{{ q.level }}</span>
|
| 169 |
+
{% endif %}
|
| 170 |
+
<span><i class="bi bi-book me-1"></i>{{ q.subject or 'Unknown' }}</span>
|
| 171 |
+
<span class="badge {{ 'bg-info' if q.question_type == 'neetprep' else 'bg-success' }}">{{ q.question_type }}</span>
|
| 172 |
+
{% if q.question_number %}
|
| 173 |
+
<span><i class="bi bi-hash me-1"></i>Q{{ q.question_number }}</span>
|
| 174 |
+
{% endif %}
|
| 175 |
+
</div>
|
| 176 |
+
</div>
|
| 177 |
+
{% endfor %}
|
| 178 |
+
</div>
|
| 179 |
+
</div>
|
| 180 |
+
{% endfor %}
|
| 181 |
+
{% else %}
|
| 182 |
+
<div class="empty-state">
|
| 183 |
+
<i class="bi bi-bookmark"></i>
|
| 184 |
+
<h4>No Questions Yet</h4>
|
| 185 |
+
<p>Questions you bookmark during quizzes will appear here.</p>
|
| 186 |
+
<a href="{{ url_for('neetprep_bp.index') }}" class="btn btn-primary">
|
| 187 |
+
<i class="bi bi-play-fill me-1"></i>Start a Quiz
|
| 188 |
+
</a>
|
| 189 |
+
</div>
|
| 190 |
+
{% endif %}
|
| 191 |
+
</div>
|
| 192 |
+
|
| 193 |
+
<!-- Edit Name Modal -->
|
| 194 |
+
<div class="modal fade" id="editNameModal" tabindex="-1">
|
| 195 |
+
<div class="modal-dialog modal-dialog-centered">
|
| 196 |
+
<div class="modal-content bg-dark text-white">
|
| 197 |
+
<div class="modal-header border-secondary">
|
| 198 |
+
<h5 class="modal-title">Edit Collection Name</h5>
|
| 199 |
+
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
| 200 |
+
</div>
|
| 201 |
+
<div class="modal-body">
|
| 202 |
+
<input type="text" id="new-name-input" class="form-control bg-secondary text-white border-secondary"
|
| 203 |
+
value="{{ collection.name or '' }}" placeholder="Collection name...">
|
| 204 |
+
</div>
|
| 205 |
+
<div class="modal-footer border-secondary">
|
| 206 |
+
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
| 207 |
+
<button type="button" class="btn btn-primary" id="save-name-btn">Save</button>
|
| 208 |
+
</div>
|
| 209 |
+
</div>
|
| 210 |
+
</div>
|
| 211 |
+
</div>
|
| 212 |
+
{% endblock %}
|
| 213 |
+
|
| 214 |
+
{% block scripts %}
|
| 215 |
+
<script>
|
| 216 |
+
const sessionId = '{{ collection.id }}';
|
| 217 |
+
|
| 218 |
+
// Edit name
|
| 219 |
+
document.getElementById('edit-name-btn').onclick = () => {
|
| 220 |
+
new bootstrap.Modal(document.getElementById('editNameModal')).show();
|
| 221 |
+
};
|
| 222 |
+
|
| 223 |
+
document.getElementById('save-name-btn').onclick = async () => {
|
| 224 |
+
const newName = document.getElementById('new-name-input').value.trim();
|
| 225 |
+
if (!newName) return;
|
| 226 |
+
|
| 227 |
+
try {
|
| 228 |
+
const res = await fetch(`/neetprep/collections/${sessionId}/update`, {
|
| 229 |
+
method: 'POST',
|
| 230 |
+
headers: { 'Content-Type': 'application/json' },
|
| 231 |
+
body: JSON.stringify({ name: newName })
|
| 232 |
+
});
|
| 233 |
+
const data = await res.json();
|
| 234 |
+
if (data.success) {
|
| 235 |
+
document.getElementById('collection-name').textContent = newName;
|
| 236 |
+
bootstrap.Modal.getInstance(document.getElementById('editNameModal')).hide();
|
| 237 |
+
}
|
| 238 |
+
} catch(e) {
|
| 239 |
+
alert('Error updating name');
|
| 240 |
+
}
|
| 241 |
+
};
|
| 242 |
+
|
| 243 |
+
// Generate PDF
|
| 244 |
+
document.getElementById('generate-pdf-btn').onclick = async function() {
|
| 245 |
+
const btn = this;
|
| 246 |
+
btn.disabled = true;
|
| 247 |
+
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Generating...';
|
| 248 |
+
|
| 249 |
+
try {
|
| 250 |
+
const res = await fetch(`/neetprep/collections/${sessionId}/generate`, {
|
| 251 |
+
method: 'POST',
|
| 252 |
+
headers: { 'Content-Type': 'application/json' },
|
| 253 |
+
body: JSON.stringify({})
|
| 254 |
+
});
|
| 255 |
+
const data = await res.json();
|
| 256 |
+
btn.disabled = false;
|
| 257 |
+
btn.innerHTML = '<i class="bi bi-file-pdf me-1"></i>Generate PDF';
|
| 258 |
+
|
| 259 |
+
if (data.success && data.pdf_url) {
|
| 260 |
+
window.open(data.pdf_url, '_blank');
|
| 261 |
+
} else {
|
| 262 |
+
alert('Error: ' + (data.error || 'Failed to generate PDF'));
|
| 263 |
+
}
|
| 264 |
+
} catch(e) {
|
| 265 |
+
btn.disabled = false;
|
| 266 |
+
btn.innerHTML = '<i class="bi bi-file-pdf me-1"></i>Generate PDF';
|
| 267 |
+
alert('Error generating PDF');
|
| 268 |
+
}
|
| 269 |
+
};
|
| 270 |
+
|
| 271 |
+
// Start Quiz
|
| 272 |
+
const quizBtn = document.getElementById('start-quiz-btn');
|
| 273 |
+
if (quizBtn) {
|
| 274 |
+
quizBtn.onclick = function() {
|
| 275 |
+
window.location.href = `/neetprep/collections/${sessionId}/quiz`;
|
| 276 |
+
};
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
// Remove from collection
|
| 280 |
+
document.querySelectorAll('.remove-btn').forEach(btn => {
|
| 281 |
+
btn.onclick = async function() {
|
| 282 |
+
const card = this.closest('.question-card');
|
| 283 |
+
const questionId = card.dataset.questionId;
|
| 284 |
+
const questionType = card.dataset.questionType;
|
| 285 |
+
|
| 286 |
+
if (!confirm('Remove this question from the collection?')) return;
|
| 287 |
+
|
| 288 |
+
try {
|
| 289 |
+
const res = await fetch('/neetprep/bookmark', {
|
| 290 |
+
method: 'DELETE',
|
| 291 |
+
headers: { 'Content-Type': 'application/json' },
|
| 292 |
+
body: JSON.stringify({ question_id: questionId, session_id: sessionId, question_type: questionType })
|
| 293 |
+
});
|
| 294 |
+
const data = await res.json();
|
| 295 |
+
if (data.success) {
|
| 296 |
+
card.remove();
|
| 297 |
+
// Update count in header
|
| 298 |
+
const countBadge = document.querySelector('.collection-header .badge.bg-secondary');
|
| 299 |
+
if (countBadge) {
|
| 300 |
+
const currentCount = parseInt(countBadge.textContent);
|
| 301 |
+
countBadge.textContent = `${currentCount - 1} questions`;
|
| 302 |
+
}
|
| 303 |
+
}
|
| 304 |
+
} catch(e) {
|
| 305 |
+
alert('Error removing question');
|
| 306 |
+
}
|
| 307 |
+
};
|
| 308 |
+
});
|
| 309 |
+
</script>
|
| 310 |
+
{% endblock %}
|
templates/dashboard.html
CHANGED
|
@@ -109,6 +109,22 @@
|
|
| 109 |
font-weight: 600;
|
| 110 |
color: #4db8ff;
|
| 111 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
</style>
|
| 113 |
{% endblock %}
|
| 114 |
|
|
@@ -119,6 +135,21 @@
|
|
| 119 |
<button id="delete-selected-btn" class="btn btn-danger" style="display: none;">Delete Selected</button>
|
| 120 |
</div>
|
| 121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
<div class="table-responsive">
|
| 123 |
<table class="table table-hover table-striped">
|
| 124 |
<thead>
|
|
@@ -128,7 +159,7 @@
|
|
| 128 |
<th scope="col" class="name-column">Name</th>
|
| 129 |
<th scope="col">Created At</th>
|
| 130 |
<th scope="col">Pages</th>
|
| 131 |
-
<th scope="col">Questions</th>
|
| 132 |
{% if show_size %}
|
| 133 |
<th scope="col">Size</th>
|
| 134 |
{% endif %}
|
|
@@ -161,7 +192,11 @@
|
|
| 161 |
</td>
|
| 162 |
<td>
|
| 163 |
<div class="btn-group" role="group">
|
| 164 |
-
{% if session.session_type == '
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
<a href="{{ url_for('main.color_rm_interface', session_id=session.id, image_index=0) }}" class="btn btn-sm btn-warning text-dark">Color RM</a>
|
| 166 |
<button class="btn btn-sm btn-info toggle-persist-btn">Toggle Persist</button>
|
| 167 |
{% if show_size %}
|
|
@@ -331,6 +366,38 @@ $(document).ready(function() {
|
|
| 331 |
}
|
| 332 |
});
|
| 333 |
{% endif %}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
});
|
| 335 |
</script>
|
| 336 |
{% endblock %}
|
|
|
|
| 109 |
font-weight: 600;
|
| 110 |
color: #4db8ff;
|
| 111 |
}
|
| 112 |
+
|
| 113 |
+
/* Filter tabs styling */
|
| 114 |
+
.nav-pills .nav-link {
|
| 115 |
+
color: #adb5bd;
|
| 116 |
+
background-color: transparent;
|
| 117 |
+
border: 1px solid #495057;
|
| 118 |
+
}
|
| 119 |
+
.nav-pills .nav-link:hover {
|
| 120 |
+
color: #fff;
|
| 121 |
+
background-color: #343a40;
|
| 122 |
+
}
|
| 123 |
+
.nav-pills .nav-link.active {
|
| 124 |
+
color: #fff !important;
|
| 125 |
+
background-color: #0d6efd !important;
|
| 126 |
+
border-color: #0d6efd;
|
| 127 |
+
}
|
| 128 |
</style>
|
| 129 |
{% endblock %}
|
| 130 |
|
|
|
|
| 135 |
<button id="delete-selected-btn" class="btn btn-danger" style="display: none;">Delete Selected</button>
|
| 136 |
</div>
|
| 137 |
|
| 138 |
+
<!-- Filter tabs -->
|
| 139 |
+
<ul class="nav nav-pills mb-3">
|
| 140 |
+
<li class="nav-item">
|
| 141 |
+
<a class="nav-link {{ 'active' if filter_type == 'all' else '' }}" href="{{ url_for('dashboard.dashboard') }}{% if show_size %}?size=1{% endif %}">All</a>
|
| 142 |
+
</li>
|
| 143 |
+
<li class="nav-item">
|
| 144 |
+
<a class="nav-link {{ 'active' if filter_type == 'standard' else '' }}" href="{{ url_for('dashboard.dashboard', filter='standard') }}{% if show_size %}&size=1{% endif %}">Standard Sessions</a>
|
| 145 |
+
</li>
|
| 146 |
+
<li class="nav-item">
|
| 147 |
+
<a class="nav-link {{ 'active' if filter_type == 'collections' else '' }}" href="{{ url_for('dashboard.dashboard', filter='collections') }}{% if show_size %}&size=1{% endif %}">
|
| 148 |
+
<i class="bi bi-bookmark-fill me-1"></i>NEETprep Collections
|
| 149 |
+
</a>
|
| 150 |
+
</li>
|
| 151 |
+
</ul>
|
| 152 |
+
|
| 153 |
<div class="table-responsive">
|
| 154 |
<table class="table table-hover table-striped">
|
| 155 |
<thead>
|
|
|
|
| 159 |
<th scope="col" class="name-column">Name</th>
|
| 160 |
<th scope="col">Created At</th>
|
| 161 |
<th scope="col">Pages</th>
|
| 162 |
+
<th scope="col">{{ 'Bookmarks' if filter_type == 'collections' else 'Questions' }}</th>
|
| 163 |
{% if show_size %}
|
| 164 |
<th scope="col">Size</th>
|
| 165 |
{% endif %}
|
|
|
|
| 192 |
</td>
|
| 193 |
<td>
|
| 194 |
<div class="btn-group" role="group">
|
| 195 |
+
{% if session.session_type == 'neetprep_collection' %}
|
| 196 |
+
<a href="{{ url_for('neetprep_bp.view_collection', session_id=session.id) }}" class="btn btn-sm btn-warning text-dark"><i class="bi bi-bookmark-fill me-1"></i>View</a>
|
| 197 |
+
<button class="btn btn-sm btn-success generate-collection-pdf-btn" data-session-id="{{ session.id }}"><i class="bi bi-file-pdf me-1"></i>PDF</button>
|
| 198 |
+
<button class="btn btn-sm btn-info toggle-persist-btn">Toggle Persist</button>
|
| 199 |
+
{% elif session.session_type == 'color_rm' %}
|
| 200 |
<a href="{{ url_for('main.color_rm_interface', session_id=session.id, image_index=0) }}" class="btn btn-sm btn-warning text-dark">Color RM</a>
|
| 201 |
<button class="btn btn-sm btn-info toggle-persist-btn">Toggle Persist</button>
|
| 202 |
{% if show_size %}
|
|
|
|
| 366 |
}
|
| 367 |
});
|
| 368 |
{% endif %}
|
| 369 |
+
|
| 370 |
+
// Generate PDF from collection
|
| 371 |
+
$('.generate-collection-pdf-btn').on('click', function() {
|
| 372 |
+
const button = $(this);
|
| 373 |
+
const sessionId = button.data('session-id');
|
| 374 |
+
button.prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-1"></span>Generating...');
|
| 375 |
+
|
| 376 |
+
$.ajax({
|
| 377 |
+
url: `/neetprep/collections/${sessionId}/generate`,
|
| 378 |
+
type: 'POST',
|
| 379 |
+
contentType: 'application/json',
|
| 380 |
+
data: JSON.stringify({}),
|
| 381 |
+
success: function(response) {
|
| 382 |
+
button.prop('disabled', false).html('<i class="bi bi-file-pdf me-1"></i>PDF');
|
| 383 |
+
if (response.success) {
|
| 384 |
+
// Open PDF in new tab
|
| 385 |
+
window.open(response.pdf_url, '_blank');
|
| 386 |
+
} else {
|
| 387 |
+
alert('Error generating PDF: ' + (response.error || 'Unknown error'));
|
| 388 |
+
}
|
| 389 |
+
},
|
| 390 |
+
error: function(xhr) {
|
| 391 |
+
button.prop('disabled', false).html('<i class="bi bi-file-pdf me-1"></i>PDF');
|
| 392 |
+
try {
|
| 393 |
+
const response = JSON.parse(xhr.responseText);
|
| 394 |
+
alert('Error generating PDF: ' + response.error);
|
| 395 |
+
} catch(e) {
|
| 396 |
+
alert('Error generating PDF');
|
| 397 |
+
}
|
| 398 |
+
}
|
| 399 |
+
});
|
| 400 |
+
});
|
| 401 |
});
|
| 402 |
</script>
|
| 403 |
{% endblock %}
|
templates/neetprep.html
CHANGED
|
@@ -4,122 +4,350 @@
|
|
| 4 |
|
| 5 |
{% block head %}
|
| 6 |
<style>
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
white-space: normal;
|
| 10 |
-
max-width: 250px;
|
| 11 |
-
}
|
| 12 |
-
@media (max-width: 768px) {
|
| 13 |
-
.name-column {
|
| 14 |
-
max-width: 25%;
|
| 15 |
-
}
|
| 16 |
}
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
}
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
}
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
position: sticky;
|
| 28 |
-
|
| 29 |
-
z-index: 2;
|
| 30 |
background-color: #212529;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
}
|
| 32 |
</style>
|
| 33 |
{% endblock %}
|
| 34 |
|
| 35 |
{% block content %}
|
| 36 |
-
<div class="container-fluid mt-
|
| 37 |
-
<
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
</div>
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
{% if neetprep_enabled %}
|
| 43 |
-
<
|
| 44 |
-
|
| 45 |
-
<div class="
|
| 46 |
-
|
| 47 |
-
<
|
| 48 |
-
|
| 49 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
</div>
|
| 51 |
-
</div>
|
| 52 |
-
<div class="d-flex flex-wrap gap-2 align-items-center">
|
| 53 |
-
<button id="classify-btn" class="btn btn-warning">Classify Unclassified Questions</button>
|
| 54 |
-
<span class="ms-3">({{ unclassified_count }} unclassified)</span>
|
| 55 |
</div>
|
| 56 |
{% else %}
|
| 57 |
-
<div class="
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
| 59 |
</div>
|
| 60 |
{% endif %}
|
| 61 |
-
|
| 62 |
-
<div id="sync-status" class="mt-3"></div>
|
| 63 |
-
<div id="pdf-link-container" class="mt-3"></div>
|
| 64 |
-
|
| 65 |
-
<hr class="my-4">
|
| 66 |
-
|
| 67 |
-
<h4>Filter by Subject</h4>
|
| 68 |
-
<form id="subject-filter-form" method="get" action="{{ url_for('neetprep_bp.index') }}" class="mb-3">
|
| 69 |
-
<div class="row">
|
| 70 |
-
<div class="col-md-4">
|
| 71 |
-
<select name="subject" id="subject" class="form-select" onchange="this.form.submit()">
|
| 72 |
-
{% for subject in available_subjects %}
|
| 73 |
-
<option value="{{ subject }}" {% if subject == selected_subject %}selected{% endif %}>{{ subject }}</option>
|
| 74 |
-
{% endfor %}
|
| 75 |
-
</select>
|
| 76 |
-
</div>
|
| 77 |
-
</div>
|
| 78 |
-
</form>
|
| 79 |
|
| 80 |
-
<
|
| 81 |
-
<
|
| 82 |
-
<div class="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
{% if neetprep_enabled %}
|
| 84 |
-
<
|
| 85 |
-
<
|
| 86 |
{% endif %}
|
| 87 |
-
|
| 88 |
-
<
|
|
|
|
| 89 |
</div>
|
|
|
|
| 90 |
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
<td>{{ loop.index }}</td>
|
| 108 |
-
<td><input class="form-check-input" type="checkbox" value="{{ item.topic }}"></td>
|
| 109 |
-
<td class="name-column">{{ item.topic }}</td>
|
| 110 |
-
{% if neetprep_enabled %}<td><span class="badge bg-secondary">{{ item.neetprep_count }}</span></td>{% endif %}
|
| 111 |
-
<td><span class="badge bg-info">{{ item.my_questions_count }}</span></td>
|
| 112 |
-
</tr>
|
| 113 |
-
{% endfor %}
|
| 114 |
-
{% else %}
|
| 115 |
-
<tr>
|
| 116 |
-
<td colspan="{% if neetprep_enabled %}5{% else %}4{% endif %}"><i>No topics found. {% if neetprep_enabled %}Sync with NeetPrep to fetch questions and topics.{% else %}No classified topics found.{% endif %}</i></td>
|
| 117 |
-
</tr>
|
| 118 |
{% endif %}
|
| 119 |
-
<
|
| 120 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
</div>
|
| 122 |
-
<button type="submit" name="pdf_type" value="selected" class="btn btn-info mt-2" {% if not topics %}disabled{% endif %}>Generate PDF for Selected Topics</button>
|
| 123 |
</form>
|
| 124 |
</div>
|
| 125 |
</div>
|
|
@@ -130,66 +358,87 @@
|
|
| 130 |
<script>
|
| 131 |
function loadSettings() {
|
| 132 |
const settings = JSON.parse(localStorage.getItem('pdfGeneratorSettings'));
|
| 133 |
-
console.log("Loaded settings from localStorage:", settings);
|
| 134 |
if (settings) {
|
| 135 |
return settings;
|
| 136 |
-
} else {
|
| 137 |
-
console.log("No settings found in localStorage, using default.");
|
| 138 |
-
return {
|
| 139 |
-
images_per_page: 4,
|
| 140 |
-
orientation: 'portrait',
|
| 141 |
-
grid_rows: 4,
|
| 142 |
-
grid_cols: 1,
|
| 143 |
-
practice_mode: 'none'
|
| 144 |
-
};
|
| 145 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
}
|
| 147 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
// Only attach event listeners if NeetPrep is enabled
|
| 149 |
{% if neetprep_enabled %}
|
| 150 |
document.getElementById('sync-btn').addEventListener('click', async () => {
|
| 151 |
const syncStatus = document.getElementById('sync-status');
|
| 152 |
const forceSync = document.getElementById('force-sync-checkbox').checked;
|
| 153 |
-
syncStatus.innerHTML = '<div class="alert alert-info">Syncing... This may take a while.</div>';
|
| 154 |
-
|
| 155 |
try {
|
| 156 |
-
const response = await fetch('/neetprep/sync', {
|
| 157 |
method: 'POST',
|
| 158 |
-
headers: {
|
| 159 |
-
'Content-Type': 'application/json'
|
| 160 |
-
},
|
| 161 |
body: JSON.stringify({ force: forceSync })
|
| 162 |
});
|
| 163 |
const result = await response.json();
|
| 164 |
-
|
| 165 |
if (response.ok) {
|
| 166 |
-
syncStatus.innerHTML = `<div class="alert alert-success">${result.status}</div>`;
|
| 167 |
-
// Reload the page to show updated topics
|
| 168 |
setTimeout(() => window.location.reload(), 2000);
|
| 169 |
} else {
|
| 170 |
-
syncStatus.innerHTML = `<div class="alert alert-danger">Error: ${result.error || 'Unknown error'}</div>`;
|
| 171 |
}
|
| 172 |
} catch (error) {
|
| 173 |
-
syncStatus.innerHTML = `<div class="alert alert-danger">Request failed: ${error}</div>`;
|
| 174 |
}
|
| 175 |
});
|
| 176 |
|
| 177 |
document.getElementById('classify-btn').addEventListener('click', async () => {
|
| 178 |
const syncStatus = document.getElementById('sync-status');
|
| 179 |
-
syncStatus.innerHTML = '<div class="alert alert-info">Classification started... This will take time due to rate limits.</div>';
|
| 180 |
-
|
| 181 |
try {
|
| 182 |
const response = await fetch('/neetprep/classify', { method: 'POST' });
|
| 183 |
const result = await response.json();
|
| 184 |
-
|
| 185 |
if (response.ok) {
|
| 186 |
-
syncStatus.innerHTML = `<div class="alert alert-success">${result.status}</div>`;
|
| 187 |
setTimeout(() => window.location.reload(), 2000);
|
| 188 |
} else {
|
| 189 |
-
syncStatus.innerHTML = `<div class="alert alert-danger">Error: ${result.error || 'Unknown error'}</div>`;
|
| 190 |
}
|
| 191 |
} catch (error) {
|
| 192 |
-
syncStatus.innerHTML = `<div class="alert alert-danger">Request failed: ${error}</div>`;
|
| 193 |
}
|
| 194 |
});
|
| 195 |
{% endif %}
|
|
@@ -199,18 +448,19 @@ document.getElementById('generate-pdf-form').addEventListener('submit', async (e
|
|
| 199 |
const pdfType = e.submitter.value;
|
| 200 |
const statusDiv = document.getElementById('sync-status');
|
| 201 |
const settings = loadSettings();
|
|
|
|
|
|
|
| 202 |
if (pdfType === 'quiz') {
|
| 203 |
-
const selectedTopics = Array.from(document.querySelectorAll('#topic-list
|
| 204 |
if (selectedTopics.length === 0) {
|
| 205 |
-
statusDiv.innerHTML = '<div class="alert alert-warning">Please select at least one topic for the quiz.</div>';
|
| 206 |
return;
|
| 207 |
}
|
| 208 |
|
| 209 |
-
// For quiz, we do a full page navigation, so we create a form and submit it.
|
| 210 |
const form = document.createElement('form');
|
| 211 |
form.method = 'POST';
|
| 212 |
form.action = '/neetprep/generate';
|
| 213 |
-
|
| 214 |
const typeInput = document.createElement('input');
|
| 215 |
typeInput.type = 'hidden';
|
| 216 |
typeInput.name = 'type';
|
|
@@ -223,14 +473,20 @@ document.getElementById('generate-pdf-form').addEventListener('submit', async (e
|
|
| 223 |
topicsInput.value = JSON.stringify(selectedTopics);
|
| 224 |
form.appendChild(topicsInput);
|
| 225 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
document.body.appendChild(form);
|
| 227 |
form.submit();
|
| 228 |
-
return;
|
| 229 |
}
|
| 230 |
|
| 231 |
-
|
| 232 |
-
let payload = {
|
| 233 |
type: pdfType,
|
|
|
|
| 234 |
layout: {
|
| 235 |
images_per_page: settings.images_per_page,
|
| 236 |
orientation: settings.orientation,
|
|
@@ -241,16 +497,15 @@ document.getElementById('generate-pdf-form').addEventListener('submit', async (e
|
|
| 241 |
};
|
| 242 |
|
| 243 |
if (pdfType === 'selected') {
|
| 244 |
-
const selectedTopics = Array.from(document.querySelectorAll('#topic-list
|
| 245 |
if (selectedTopics.length === 0) {
|
| 246 |
-
statusDiv.innerHTML = '<div class="alert alert-warning">Please select at least one topic.</div>';
|
| 247 |
return;
|
| 248 |
}
|
| 249 |
payload.topics = selectedTopics;
|
| 250 |
}
|
| 251 |
|
| 252 |
-
|
| 253 |
-
statusDiv.innerHTML = '<div class="alert alert-info">Generating PDF...</div>';
|
| 254 |
|
| 255 |
try {
|
| 256 |
const response = await fetch('/neetprep/generate', {
|
|
@@ -261,24 +516,18 @@ document.getElementById('generate-pdf-form').addEventListener('submit', async (e
|
|
| 261 |
const result = await response.json();
|
| 262 |
|
| 263 |
if (response.ok && result.success) {
|
| 264 |
-
statusDiv.innerHTML = '<div class="alert alert-success">PDF generated!</div>';
|
| 265 |
if(result.pdf_url) {
|
| 266 |
const pdfLinkContainer = document.getElementById('pdf-link-container');
|
| 267 |
-
|
| 268 |
-
link.href = result.pdf_url;
|
| 269 |
-
link.textContent = 'View Generated PDF';
|
| 270 |
-
link.className = 'btn btn-outline-light';
|
| 271 |
-
link.target = '_blank';
|
| 272 |
-
pdfLinkContainer.innerHTML = ''; // Clear previous links
|
| 273 |
-
pdfLinkContainer.appendChild(link);
|
| 274 |
window.open(result.pdf_url, '_blank');
|
| 275 |
}
|
| 276 |
} else {
|
| 277 |
-
statusDiv.innerHTML = `<div class="alert alert-danger">Error: ${result.error || 'PDF generation failed.'}</div>`;
|
| 278 |
}
|
| 279 |
} catch (error) {
|
| 280 |
-
statusDiv.innerHTML = `<div class="alert alert-danger">Request failed: ${error}</div>`;
|
| 281 |
}
|
| 282 |
});
|
| 283 |
</script>
|
| 284 |
-
{% endblock %}
|
|
|
|
| 4 |
|
| 5 |
{% block head %}
|
| 6 |
<style>
|
| 7 |
+
body, html {
|
| 8 |
+
background-color: #212529;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
}
|
| 10 |
+
|
| 11 |
+
.page-header {
|
| 12 |
+
background: linear-gradient(135deg, #1a1d21 0%, #2b3035 100%);
|
| 13 |
+
border-radius: 12px;
|
| 14 |
+
padding: 24px;
|
| 15 |
+
margin-bottom: 24px;
|
| 16 |
+
border: 1px solid #343a40;
|
| 17 |
}
|
| 18 |
+
|
| 19 |
+
.page-header h2 {
|
| 20 |
+
margin: 0;
|
| 21 |
+
font-weight: 600;
|
| 22 |
+
color: #fff;
|
| 23 |
}
|
| 24 |
+
|
| 25 |
+
.section-card {
|
| 26 |
+
background-color: #2b3035;
|
| 27 |
+
border-radius: 12px;
|
| 28 |
+
padding: 20px;
|
| 29 |
+
margin-bottom: 20px;
|
| 30 |
+
border: 1px solid #343a40;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
.section-title {
|
| 34 |
+
font-size: 1rem;
|
| 35 |
+
font-weight: 600;
|
| 36 |
+
color: #adb5bd;
|
| 37 |
+
text-transform: uppercase;
|
| 38 |
+
letter-spacing: 0.5px;
|
| 39 |
+
margin-bottom: 16px;
|
| 40 |
+
display: flex;
|
| 41 |
+
align-items: center;
|
| 42 |
+
gap: 8px;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
.section-title i {
|
| 46 |
+
color: #6c757d;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
/* Sync controls */
|
| 50 |
+
.sync-controls {
|
| 51 |
+
display: flex;
|
| 52 |
+
flex-wrap: wrap;
|
| 53 |
+
gap: 12px;
|
| 54 |
+
align-items: center;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
.sync-btn {
|
| 58 |
+
display: flex;
|
| 59 |
+
align-items: center;
|
| 60 |
+
gap: 8px;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
/* Source filter pills */
|
| 64 |
+
.source-filter-group {
|
| 65 |
+
display: flex;
|
| 66 |
+
flex-wrap: wrap;
|
| 67 |
+
gap: 8px;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
.source-filter-group .btn-check:checked + .btn {
|
| 71 |
+
box-shadow: 0 0 0 2px rgba(13, 110, 253, 0.5);
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
/* Topic table */
|
| 75 |
+
.topic-table-container {
|
| 76 |
+
max-height: 350px;
|
| 77 |
+
overflow-y: auto;
|
| 78 |
+
border-radius: 8px;
|
| 79 |
+
border: 1px solid #343a40;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
.topic-table {
|
| 83 |
+
margin-bottom: 0;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
.topic-table thead th {
|
| 87 |
position: sticky;
|
| 88 |
+
top: 0;
|
|
|
|
| 89 |
background-color: #212529;
|
| 90 |
+
border-bottom: 2px solid #495057;
|
| 91 |
+
font-weight: 600;
|
| 92 |
+
font-size: 0.85rem;
|
| 93 |
+
color: #adb5bd;
|
| 94 |
+
text-transform: uppercase;
|
| 95 |
+
letter-spacing: 0.3px;
|
| 96 |
+
padding: 12px 10px;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
.topic-table tbody td {
|
| 100 |
+
padding: 10px;
|
| 101 |
+
vertical-align: middle;
|
| 102 |
+
border-color: #343a40;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
.topic-table tbody tr:hover {
|
| 106 |
+
background-color: rgba(13, 110, 253, 0.1);
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.topic-name {
|
| 110 |
+
font-weight: 500;
|
| 111 |
+
color: #e9ecef;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
/* Action buttons grid */
|
| 115 |
+
.action-grid {
|
| 116 |
+
display: grid;
|
| 117 |
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
| 118 |
+
gap: 12px;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
.action-btn {
|
| 122 |
+
display: flex;
|
| 123 |
+
align-items: center;
|
| 124 |
+
justify-content: center;
|
| 125 |
+
gap: 8px;
|
| 126 |
+
padding: 12px 16px;
|
| 127 |
+
font-weight: 500;
|
| 128 |
+
border-radius: 8px;
|
| 129 |
+
transition: transform 0.1s, box-shadow 0.1s;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
.action-btn:hover {
|
| 133 |
+
transform: translateY(-1px);
|
| 134 |
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
/* Subject filter */
|
| 138 |
+
.subject-select {
|
| 139 |
+
max-width: 300px;
|
| 140 |
+
background-color: #343a40;
|
| 141 |
+
border-color: #495057;
|
| 142 |
+
color: #fff;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
.subject-select:focus {
|
| 146 |
+
background-color: #343a40;
|
| 147 |
+
border-color: #0d6efd;
|
| 148 |
+
color: #fff;
|
| 149 |
+
box-shadow: 0 0 0 2px rgba(13, 110, 253, 0.25);
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
/* Status alerts */
|
| 153 |
+
#sync-status .alert {
|
| 154 |
+
border-radius: 8px;
|
| 155 |
+
border: none;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
/* Badge styling */
|
| 159 |
+
.count-badge {
|
| 160 |
+
min-width: 40px;
|
| 161 |
+
display: inline-block;
|
| 162 |
+
text-align: center;
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
/* Checkbox styling */
|
| 166 |
+
.form-check-input {
|
| 167 |
+
width: 18px;
|
| 168 |
+
height: 18px;
|
| 169 |
+
cursor: pointer;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
.form-check-input:checked {
|
| 173 |
+
background-color: #0d6efd;
|
| 174 |
+
border-color: #0d6efd;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
/* Selection info bar */
|
| 178 |
+
.selection-info {
|
| 179 |
+
background-color: #343a40;
|
| 180 |
+
border-radius: 8px;
|
| 181 |
+
padding: 10px 16px;
|
| 182 |
+
display: flex;
|
| 183 |
+
align-items: center;
|
| 184 |
+
justify-content: space-between;
|
| 185 |
+
margin-bottom: 12px;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
.selection-count {
|
| 189 |
+
font-weight: 500;
|
| 190 |
+
color: #0d6efd;
|
| 191 |
}
|
| 192 |
</style>
|
| 193 |
{% endblock %}
|
| 194 |
|
| 195 |
{% block content %}
|
| 196 |
+
<div class="container-fluid mt-4" style="width: 90%; margin: auto;">
|
| 197 |
+
<!-- Page Header -->
|
| 198 |
+
<div class="page-header">
|
| 199 |
+
<div class="d-flex justify-content-between align-items-center flex-wrap gap-3">
|
| 200 |
+
<h2><i class="bi bi-journal-x me-2"></i>Incorrect Question Manager</h2>
|
| 201 |
+
<div class="d-flex gap-2">
|
| 202 |
+
{% if neetprep_enabled %}
|
| 203 |
+
<a href="/neetprep/edit" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil me-1"></i>Edit Synced</a>
|
| 204 |
+
{% endif %}
|
| 205 |
+
<a href="/classified/edit" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil me-1"></i>Edit Classified</a>
|
| 206 |
+
</div>
|
| 207 |
</div>
|
| 208 |
+
</div>
|
| 209 |
+
|
| 210 |
+
<div class="row">
|
| 211 |
+
<!-- Left Column: Controls -->
|
| 212 |
+
<div class="col-lg-4">
|
| 213 |
{% if neetprep_enabled %}
|
| 214 |
+
<!-- Sync Section -->
|
| 215 |
+
<div class="section-card">
|
| 216 |
+
<div class="section-title"><i class="bi bi-cloud-download"></i>NeetPrep Sync</div>
|
| 217 |
+
<div class="sync-controls">
|
| 218 |
+
<button id="sync-btn" class="btn btn-primary sync-btn">
|
| 219 |
+
<i class="bi bi-arrow-repeat"></i>Sync Questions
|
| 220 |
+
</button>
|
| 221 |
+
<div class="form-check">
|
| 222 |
+
<input class="form-check-input" type="checkbox" id="force-sync-checkbox">
|
| 223 |
+
<label class="form-check-label text-muted" for="force-sync-checkbox">Force full re-sync</label>
|
| 224 |
+
</div>
|
| 225 |
+
</div>
|
| 226 |
+
<hr class="my-3 border-secondary">
|
| 227 |
+
<div class="d-flex align-items-center gap-3">
|
| 228 |
+
<button id="classify-btn" class="btn btn-warning btn-sm">
|
| 229 |
+
<i class="bi bi-tags me-1"></i>Classify
|
| 230 |
+
</button>
|
| 231 |
+
<span class="text-muted small">{{ unclassified_count }} unclassified</span>
|
| 232 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
</div>
|
| 234 |
{% else %}
|
| 235 |
+
<div class="section-card">
|
| 236 |
+
<div class="alert alert-info mb-0" role="alert">
|
| 237 |
+
<i class="bi bi-info-circle me-2"></i>
|
| 238 |
+
NeetPrep Sync is disabled. Enable it in <a href="{{ url_for('settings.settings') }}" class="alert-link">Settings</a>.
|
| 239 |
+
</div>
|
| 240 |
</div>
|
| 241 |
{% endif %}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
|
| 243 |
+
<!-- Subject Filter -->
|
| 244 |
+
<div class="section-card">
|
| 245 |
+
<div class="section-title"><i class="bi bi-funnel"></i>Filter by Subject</div>
|
| 246 |
+
<form id="subject-filter-form" method="get" action="{{ url_for('neetprep_bp.index') }}">
|
| 247 |
+
<select name="subject" id="subject" class="form-select subject-select" onchange="this.form.submit()">
|
| 248 |
+
{% for subject in available_subjects %}
|
| 249 |
+
<option value="{{ subject }}" {% if subject == selected_subject %}selected{% endif %}>{{ subject }}</option>
|
| 250 |
+
{% endfor %}
|
| 251 |
+
</select>
|
| 252 |
+
</form>
|
| 253 |
+
</div>
|
| 254 |
+
|
| 255 |
+
<!-- Question Source -->
|
| 256 |
+
<div class="section-card">
|
| 257 |
+
<div class="section-title"><i class="bi bi-collection"></i>Question Source</div>
|
| 258 |
+
<div class="source-filter-group">
|
| 259 |
+
<input type="radio" class="btn-check" name="source_filter" id="source-all" value="all" checked>
|
| 260 |
+
<label class="btn btn-outline-secondary btn-sm" for="source-all">All</label>
|
| 261 |
+
|
| 262 |
{% if neetprep_enabled %}
|
| 263 |
+
<input type="radio" class="btn-check" name="source_filter" id="source-neetprep" value="neetprep">
|
| 264 |
+
<label class="btn btn-outline-info btn-sm" for="source-neetprep">NeetPrep</label>
|
| 265 |
{% endif %}
|
| 266 |
+
|
| 267 |
+
<input type="radio" class="btn-check" name="source_filter" id="source-classified" value="classified">
|
| 268 |
+
<label class="btn btn-outline-success btn-sm" for="source-classified">Classified</label>
|
| 269 |
</div>
|
| 270 |
+
</div>
|
| 271 |
|
| 272 |
+
<!-- Status Messages -->
|
| 273 |
+
<div id="sync-status"></div>
|
| 274 |
+
<div id="pdf-link-container"></div>
|
| 275 |
+
</div>
|
| 276 |
+
|
| 277 |
+
<!-- Right Column: Topics & Actions -->
|
| 278 |
+
<div class="col-lg-8">
|
| 279 |
+
<form id="generate-pdf-form">
|
| 280 |
+
<!-- Quick Actions -->
|
| 281 |
+
<div class="section-card">
|
| 282 |
+
<div class="section-title"><i class="bi bi-lightning"></i>Quick Actions</div>
|
| 283 |
+
<div class="action-grid">
|
| 284 |
+
{% if neetprep_enabled %}
|
| 285 |
+
<button type="submit" name="pdf_type" value="all" class="btn btn-success action-btn">
|
| 286 |
+
<i class="bi bi-file-pdf"></i>Generate All PDF
|
| 287 |
+
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
{% endif %}
|
| 289 |
+
<button type="submit" name="pdf_type" value="quiz" class="btn btn-primary action-btn">
|
| 290 |
+
<i class="bi bi-play-circle"></i>Start Quiz
|
| 291 |
+
</button>
|
| 292 |
+
<button type="submit" name="pdf_type" value="selected" class="btn btn-info action-btn" {% if not topics %}disabled{% endif %}>
|
| 293 |
+
<i class="bi bi-check2-square"></i>Generate Selected
|
| 294 |
+
</button>
|
| 295 |
+
</div>
|
| 296 |
+
</div>
|
| 297 |
+
|
| 298 |
+
<!-- Topic Selection -->
|
| 299 |
+
<div class="section-card">
|
| 300 |
+
<div class="section-title"><i class="bi bi-list-check"></i>Select Topics</div>
|
| 301 |
+
|
| 302 |
+
{% if topics %}
|
| 303 |
+
<!-- Selection Info Bar -->
|
| 304 |
+
<div class="selection-info">
|
| 305 |
+
<div>
|
| 306 |
+
<input class="form-check-input me-2" type="checkbox" id="select-all-topics">
|
| 307 |
+
<label for="select-all-topics" class="form-check-label">Select All</label>
|
| 308 |
+
</div>
|
| 309 |
+
<span class="selection-count"><span id="selected-count">0</span> / {{ topics|length }} selected</span>
|
| 310 |
+
</div>
|
| 311 |
+
{% endif %}
|
| 312 |
+
|
| 313 |
+
<div class="topic-table-container">
|
| 314 |
+
<table class="table table-dark table-hover topic-table">
|
| 315 |
+
<thead>
|
| 316 |
+
<tr>
|
| 317 |
+
<th style="width: 50px;">#</th>
|
| 318 |
+
<th style="width: 50px;"></th>
|
| 319 |
+
<th>Topic</th>
|
| 320 |
+
{% if neetprep_enabled %}<th style="width: 100px;">NeetPrep</th>{% endif %}
|
| 321 |
+
<th style="width: 100px;">My Qs</th>
|
| 322 |
+
</tr>
|
| 323 |
+
</thead>
|
| 324 |
+
<tbody id="topic-list">
|
| 325 |
+
{% if topics %}
|
| 326 |
+
{% for item in topics %}
|
| 327 |
+
<tr>
|
| 328 |
+
<td class="text-muted">{{ loop.index }}</td>
|
| 329 |
+
<td><input class="form-check-input topic-checkbox" type="checkbox" value="{{ item.topic }}"></td>
|
| 330 |
+
<td class="topic-name">{{ item.topic }}</td>
|
| 331 |
+
{% if neetprep_enabled %}<td><span class="badge bg-secondary count-badge">{{ item.neetprep_count }}</span></td>{% endif %}
|
| 332 |
+
<td><span class="badge bg-info count-badge">{{ item.my_questions_count }}</span></td>
|
| 333 |
+
</tr>
|
| 334 |
+
{% endfor %}
|
| 335 |
+
{% else %}
|
| 336 |
+
<tr>
|
| 337 |
+
<td colspan="{% if neetprep_enabled %}5{% else %}4{% endif %}" class="text-center text-muted py-4">
|
| 338 |
+
<i class="bi bi-inbox fs-1 d-block mb-2"></i>
|
| 339 |
+
{% if neetprep_enabled %}
|
| 340 |
+
No topics found. Sync with NeetPrep to fetch questions.
|
| 341 |
+
{% else %}
|
| 342 |
+
No classified topics found.
|
| 343 |
+
{% endif %}
|
| 344 |
+
</td>
|
| 345 |
+
</tr>
|
| 346 |
+
{% endif %}
|
| 347 |
+
</tbody>
|
| 348 |
+
</table>
|
| 349 |
+
</div>
|
| 350 |
</div>
|
|
|
|
| 351 |
</form>
|
| 352 |
</div>
|
| 353 |
</div>
|
|
|
|
| 358 |
<script>
|
| 359 |
function loadSettings() {
|
| 360 |
const settings = JSON.parse(localStorage.getItem('pdfGeneratorSettings'));
|
|
|
|
| 361 |
if (settings) {
|
| 362 |
return settings;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 363 |
}
|
| 364 |
+
return {
|
| 365 |
+
images_per_page: 4,
|
| 366 |
+
orientation: 'portrait',
|
| 367 |
+
grid_rows: 4,
|
| 368 |
+
grid_cols: 1,
|
| 369 |
+
practice_mode: 'none'
|
| 370 |
+
};
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
// Select all checkbox functionality
|
| 374 |
+
const selectAllCheckbox = document.getElementById('select-all-topics');
|
| 375 |
+
const topicCheckboxes = document.querySelectorAll('.topic-checkbox');
|
| 376 |
+
const selectedCountSpan = document.getElementById('selected-count');
|
| 377 |
+
|
| 378 |
+
function updateSelectedCount() {
|
| 379 |
+
const count = document.querySelectorAll('.topic-checkbox:checked').length;
|
| 380 |
+
if (selectedCountSpan) selectedCountSpan.textContent = count;
|
| 381 |
+
|
| 382 |
+
// Update select all checkbox state
|
| 383 |
+
if (selectAllCheckbox) {
|
| 384 |
+
selectAllCheckbox.checked = count === topicCheckboxes.length && topicCheckboxes.length > 0;
|
| 385 |
+
selectAllCheckbox.indeterminate = count > 0 && count < topicCheckboxes.length;
|
| 386 |
+
}
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
if (selectAllCheckbox) {
|
| 390 |
+
selectAllCheckbox.addEventListener('change', () => {
|
| 391 |
+
topicCheckboxes.forEach(cb => cb.checked = selectAllCheckbox.checked);
|
| 392 |
+
updateSelectedCount();
|
| 393 |
+
});
|
| 394 |
}
|
| 395 |
|
| 396 |
+
topicCheckboxes.forEach(cb => {
|
| 397 |
+
cb.addEventListener('change', updateSelectedCount);
|
| 398 |
+
});
|
| 399 |
+
|
| 400 |
// Only attach event listeners if NeetPrep is enabled
|
| 401 |
{% if neetprep_enabled %}
|
| 402 |
document.getElementById('sync-btn').addEventListener('click', async () => {
|
| 403 |
const syncStatus = document.getElementById('sync-status');
|
| 404 |
const forceSync = document.getElementById('force-sync-checkbox').checked;
|
| 405 |
+
syncStatus.innerHTML = '<div class="alert alert-info"><i class="bi bi-hourglass-split me-2"></i>Syncing... This may take a while.</div>';
|
| 406 |
+
|
| 407 |
try {
|
| 408 |
+
const response = await fetch('/neetprep/sync', {
|
| 409 |
method: 'POST',
|
| 410 |
+
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
|
| 411 |
body: JSON.stringify({ force: forceSync })
|
| 412 |
});
|
| 413 |
const result = await response.json();
|
| 414 |
+
|
| 415 |
if (response.ok) {
|
| 416 |
+
syncStatus.innerHTML = `<div class="alert alert-success"><i class="bi bi-check-circle me-2"></i>${result.status}</div>`;
|
|
|
|
| 417 |
setTimeout(() => window.location.reload(), 2000);
|
| 418 |
} else {
|
| 419 |
+
syncStatus.innerHTML = `<div class="alert alert-danger"><i class="bi bi-exclamation-triangle me-2"></i>Error: ${result.error || 'Unknown error'}</div>`;
|
| 420 |
}
|
| 421 |
} catch (error) {
|
| 422 |
+
syncStatus.innerHTML = `<div class="alert alert-danger"><i class="bi bi-exclamation-triangle me-2"></i>Request failed: ${error}</div>`;
|
| 423 |
}
|
| 424 |
});
|
| 425 |
|
| 426 |
document.getElementById('classify-btn').addEventListener('click', async () => {
|
| 427 |
const syncStatus = document.getElementById('sync-status');
|
| 428 |
+
syncStatus.innerHTML = '<div class="alert alert-info"><i class="bi bi-hourglass-split me-2"></i>Classification started... This will take time due to rate limits.</div>';
|
| 429 |
+
|
| 430 |
try {
|
| 431 |
const response = await fetch('/neetprep/classify', { method: 'POST' });
|
| 432 |
const result = await response.json();
|
| 433 |
+
|
| 434 |
if (response.ok) {
|
| 435 |
+
syncStatus.innerHTML = `<div class="alert alert-success"><i class="bi bi-check-circle me-2"></i>${result.status}</div>`;
|
| 436 |
setTimeout(() => window.location.reload(), 2000);
|
| 437 |
} else {
|
| 438 |
+
syncStatus.innerHTML = `<div class="alert alert-danger"><i class="bi bi-exclamation-triangle me-2"></i>Error: ${result.error || 'Unknown error'}</div>`;
|
| 439 |
}
|
| 440 |
} catch (error) {
|
| 441 |
+
syncStatus.innerHTML = `<div class="alert alert-danger"><i class="bi bi-exclamation-triangle me-2"></i>Request failed: ${error}</div>`;
|
| 442 |
}
|
| 443 |
});
|
| 444 |
{% endif %}
|
|
|
|
| 448 |
const pdfType = e.submitter.value;
|
| 449 |
const statusDiv = document.getElementById('sync-status');
|
| 450 |
const settings = loadSettings();
|
| 451 |
+
const sourceFilter = document.querySelector('input[name="source_filter"]:checked').value;
|
| 452 |
+
|
| 453 |
if (pdfType === 'quiz') {
|
| 454 |
+
const selectedTopics = Array.from(document.querySelectorAll('#topic-list .topic-checkbox:checked')).map(cb => cb.value);
|
| 455 |
if (selectedTopics.length === 0) {
|
| 456 |
+
statusDiv.innerHTML = '<div class="alert alert-warning"><i class="bi bi-exclamation-circle me-2"></i>Please select at least one topic for the quiz.</div>';
|
| 457 |
return;
|
| 458 |
}
|
| 459 |
|
|
|
|
| 460 |
const form = document.createElement('form');
|
| 461 |
form.method = 'POST';
|
| 462 |
form.action = '/neetprep/generate';
|
| 463 |
+
|
| 464 |
const typeInput = document.createElement('input');
|
| 465 |
typeInput.type = 'hidden';
|
| 466 |
typeInput.name = 'type';
|
|
|
|
| 473 |
topicsInput.value = JSON.stringify(selectedTopics);
|
| 474 |
form.appendChild(topicsInput);
|
| 475 |
|
| 476 |
+
const sourceInput = document.createElement('input');
|
| 477 |
+
sourceInput.type = 'hidden';
|
| 478 |
+
sourceInput.name = 'source';
|
| 479 |
+
sourceInput.value = sourceFilter;
|
| 480 |
+
form.appendChild(sourceInput);
|
| 481 |
+
|
| 482 |
document.body.appendChild(form);
|
| 483 |
form.submit();
|
| 484 |
+
return;
|
| 485 |
}
|
| 486 |
|
| 487 |
+
let payload = {
|
|
|
|
| 488 |
type: pdfType,
|
| 489 |
+
source: sourceFilter,
|
| 490 |
layout: {
|
| 491 |
images_per_page: settings.images_per_page,
|
| 492 |
orientation: settings.orientation,
|
|
|
|
| 497 |
};
|
| 498 |
|
| 499 |
if (pdfType === 'selected') {
|
| 500 |
+
const selectedTopics = Array.from(document.querySelectorAll('#topic-list .topic-checkbox:checked')).map(cb => cb.value);
|
| 501 |
if (selectedTopics.length === 0) {
|
| 502 |
+
statusDiv.innerHTML = '<div class="alert alert-warning"><i class="bi bi-exclamation-circle me-2"></i>Please select at least one topic.</div>';
|
| 503 |
return;
|
| 504 |
}
|
| 505 |
payload.topics = selectedTopics;
|
| 506 |
}
|
| 507 |
|
| 508 |
+
statusDiv.innerHTML = '<div class="alert alert-info"><i class="bi bi-hourglass-split me-2"></i>Generating PDF...</div>';
|
|
|
|
| 509 |
|
| 510 |
try {
|
| 511 |
const response = await fetch('/neetprep/generate', {
|
|
|
|
| 516 |
const result = await response.json();
|
| 517 |
|
| 518 |
if (response.ok && result.success) {
|
| 519 |
+
statusDiv.innerHTML = '<div class="alert alert-success"><i class="bi bi-check-circle me-2"></i>PDF generated!</div>';
|
| 520 |
if(result.pdf_url) {
|
| 521 |
const pdfLinkContainer = document.getElementById('pdf-link-container');
|
| 522 |
+
pdfLinkContainer.innerHTML = `<a href="${result.pdf_url}" class="btn btn-outline-light" target="_blank"><i class="bi bi-file-pdf me-2"></i>View Generated PDF</a>`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 523 |
window.open(result.pdf_url, '_blank');
|
| 524 |
}
|
| 525 |
} else {
|
| 526 |
+
statusDiv.innerHTML = `<div class="alert alert-danger"><i class="bi bi-exclamation-triangle me-2"></i>Error: ${result.error || 'PDF generation failed.'}</div>`;
|
| 527 |
}
|
| 528 |
} catch (error) {
|
| 529 |
+
statusDiv.innerHTML = `<div class="alert alert-danger"><i class="bi bi-exclamation-triangle me-2"></i>Request failed: ${error}</div>`;
|
| 530 |
}
|
| 531 |
});
|
| 532 |
</script>
|
| 533 |
+
{% endblock %}
|
templates/neetprep_edit.html
CHANGED
|
@@ -2,12 +2,58 @@
|
|
| 2 |
|
| 3 |
{% block title %}Edit NeetPrep Questions{% endblock %}
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
{% block content %}
|
| 6 |
<div class="container mt-5">
|
| 7 |
<div class="card bg-dark text-white">
|
| 8 |
-
<div class="card-header d-flex justify-content-between align-items-center">
|
| 9 |
<h2>Edit NeetPrep Questions</h2>
|
| 10 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
</div>
|
| 12 |
<div class="card-body">
|
| 13 |
<!-- Filter Form -->
|
|
@@ -18,8 +64,11 @@
|
|
| 18 |
<div class="col-md-4">
|
| 19 |
<select id="topic-filter" class="form-select bg-dark text-white">
|
| 20 |
<option value="all">All Topics</option>
|
|
|
|
| 21 |
{% for topic in topics %}
|
|
|
|
| 22 |
<option value="{{ topic }}">{{ topic }}</option>
|
|
|
|
| 23 |
{% endfor %}
|
| 24 |
</select>
|
| 25 |
</div>
|
|
@@ -30,6 +79,7 @@
|
|
| 30 |
<table class="table table-dark table-hover">
|
| 31 |
<thead>
|
| 32 |
<tr>
|
|
|
|
| 33 |
<th>Question Text</th>
|
| 34 |
<th>Topic</th>
|
| 35 |
<th>Subject</th>
|
|
@@ -38,16 +88,17 @@
|
|
| 38 |
</thead>
|
| 39 |
<tbody id="questions-table-body">
|
| 40 |
{% for q in questions %}
|
| 41 |
-
<tr data-topic="{{ q.topic }}">
|
| 42 |
-
<td>{{
|
|
|
|
| 43 |
<td class="topic-cell">{{ q.topic }}</td>
|
| 44 |
<td class="subject-cell">{{ q.subject }}</td>
|
| 45 |
<td>
|
| 46 |
-
<button class="btn btn-sm btn-primary edit-btn"
|
| 47 |
-
data-id="{{ q.id }}"
|
| 48 |
-
data-topic="{{ q.topic }}"
|
| 49 |
-
data-subject="{{ q.subject }}"
|
| 50 |
-
data-bs-toggle="modal"
|
| 51 |
data-bs-target="#editModal">
|
| 52 |
Edit
|
| 53 |
</button>
|
|
@@ -61,7 +112,7 @@
|
|
| 61 |
</div>
|
| 62 |
</div>
|
| 63 |
|
| 64 |
-
<!-- Edit Modal -->
|
| 65 |
<div class="modal fade" id="editModal" tabindex="-1" aria-labelledby="editModalLabel" aria-hidden="true">
|
| 66 |
<div class="modal-dialog">
|
| 67 |
<div class="modal-content bg-dark text-white">
|
|
@@ -89,10 +140,515 @@
|
|
| 89 |
</div>
|
| 90 |
</div>
|
| 91 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
{% endblock %}
|
| 93 |
|
| 94 |
{% block scripts %}
|
| 95 |
<script>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
document.addEventListener('DOMContentLoaded', function () {
|
| 97 |
const editModal = new bootstrap.Modal(document.getElementById('editModal'));
|
| 98 |
const editQuestionId = document.getElementById('edit-question-id');
|
|
@@ -121,15 +677,12 @@ document.addEventListener('DOMContentLoaded', function () {
|
|
| 121 |
const result = await response.json();
|
| 122 |
|
| 123 |
if (result.success) {
|
| 124 |
-
// Update the table row
|
| 125 |
const row = document.querySelector(`button[data-id='${id}']`).closest('tr');
|
| 126 |
row.querySelector('.topic-cell').textContent = topic;
|
| 127 |
row.querySelector('.subject-cell').textContent = subject;
|
| 128 |
-
// Update the button's data attributes as well
|
| 129 |
const editBtn = row.querySelector('.edit-btn');
|
| 130 |
editBtn.dataset.topic = topic;
|
| 131 |
editBtn.dataset.subject = subject;
|
| 132 |
-
|
| 133 |
editModal.hide();
|
| 134 |
} else {
|
| 135 |
alert(`Error: ${result.error}`);
|
|
@@ -153,7 +706,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
|
| 153 |
const row = rows[i];
|
| 154 |
const topic = row.dataset.topic;
|
| 155 |
const text = row.textContent || row.innerText;
|
| 156 |
-
|
| 157 |
const topicMatch = (selectedTopic === 'all' || topic === selectedTopic);
|
| 158 |
const searchMatch = (text.toLowerCase().indexOf(searchText) > -1);
|
| 159 |
|
|
@@ -167,6 +720,424 @@ document.addEventListener('DOMContentLoaded', function () {
|
|
| 167 |
|
| 168 |
searchInput.addEventListener('keyup', filterTable);
|
| 169 |
topicFilter.addEventListener('change', filterTable);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
</script>
|
| 172 |
{% endblock %}
|
|
|
|
| 2 |
|
| 3 |
{% block title %}Edit NeetPrep Questions{% endblock %}
|
| 4 |
|
| 5 |
+
{% block head %}
|
| 6 |
+
<style>
|
| 7 |
+
/* Style for rendered question HTML */
|
| 8 |
+
#topic_q_text img {
|
| 9 |
+
max-width: 100%;
|
| 10 |
+
height: auto;
|
| 11 |
+
border-radius: 4px;
|
| 12 |
+
}
|
| 13 |
+
#topic_q_text p {
|
| 14 |
+
margin-bottom: 0.5rem;
|
| 15 |
+
}
|
| 16 |
+
#topic_q_text {
|
| 17 |
+
font-size: 0.95rem;
|
| 18 |
+
line-height: 1.5;
|
| 19 |
+
}
|
| 20 |
+
/* Truncate table cell text */
|
| 21 |
+
.question-text-cell {
|
| 22 |
+
max-width: 300px;
|
| 23 |
+
overflow: hidden;
|
| 24 |
+
text-overflow: ellipsis;
|
| 25 |
+
white-space: nowrap;
|
| 26 |
+
}
|
| 27 |
+
/* Range toggles */
|
| 28 |
+
.range-toggle { transition: all 0.2s; }
|
| 29 |
+
.range-toggle.active { transform: scale(1.02); box-shadow: 0 0 8px rgba(255,255,255,0.2); }
|
| 30 |
+
.range-toggle.active.btn-outline-primary { background: #0d6efd; color: #fff; }
|
| 31 |
+
.range-toggle.active.btn-outline-warning { background: #ffc107; color: #000; }
|
| 32 |
+
.range-toggle.active.btn-outline-info { background: #0dcaf0; color: #000; }
|
| 33 |
+
/* Range slider styling */
|
| 34 |
+
.range-slider-row { background: #343a40; border-radius: 8px; padding: 12px; margin-bottom: 10px; }
|
| 35 |
+
.range-slider-row:hover { background: #3d444b; }
|
| 36 |
+
.dual-range-container { position: relative; height: 40px; }
|
| 37 |
+
.dual-range-track { position: absolute; top: 50%; left: 0; right: 0; height: 8px; background: #495057; border-radius: 4px; transform: translateY(-50%); }
|
| 38 |
+
.dual-range-highlight { position: absolute; top: 50%; height: 8px; background: linear-gradient(90deg, #0d6efd, #0dcaf0); border-radius: 4px; transform: translateY(-50%); }
|
| 39 |
+
.dual-range-container input[type="range"] { position: absolute; top: 0; left: 0; width: 100%; height: 100%; -webkit-appearance: none; background: transparent; pointer-events: none; }
|
| 40 |
+
.dual-range-container input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 24px; height: 24px; background: #fff; border: 3px solid #0d6efd; border-radius: 50%; cursor: pointer; pointer-events: auto; box-shadow: 0 2px 6px rgba(0,0,0,0.3); transition: transform 0.15s; }
|
| 41 |
+
.dual-range-container input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.15); }
|
| 42 |
+
.dual-range-container input[type="range"]::-moz-range-thumb { width: 24px; height: 24px; background: #fff; border: 3px solid #0d6efd; border-radius: 50%; cursor: pointer; pointer-events: auto; box-shadow: 0 2px 6px rgba(0,0,0,0.3); }
|
| 43 |
+
</style>
|
| 44 |
+
{% endblock %}
|
| 45 |
+
|
| 46 |
{% block content %}
|
| 47 |
<div class="container mt-5">
|
| 48 |
<div class="card bg-dark text-white">
|
| 49 |
+
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
| 50 |
<h2>Edit NeetPrep Questions</h2>
|
| 51 |
+
<div class="d-flex gap-2">
|
| 52 |
+
<button type="button" class="btn btn-warning" data-bs-toggle="modal" data-bs-target="#manualClassificationModal">
|
| 53 |
+
<i class="bi bi-list-check"></i> Manual Classification
|
| 54 |
+
</button>
|
| 55 |
+
<a href="/neetprep" class="btn btn-outline-light">Back to NeetPrep Home</a>
|
| 56 |
+
</div>
|
| 57 |
</div>
|
| 58 |
<div class="card-body">
|
| 59 |
<!-- Filter Form -->
|
|
|
|
| 64 |
<div class="col-md-4">
|
| 65 |
<select id="topic-filter" class="form-select bg-dark text-white">
|
| 66 |
<option value="all">All Topics</option>
|
| 67 |
+
<option value="Unclassified" selected>Unclassified Only</option>
|
| 68 |
{% for topic in topics %}
|
| 69 |
+
{% if topic != 'Unclassified' %}
|
| 70 |
<option value="{{ topic }}">{{ topic }}</option>
|
| 71 |
+
{% endif %}
|
| 72 |
{% endfor %}
|
| 73 |
</select>
|
| 74 |
</div>
|
|
|
|
| 79 |
<table class="table table-dark table-hover">
|
| 80 |
<thead>
|
| 81 |
<tr>
|
| 82 |
+
<th style="width: 40px;">#</th>
|
| 83 |
<th>Question Text</th>
|
| 84 |
<th>Topic</th>
|
| 85 |
<th>Subject</th>
|
|
|
|
| 88 |
</thead>
|
| 89 |
<tbody id="questions-table-body">
|
| 90 |
{% for q in questions %}
|
| 91 |
+
<tr data-topic="{{ q.topic }}" data-id="{{ q.id }}" data-index="{{ loop.index }}" data-html="{{ q.question_text | e }}">
|
| 92 |
+
<td>{{ loop.index }}</td>
|
| 93 |
+
<td class="question-text-cell">{{ q.question_text_plain[:100] }}{% if q.question_text_plain|length > 100 %}...{% endif %}</td>
|
| 94 |
<td class="topic-cell">{{ q.topic }}</td>
|
| 95 |
<td class="subject-cell">{{ q.subject }}</td>
|
| 96 |
<td>
|
| 97 |
+
<button class="btn btn-sm btn-primary edit-btn"
|
| 98 |
+
data-id="{{ q.id }}"
|
| 99 |
+
data-topic="{{ q.topic }}"
|
| 100 |
+
data-subject="{{ q.subject }}"
|
| 101 |
+
data-bs-toggle="modal"
|
| 102 |
data-bs-target="#editModal">
|
| 103 |
Edit
|
| 104 |
</button>
|
|
|
|
| 112 |
</div>
|
| 113 |
</div>
|
| 114 |
|
| 115 |
+
<!-- Edit Modal (Single Question) -->
|
| 116 |
<div class="modal fade" id="editModal" tabindex="-1" aria-labelledby="editModalLabel" aria-hidden="true">
|
| 117 |
<div class="modal-dialog">
|
| 118 |
<div class="modal-content bg-dark text-white">
|
|
|
|
| 140 |
</div>
|
| 141 |
</div>
|
| 142 |
</div>
|
| 143 |
+
|
| 144 |
+
<!-- Manual Classification Setup Modal -->
|
| 145 |
+
<div class="modal fade" id="manualClassificationModal" tabindex="-1">
|
| 146 |
+
<div class="modal-dialog modal-lg">
|
| 147 |
+
<div class="modal-content bg-dark text-white">
|
| 148 |
+
<div class="modal-header border-secondary">
|
| 149 |
+
<h5 class="modal-title"><i class="bi bi-list-check me-2"></i>Quick Classification</h5>
|
| 150 |
+
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
| 151 |
+
</div>
|
| 152 |
+
<div class="modal-body">
|
| 153 |
+
<div class="alert alert-info border-0 mb-3">
|
| 154 |
+
<i class="bi bi-lightning-fill me-2"></i>
|
| 155 |
+
<span id="visible-count-info">Classify filtered questions with AI-powered suggestions.</span>
|
| 156 |
+
</div>
|
| 157 |
+
|
| 158 |
+
<!-- Quick Range Toggles -->
|
| 159 |
+
<div class="mb-3">
|
| 160 |
+
<label class="form-label">Quick Select</label>
|
| 161 |
+
<div class="d-flex flex-wrap gap-2">
|
| 162 |
+
<button type="button" class="btn btn-outline-primary range-toggle active" data-range="all">
|
| 163 |
+
<i class="bi bi-collection me-1"></i>All Visible
|
| 164 |
+
</button>
|
| 165 |
+
<button type="button" class="btn btn-outline-warning range-toggle" data-range="unclassified">
|
| 166 |
+
<i class="bi bi-question-circle me-1"></i>Unclassified
|
| 167 |
+
</button>
|
| 168 |
+
<button type="button" class="btn btn-outline-info range-toggle" data-range="custom">
|
| 169 |
+
<i class="bi bi-sliders me-1"></i>Custom Range
|
| 170 |
+
</button>
|
| 171 |
+
</div>
|
| 172 |
+
</div>
|
| 173 |
+
|
| 174 |
+
<!-- Custom Range Slider UI -->
|
| 175 |
+
<div id="custom-range-container" class="d-none">
|
| 176 |
+
<div class="card bg-secondary mb-3">
|
| 177 |
+
<div class="card-body">
|
| 178 |
+
<div class="d-flex justify-content-between align-items-center mb-2">
|
| 179 |
+
<label class="form-label mb-0">Select Question Ranges</label>
|
| 180 |
+
<button type="button" class="btn btn-sm btn-success" onclick="addRangeSlider()">
|
| 181 |
+
<i class="bi bi-plus-lg me-1"></i>Add Range
|
| 182 |
+
</button>
|
| 183 |
+
</div>
|
| 184 |
+
|
| 185 |
+
<div id="range-sliders-container">
|
| 186 |
+
<!-- Range sliders will be added here -->
|
| 187 |
+
</div>
|
| 188 |
+
|
| 189 |
+
<div class="mt-3 p-2 bg-dark rounded">
|
| 190 |
+
<small class="text-muted">Selected: </small>
|
| 191 |
+
<span id="selected-ranges-display" class="text-info fw-bold">None</span>
|
| 192 |
+
<small class="text-muted ms-2">(<span id="selected-count">0</span> questions)</small>
|
| 193 |
+
</div>
|
| 194 |
+
</div>
|
| 195 |
+
</div>
|
| 196 |
+
|
| 197 |
+
<!-- Preview Section -->
|
| 198 |
+
<div class="row" id="range-preview-section">
|
| 199 |
+
<div class="col-6">
|
| 200 |
+
<div class="card bg-secondary">
|
| 201 |
+
<div class="card-header py-1 text-center">
|
| 202 |
+
<small class="text-success"><i class="bi bi-arrow-right-circle me-1"></i>Start</small>
|
| 203 |
+
</div>
|
| 204 |
+
<div class="card-body p-2">
|
| 205 |
+
<div id="preview-start-text" class="small" style="max-height: 60px; overflow: hidden;"></div>
|
| 206 |
+
<div class="mt-1"><span class="badge bg-primary" id="preview-start-num">#1</span></div>
|
| 207 |
+
</div>
|
| 208 |
+
</div>
|
| 209 |
+
</div>
|
| 210 |
+
<div class="col-6">
|
| 211 |
+
<div class="card bg-secondary">
|
| 212 |
+
<div class="card-header py-1 text-center">
|
| 213 |
+
<small class="text-danger"><i class="bi bi-arrow-left-circle me-1"></i>End</small>
|
| 214 |
+
</div>
|
| 215 |
+
<div class="card-body p-2">
|
| 216 |
+
<div id="preview-end-text" class="small" style="max-height: 60px; overflow: hidden;"></div>
|
| 217 |
+
<div class="mt-1"><span class="badge bg-primary" id="preview-end-num">#1</span></div>
|
| 218 |
+
</div>
|
| 219 |
+
</div>
|
| 220 |
+
</div>
|
| 221 |
+
</div>
|
| 222 |
+
</div>
|
| 223 |
+
</div>
|
| 224 |
+
<div class="modal-footer border-secondary">
|
| 225 |
+
<button type="button" class="btn btn-lg btn-primary w-100" onclick="startManualClassification()">
|
| 226 |
+
<i class="bi bi-play-fill me-2"></i>Start Classification
|
| 227 |
+
</button>
|
| 228 |
+
</div>
|
| 229 |
+
</div>
|
| 230 |
+
</div>
|
| 231 |
+
</div>
|
| 232 |
+
|
| 233 |
+
<!-- Topic Selection Wizard Modal -->
|
| 234 |
+
<div class="modal fade" id="topicSelectionModal" tabindex="-1" data-bs-backdrop="static" data-bs-keyboard="false">
|
| 235 |
+
<div class="modal-dialog modal-lg modal-dialog-centered">
|
| 236 |
+
<div class="modal-content bg-dark text-white border-secondary">
|
| 237 |
+
<div class="modal-header border-secondary py-2">
|
| 238 |
+
<div class="d-flex align-items-center gap-3">
|
| 239 |
+
<span class="badge bg-primary fs-6" id="topic_q_num">#1</span>
|
| 240 |
+
<div class="progress flex-grow-1" style="width: 150px; height: 6px;">
|
| 241 |
+
<div class="progress-bar" id="topic_progress_bar" role="progressbar" style="width: 0%"></div>
|
| 242 |
+
</div>
|
| 243 |
+
<small id="topic_progress" class="text-muted">1/10</small>
|
| 244 |
+
</div>
|
| 245 |
+
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
| 246 |
+
</div>
|
| 247 |
+
<div class="modal-body">
|
| 248 |
+
<!-- Question Display -->
|
| 249 |
+
<div class="mb-3 p-3 bg-black bg-opacity-25 rounded border border-secondary" style="max-height: 180px; overflow-y: auto;">
|
| 250 |
+
<div id="topic_q_text" class="text-white"></div>
|
| 251 |
+
</div>
|
| 252 |
+
|
| 253 |
+
<!-- Subject Selection (Auto-detected, editable) -->
|
| 254 |
+
<div class="mb-3">
|
| 255 |
+
<label class="form-label small text-muted mb-2">Subject (click to change)</label>
|
| 256 |
+
<div id="subject_pills" class="d-flex flex-wrap gap-2">
|
| 257 |
+
<button type="button" class="btn btn-outline-success subject-pill" data-subject="Biology">
|
| 258 |
+
<i class="bi bi-flower1 me-1"></i>Biology
|
| 259 |
+
</button>
|
| 260 |
+
<button type="button" class="btn btn-outline-warning subject-pill" data-subject="Chemistry">
|
| 261 |
+
<i class="bi bi-droplet me-1"></i>Chemistry
|
| 262 |
+
</button>
|
| 263 |
+
<button type="button" class="btn btn-outline-info subject-pill" data-subject="Physics">
|
| 264 |
+
<i class="bi bi-lightning me-1"></i>Physics
|
| 265 |
+
</button>
|
| 266 |
+
<button type="button" class="btn btn-outline-danger subject-pill" data-subject="Mathematics">
|
| 267 |
+
<i class="bi bi-calculator me-1"></i>Mathematics
|
| 268 |
+
</button>
|
| 269 |
+
</div>
|
| 270 |
+
</div>
|
| 271 |
+
|
| 272 |
+
<!-- Chapter/Topic Input -->
|
| 273 |
+
<div class="mb-2">
|
| 274 |
+
<label class="form-label small text-muted mb-2">Chapter / Topic</label>
|
| 275 |
+
<div class="input-group input-group-lg">
|
| 276 |
+
<input type="text" id="topic_input" class="form-control bg-dark text-white border-secondary" placeholder="Start typing or use AI...">
|
| 277 |
+
<button class="btn btn-info" type="button" id="btn_get_suggestion" onclick="getAiSuggestion()">
|
| 278 |
+
<span class="spinner-border spinner-border-sm d-none" role="status"></span>
|
| 279 |
+
<i class="bi bi-stars"></i>
|
| 280 |
+
</button>
|
| 281 |
+
</div>
|
| 282 |
+
</div>
|
| 283 |
+
|
| 284 |
+
<!-- Suggestions -->
|
| 285 |
+
<div id="ai_suggestion_container" class="d-none">
|
| 286 |
+
<div id="ai_suggestion_chips" class="d-flex flex-wrap gap-2"></div>
|
| 287 |
+
</div>
|
| 288 |
+
<div id="ai_error_msg" class="alert alert-danger d-none mt-2 py-2"></div>
|
| 289 |
+
</div>
|
| 290 |
+
<div class="modal-footer border-secondary py-2">
|
| 291 |
+
<button type="button" class="btn btn-outline-secondary btn-lg px-4" onclick="prevTopicQuestion()">
|
| 292 |
+
<i class="bi bi-chevron-left"></i>
|
| 293 |
+
</button>
|
| 294 |
+
<button type="button" class="btn btn-primary btn-lg px-5" onclick="nextTopicQuestion()">
|
| 295 |
+
Next <i class="bi bi-chevron-right ms-1"></i>
|
| 296 |
+
</button>
|
| 297 |
+
</div>
|
| 298 |
+
</div>
|
| 299 |
+
</div>
|
| 300 |
+
</div>
|
| 301 |
+
</div>
|
| 302 |
+
</div>
|
| 303 |
+
</div>
|
| 304 |
+
</div>
|
| 305 |
{% endblock %}
|
| 306 |
|
| 307 |
{% block scripts %}
|
| 308 |
<script>
|
| 309 |
+
// --- Manual Classification Logic (global functions) ---
|
| 310 |
+
let manualQuestionsList = [];
|
| 311 |
+
let currentManualIndex = 0;
|
| 312 |
+
let manualSubject = "Biology"; // Will be auto-detected
|
| 313 |
+
let suggestionCache = new Map();
|
| 314 |
+
let selectedRangeMode = 'all'; // 'all', 'unclassified', 'custom'
|
| 315 |
+
let customRanges = []; // Array of {start, end, subject} for custom slider ranges
|
| 316 |
+
let questionSubjectMap = new Map(); // Maps question index to pre-selected subject
|
| 317 |
+
|
| 318 |
+
// Get question text preview by visible index
|
| 319 |
+
function getQuestionPreview(visibleIndex) {
|
| 320 |
+
const visibleRows = getVisibleRows();
|
| 321 |
+
if (visibleIndex >= 0 && visibleIndex < visibleRows.length) {
|
| 322 |
+
const row = visibleRows[visibleIndex];
|
| 323 |
+
return row.querySelector('.question-text-cell').textContent.substring(0, 80) + '...';
|
| 324 |
+
}
|
| 325 |
+
return '';
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
// Get question number by visible index
|
| 329 |
+
function getQuestionNumber(visibleIndex) {
|
| 330 |
+
const visibleRows = getVisibleRows();
|
| 331 |
+
if (visibleIndex >= 0 && visibleIndex < visibleRows.length) {
|
| 332 |
+
return visibleRows[visibleIndex].dataset.index;
|
| 333 |
+
}
|
| 334 |
+
return visibleIndex + 1;
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
// Add a new range slider
|
| 338 |
+
function addRangeSlider() {
|
| 339 |
+
const container = document.getElementById('range-sliders-container');
|
| 340 |
+
const visibleRows = getVisibleRows();
|
| 341 |
+
const maxVal = visibleRows.length;
|
| 342 |
+
|
| 343 |
+
if (maxVal === 0) {
|
| 344 |
+
alert("No visible questions to select from.");
|
| 345 |
+
return;
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
const sliderId = Date.now();
|
| 349 |
+
|
| 350 |
+
const sliderHtml = `
|
| 351 |
+
<div class="range-slider-row" id="slider-row-${sliderId}" data-slider-id="${sliderId}">
|
| 352 |
+
<div class="d-flex justify-content-between align-items-center mb-2">
|
| 353 |
+
<div class="d-flex align-items-center gap-2">
|
| 354 |
+
<span class="badge bg-success" id="start-badge-${sliderId}">Q1</span>
|
| 355 |
+
<i class="bi bi-arrow-right text-muted"></i>
|
| 356 |
+
<span class="badge bg-danger" id="end-badge-${sliderId}">Q${maxVal}</span>
|
| 357 |
+
</div>
|
| 358 |
+
<div class="d-flex align-items-center gap-2">
|
| 359 |
+
<select class="form-select form-select-sm bg-dark text-white border-secondary" id="subject-${sliderId}" style="width: auto;">
|
| 360 |
+
<option value="auto">🤖 Auto-detect</option>
|
| 361 |
+
<option value="Biology">🌱 Biology</option>
|
| 362 |
+
<option value="Chemistry">🧪 Chemistry</option>
|
| 363 |
+
<option value="Physics">⚡ Physics</option>
|
| 364 |
+
<option value="Mathematics">📐 Mathematics</option>
|
| 365 |
+
</select>
|
| 366 |
+
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeRangeSlider(${sliderId})">
|
| 367 |
+
<i class="bi bi-trash"></i>
|
| 368 |
+
</button>
|
| 369 |
+
</div>
|
| 370 |
+
</div>
|
| 371 |
+
<div class="dual-range-container">
|
| 372 |
+
<div class="dual-range-track"></div>
|
| 373 |
+
<div class="dual-range-highlight" id="highlight-${sliderId}"></div>
|
| 374 |
+
<input type="range" min="1" max="${maxVal}" value="1" class="range-start" id="start-${sliderId}"
|
| 375 |
+
oninput="updateRangeSlider(${sliderId})">
|
| 376 |
+
<input type="range" min="1" max="${maxVal}" value="${maxVal}" class="range-end" id="end-${sliderId}"
|
| 377 |
+
oninput="updateRangeSlider(${sliderId})">
|
| 378 |
+
</div>
|
| 379 |
+
</div>
|
| 380 |
+
`;
|
| 381 |
+
|
| 382 |
+
container.insertAdjacentHTML('beforeend', sliderHtml);
|
| 383 |
+
customRanges.push({ id: sliderId, start: 1, end: maxVal, subject: 'auto' });
|
| 384 |
+
|
| 385 |
+
// Add subject change listener
|
| 386 |
+
document.getElementById(`subject-${sliderId}`).addEventListener('change', (e) => {
|
| 387 |
+
const rangeObj = customRanges.find(r => r.id === sliderId);
|
| 388 |
+
if (rangeObj) rangeObj.subject = e.target.value;
|
| 389 |
+
});
|
| 390 |
+
|
| 391 |
+
updateRangeSlider(sliderId);
|
| 392 |
+
updateSelectedRangesDisplay();
|
| 393 |
+
updateRangePreview();
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
// Update range slider visuals and values
|
| 397 |
+
function updateRangeSlider(sliderId) {
|
| 398 |
+
const startInput = document.getElementById(`start-${sliderId}`);
|
| 399 |
+
const endInput = document.getElementById(`end-${sliderId}`);
|
| 400 |
+
const highlight = document.getElementById(`highlight-${sliderId}`);
|
| 401 |
+
const startBadge = document.getElementById(`start-badge-${sliderId}`);
|
| 402 |
+
const endBadge = document.getElementById(`end-badge-${sliderId}`);
|
| 403 |
+
|
| 404 |
+
let startVal = parseInt(startInput.value);
|
| 405 |
+
let endVal = parseInt(endInput.value);
|
| 406 |
+
|
| 407 |
+
// Ensure start <= end
|
| 408 |
+
if (startVal > endVal) {
|
| 409 |
+
if (startInput === document.activeElement) {
|
| 410 |
+
endInput.value = startVal;
|
| 411 |
+
endVal = startVal;
|
| 412 |
+
} else {
|
| 413 |
+
startInput.value = endVal;
|
| 414 |
+
startVal = endVal;
|
| 415 |
+
}
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
// Update highlight bar
|
| 419 |
+
const max = parseInt(startInput.max);
|
| 420 |
+
const leftPercent = ((startVal - 1) / (max - 1)) * 100;
|
| 421 |
+
const rightPercent = ((endVal - 1) / (max - 1)) * 100;
|
| 422 |
+
highlight.style.left = leftPercent + '%';
|
| 423 |
+
highlight.style.width = (rightPercent - leftPercent) + '%';
|
| 424 |
+
|
| 425 |
+
// Update badges with actual question numbers
|
| 426 |
+
startBadge.textContent = `#${getQuestionNumber(startVal - 1)}`;
|
| 427 |
+
endBadge.textContent = `#${getQuestionNumber(endVal - 1)}`;
|
| 428 |
+
|
| 429 |
+
// Update stored range
|
| 430 |
+
const rangeObj = customRanges.find(r => r.id === sliderId);
|
| 431 |
+
if (rangeObj) {
|
| 432 |
+
rangeObj.start = startVal;
|
| 433 |
+
rangeObj.end = endVal;
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
updateSelectedRangesDisplay();
|
| 437 |
+
updateRangePreview();
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
// Remove a range slider
|
| 441 |
+
function removeRangeSlider(sliderId) {
|
| 442 |
+
document.getElementById(`slider-row-${sliderId}`)?.remove();
|
| 443 |
+
customRanges = customRanges.filter(r => r.id !== sliderId);
|
| 444 |
+
updateSelectedRangesDisplay();
|
| 445 |
+
updateRangePreview();
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
// Update the display of selected ranges
|
| 449 |
+
function updateSelectedRangesDisplay() {
|
| 450 |
+
const display = document.getElementById('selected-ranges-display');
|
| 451 |
+
const countSpan = document.getElementById('selected-count');
|
| 452 |
+
|
| 453 |
+
if (customRanges.length === 0) {
|
| 454 |
+
display.textContent = 'None';
|
| 455 |
+
countSpan.textContent = '0';
|
| 456 |
+
return;
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
const rangeStrs = customRanges.map(r => {
|
| 460 |
+
const startNum = getQuestionNumber(r.start - 1);
|
| 461 |
+
const endNum = getQuestionNumber(r.end - 1);
|
| 462 |
+
return r.start === r.end ? `#${startNum}` : `#${startNum}-#${endNum}`;
|
| 463 |
+
});
|
| 464 |
+
display.textContent = rangeStrs.join(', ');
|
| 465 |
+
|
| 466 |
+
// Count unique questions
|
| 467 |
+
const indices = new Set();
|
| 468 |
+
customRanges.forEach(r => {
|
| 469 |
+
for (let i = r.start; i <= r.end; i++) indices.add(i);
|
| 470 |
+
});
|
| 471 |
+
countSpan.textContent = indices.size;
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
// Update preview text
|
| 475 |
+
function updateRangePreview() {
|
| 476 |
+
if (customRanges.length === 0) return;
|
| 477 |
+
|
| 478 |
+
// Find first and last question across all ranges
|
| 479 |
+
let minQ = Infinity, maxQ = -Infinity;
|
| 480 |
+
customRanges.forEach(r => {
|
| 481 |
+
if (r.start < minQ) minQ = r.start;
|
| 482 |
+
if (r.end > maxQ) maxQ = r.end;
|
| 483 |
+
});
|
| 484 |
+
|
| 485 |
+
const startIdx = minQ - 1;
|
| 486 |
+
const endIdx = maxQ - 1;
|
| 487 |
+
|
| 488 |
+
document.getElementById('preview-start-text').textContent = getQuestionPreview(startIdx);
|
| 489 |
+
document.getElementById('preview-end-text').textContent = getQuestionPreview(endIdx);
|
| 490 |
+
document.getElementById('preview-start-num').textContent = `#${getQuestionNumber(startIdx)}`;
|
| 491 |
+
document.getElementById('preview-end-num').textContent = `#${getQuestionNumber(endIdx)}`;
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
// Setup range toggle handlers
|
| 495 |
+
function setupRangeToggles() {
|
| 496 |
+
document.querySelectorAll('.range-toggle').forEach(btn => {
|
| 497 |
+
btn.addEventListener('click', () => {
|
| 498 |
+
document.querySelectorAll('.range-toggle').forEach(b => b.classList.remove('active'));
|
| 499 |
+
btn.classList.add('active');
|
| 500 |
+
selectedRangeMode = btn.dataset.range;
|
| 501 |
+
|
| 502 |
+
// Show/hide custom range container
|
| 503 |
+
const customContainer = document.getElementById('custom-range-container');
|
| 504 |
+
if (selectedRangeMode === 'custom') {
|
| 505 |
+
customContainer.classList.remove('d-none');
|
| 506 |
+
// Add initial slider if none exist
|
| 507 |
+
if (customRanges.length === 0) {
|
| 508 |
+
addRangeSlider();
|
| 509 |
+
}
|
| 510 |
+
} else {
|
| 511 |
+
customContainer.classList.add('d-none');
|
| 512 |
+
}
|
| 513 |
+
});
|
| 514 |
+
});
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
// Subject detection keywords
|
| 518 |
+
const SUBJECT_KEYWORDS = {
|
| 519 |
+
'Biology': ['cell', 'dna', 'rna', 'protein', 'enzyme', 'mitosis', 'meiosis', 'photosynthesis', 'respiration', 'chromosome', 'gene', 'mutation', 'evolution', 'species', 'organism', 'tissue', 'organ', 'plant', 'animal', 'bacteria', 'virus', 'ecology', 'ecosystem', 'biodiversity', 'reproduction', 'hormone', 'neuron', 'blood', 'heart', 'kidney', 'liver', 'digestion', 'excretion', 'inheritance', 'mendel', 'allele', 'genotype', 'phenotype', 'biomolecule', 'lipid', 'carbohydrate', 'amino acid'],
|
| 520 |
+
'Chemistry': ['atom', 'molecule', 'electron', 'proton', 'neutron', 'orbital', 'bond', 'ionic', 'covalent', 'reaction', 'oxidation', 'reduction', 'acid', 'base', 'ph', 'salt', 'equilibrium', 'mole', 'molarity', 'concentration', 'solution', 'organic', 'inorganic', 'alkane', 'alkene', 'alkyne', 'alcohol', 'aldehyde', 'ketone', 'ester', 'ether', 'amine', 'polymer', 'thermodynamics', 'enthalpy', 'entropy', 'electrochemistry', 'electrolysis', 'catalyst', 'kinetics', 'periodic table', 'element', 'compound'],
|
| 521 |
+
'Physics': ['force', 'velocity', 'acceleration', 'momentum', 'energy', 'work', 'power', 'newton', 'gravity', 'friction', 'motion', 'wave', 'frequency', 'wavelength', 'amplitude', 'sound', 'light', 'optics', 'lens', 'mirror', 'reflection', 'refraction', 'diffraction', 'interference', 'electric', 'current', 'voltage', 'resistance', 'capacitor', 'inductor', 'magnetic', 'electromagnetic', 'quantum', 'photon', 'nuclear', 'radioactive', 'thermodynamics', 'heat', 'temperature', 'pressure'],
|
| 522 |
+
'Mathematics': ['equation', 'function', 'derivative', 'integral', 'limit', 'matrix', 'determinant', 'vector', 'probability', 'statistics', 'mean', 'median', 'variance', 'trigonometry', 'sine', 'cosine', 'tangent', 'logarithm', 'exponential', 'polynomial', 'quadratic', 'linear', 'geometry', 'circle', 'triangle', 'parabola', 'ellipse', 'hyperbola', 'calculus', 'differentiation', 'integration', 'sequence', 'series', 'permutation', 'combination', 'set', 'relation', 'complex number']
|
| 523 |
+
};
|
| 524 |
+
|
| 525 |
+
// All NCERT chapters by subject for autocomplete
|
| 526 |
+
const ALL_CHAPTERS = {
|
| 527 |
+
'Biology': [
|
| 528 |
+
'The Living World', 'Biological Classification', 'Plant Kingdom', 'Animal Kingdom',
|
| 529 |
+
'Morphology of Flowering Plants', 'Anatomy of Flowering Plants', 'Structural Organisation in Animals',
|
| 530 |
+
'Cell: The Unit of Life', 'Biomolecules', 'Cell Cycle and Cell Division',
|
| 531 |
+
'Photosynthesis in Higher Plants', 'Respiration in Plants', 'Plant Growth and Development',
|
| 532 |
+
'Breathing and Exchange of Gases', 'Body Fluids and Circulation', 'Excretory Products and their Elimination',
|
| 533 |
+
'Locomotion and Movement', 'Neural Control and Coordination', 'Chemical Coordination and Integration',
|
| 534 |
+
'Sexual Reproduction in Flowering Plants', 'Human Reproduction', 'Reproductive Health',
|
| 535 |
+
'Principles of Inheritance and Variation', 'Molecular Basis of Inheritance', 'Evolution',
|
| 536 |
+
'Health and Disease', 'Improvement in Food Production', 'Microbes in Human Welfare',
|
| 537 |
+
'Biotechnology - Principles and Processes', 'Biotechnology and Its Applications',
|
| 538 |
+
'Organisms and Populations', 'Ecosystem', 'Biodiversity and Its Conservation'
|
| 539 |
+
],
|
| 540 |
+
'Chemistry': [
|
| 541 |
+
'Some Basic Concepts of Chemistry', 'Structure of Atom', 'Classification of Elements and Periodicity in Properties',
|
| 542 |
+
'Chemical Bonding and Molecular Structure', 'States of Matter: Gases and Liquids', 'Thermodynamics',
|
| 543 |
+
'Equilibrium', 'Redox Reactions', 'Hydrogen', 'The s-Block Elements', 'The p-Block Elements (Group 13 and 14)',
|
| 544 |
+
'Organic Chemistry – Some Basic Principles and Techniques (GOC)', 'Hydrocarbons', 'Environmental Chemistry',
|
| 545 |
+
'The Solid State', 'Solutions', 'Electrochemistry', 'Chemical Kinetics', 'Surface Chemistry',
|
| 546 |
+
'General Principles and Processes of Isolation of Elements (Metallurgy)', 'The p-Block Elements (Group 15 to 18)',
|
| 547 |
+
'The d- and f- Block Elements', 'Coordination Compounds', 'Haloalkanes and Haloarenes',
|
| 548 |
+
'Alcohols, Phenols and Ethers', 'Aldehydes, Ketones and Carboxylic Acids', 'Amines',
|
| 549 |
+
'Biomolecules', 'Polymers', 'Chemistry in Everyday Life'
|
| 550 |
+
],
|
| 551 |
+
'Physics': [
|
| 552 |
+
'Units and Measurements', 'Motion in a Straight Line', 'Motion in a Plane', 'Laws of Motion',
|
| 553 |
+
'Work, Energy and Power', 'System of Particles and Rotational Motion', 'Gravitation',
|
| 554 |
+
'Mechanical Properties of Solids', 'Mechanical Properties of Fluids', 'Thermal Properties of Matter',
|
| 555 |
+
'Thermodynamics', 'Kinetic Theory', 'Oscillations', 'Waves',
|
| 556 |
+
'Electric Charges and Fields', 'Electrostatic Potential and Capacitance', 'Current Electricity',
|
| 557 |
+
'Moving Charges and Magnetism', 'Magnetism and Matter', 'Electromagnetic Induction',
|
| 558 |
+
'Alternating Current', 'Electromagnetic Waves', 'Ray Optics and Optical Instruments',
|
| 559 |
+
'Wave Optics', 'Dual Nature of Radiation and Matter', 'Atoms', 'Nuclei',
|
| 560 |
+
'Semiconductor Electronics: Materials, Devices and Simple Circuits', 'Communication Systems'
|
| 561 |
+
],
|
| 562 |
+
'Mathematics': [
|
| 563 |
+
'Sets', 'Relations and Functions', 'Trigonometric Functions', 'Principle of Mathematical Induction',
|
| 564 |
+
'Complex Numbers and Quadratic Equations', 'Linear Inequalities', 'Permutations and Combinations',
|
| 565 |
+
'Binomial Theorem', 'Sequences and Series', 'Straight Lines', 'Conic Sections',
|
| 566 |
+
'Introduction to Three Dimensional Geometry', 'Limits and Derivatives', 'Mathematical Reasoning',
|
| 567 |
+
'Statistics', 'Probability', 'Inverse Trigonometric Functions', 'Matrices', 'Determinants',
|
| 568 |
+
'Continuity and Differentiability', 'Application of Derivatives', 'Integrals',
|
| 569 |
+
'Application of Integrals', 'Differential Equations', 'Vector Algebra',
|
| 570 |
+
'Three Dimensional Geometry', 'Linear Programming'
|
| 571 |
+
]
|
| 572 |
+
};
|
| 573 |
+
|
| 574 |
+
function detectSubject(text) {
|
| 575 |
+
const lowerText = text.toLowerCase();
|
| 576 |
+
const scores = {};
|
| 577 |
+
|
| 578 |
+
for (const [subject, keywords] of Object.entries(SUBJECT_KEYWORDS)) {
|
| 579 |
+
scores[subject] = 0;
|
| 580 |
+
for (const keyword of keywords) {
|
| 581 |
+
if (lowerText.includes(keyword)) {
|
| 582 |
+
scores[subject]++;
|
| 583 |
+
}
|
| 584 |
+
}
|
| 585 |
+
}
|
| 586 |
+
|
| 587 |
+
// Sort by score and return top subjects
|
| 588 |
+
const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]);
|
| 589 |
+
const topSubjects = sorted.filter(([_, score]) => score > 0).slice(0, 2).map(([subj, _]) => subj);
|
| 590 |
+
|
| 591 |
+
return topSubjects.length > 0 ? topSubjects : ['Biology']; // Default to Biology
|
| 592 |
+
}
|
| 593 |
+
|
| 594 |
+
function setActiveSubject(subject) {
|
| 595 |
+
manualSubject = subject;
|
| 596 |
+
document.querySelectorAll('.subject-pill').forEach(pill => {
|
| 597 |
+
if (pill.dataset.subject === subject) {
|
| 598 |
+
pill.classList.remove('btn-outline-success', 'btn-outline-warning', 'btn-outline-info', 'btn-outline-danger');
|
| 599 |
+
pill.classList.add('btn-success', 'btn-warning', 'btn-info', 'btn-danger');
|
| 600 |
+
// Keep the appropriate color
|
| 601 |
+
if (subject === 'Biology') { pill.className = 'btn btn-success subject-pill'; }
|
| 602 |
+
else if (subject === 'Chemistry') { pill.className = 'btn btn-warning subject-pill'; }
|
| 603 |
+
else if (subject === 'Physics') { pill.className = 'btn btn-info subject-pill'; }
|
| 604 |
+
else if (subject === 'Mathematics') { pill.className = 'btn btn-danger subject-pill'; }
|
| 605 |
+
} else {
|
| 606 |
+
// Reset to outline
|
| 607 |
+
if (pill.dataset.subject === 'Biology') { pill.className = 'btn btn-outline-success subject-pill'; }
|
| 608 |
+
else if (pill.dataset.subject === 'Chemistry') { pill.className = 'btn btn-outline-warning subject-pill'; }
|
| 609 |
+
else if (pill.dataset.subject === 'Physics') { pill.className = 'btn btn-outline-info subject-pill'; }
|
| 610 |
+
else if (pill.dataset.subject === 'Mathematics') { pill.className = 'btn btn-outline-danger subject-pill'; }
|
| 611 |
+
}
|
| 612 |
+
});
|
| 613 |
+
}
|
| 614 |
+
|
| 615 |
+
function getVisibleRows() {
|
| 616 |
+
// Get only currently visible (filtered) rows
|
| 617 |
+
return Array.from(document.querySelectorAll('#questions-table-body tr')).filter(row => row.style.display !== 'none');
|
| 618 |
+
}
|
| 619 |
+
|
| 620 |
+
function updateTypingSuggestions(inputValue) {
|
| 621 |
+
const container = document.getElementById('ai_suggestion_container');
|
| 622 |
+
const chipsContainer = document.getElementById('ai_suggestion_chips');
|
| 623 |
+
|
| 624 |
+
if (!inputValue || inputValue.length < 2) {
|
| 625 |
+
return;
|
| 626 |
+
}
|
| 627 |
+
|
| 628 |
+
const chapters = ALL_CHAPTERS[manualSubject] || [];
|
| 629 |
+
const searchLower = inputValue.toLowerCase();
|
| 630 |
+
|
| 631 |
+
// Filter chapters that match the input
|
| 632 |
+
const matches = chapters.filter(ch => ch.toLowerCase().includes(searchLower)).slice(0, 6);
|
| 633 |
+
|
| 634 |
+
if (matches.length > 0) {
|
| 635 |
+
container.classList.remove('d-none');
|
| 636 |
+
chipsContainer.innerHTML = '';
|
| 637 |
+
|
| 638 |
+
matches.forEach(suggestion => {
|
| 639 |
+
const chip = document.createElement('button');
|
| 640 |
+
chip.className = 'btn btn-outline-secondary btn-sm rounded-pill';
|
| 641 |
+
chip.innerText = suggestion;
|
| 642 |
+
chip.onclick = () => {
|
| 643 |
+
document.getElementById('topic_input').value = suggestion;
|
| 644 |
+
chipsContainer.innerHTML = '';
|
| 645 |
+
container.classList.add('d-none');
|
| 646 |
+
};
|
| 647 |
+
chipsContainer.appendChild(chip);
|
| 648 |
+
});
|
| 649 |
+
}
|
| 650 |
+
}
|
| 651 |
+
|
| 652 |
document.addEventListener('DOMContentLoaded', function () {
|
| 653 |
const editModal = new bootstrap.Modal(document.getElementById('editModal'));
|
| 654 |
const editQuestionId = document.getElementById('edit-question-id');
|
|
|
|
| 677 |
const result = await response.json();
|
| 678 |
|
| 679 |
if (result.success) {
|
|
|
|
| 680 |
const row = document.querySelector(`button[data-id='${id}']`).closest('tr');
|
| 681 |
row.querySelector('.topic-cell').textContent = topic;
|
| 682 |
row.querySelector('.subject-cell').textContent = subject;
|
|
|
|
| 683 |
const editBtn = row.querySelector('.edit-btn');
|
| 684 |
editBtn.dataset.topic = topic;
|
| 685 |
editBtn.dataset.subject = subject;
|
|
|
|
| 686 |
editModal.hide();
|
| 687 |
} else {
|
| 688 |
alert(`Error: ${result.error}`);
|
|
|
|
| 706 |
const row = rows[i];
|
| 707 |
const topic = row.dataset.topic;
|
| 708 |
const text = row.textContent || row.innerText;
|
| 709 |
+
|
| 710 |
const topicMatch = (selectedTopic === 'all' || topic === selectedTopic);
|
| 711 |
const searchMatch = (text.toLowerCase().indexOf(searchText) > -1);
|
| 712 |
|
|
|
|
| 720 |
|
| 721 |
searchInput.addEventListener('keyup', filterTable);
|
| 722 |
topicFilter.addEventListener('change', filterTable);
|
| 723 |
+
|
| 724 |
+
// Apply filter on page load (default to Unclassified)
|
| 725 |
+
filterTable();
|
| 726 |
+
|
| 727 |
+
// Update visible count when modal opens
|
| 728 |
+
document.getElementById('manualClassificationModal').addEventListener('show.bs.modal', () => {
|
| 729 |
+
const visibleCount = getVisibleRows().length;
|
| 730 |
+
document.getElementById('visible-count-info').textContent =
|
| 731 |
+
`${visibleCount} questions currently visible. Classification will apply to these.`;
|
| 732 |
+
// Reset slider state when modal opens
|
| 733 |
+
customRanges = [];
|
| 734 |
+
document.getElementById('range-sliders-container').innerHTML = '';
|
| 735 |
+
document.getElementById('custom-range-container').classList.add('d-none');
|
| 736 |
+
document.querySelectorAll('.range-toggle').forEach(b => b.classList.toggle('active', b.dataset.range === 'all'));
|
| 737 |
+
selectedRangeMode = 'all';
|
| 738 |
+
});
|
| 739 |
+
|
| 740 |
+
// Setup range toggles
|
| 741 |
+
setupRangeToggles();
|
| 742 |
+
|
| 743 |
+
// Live autocomplete as user types in topic input
|
| 744 |
+
document.getElementById('topic_input').addEventListener('input', (e) => {
|
| 745 |
+
updateTypingSuggestions(e.target.value);
|
| 746 |
+
});
|
| 747 |
});
|
| 748 |
+
|
| 749 |
+
function parseRange(rangeStr, maxVal) {
|
| 750 |
+
const indices = new Set();
|
| 751 |
+
const parts = rangeStr.split(',');
|
| 752 |
+
for (let part of parts) {
|
| 753 |
+
part = part.trim();
|
| 754 |
+
if (!part) continue;
|
| 755 |
+
if (part.includes('-')) {
|
| 756 |
+
const [start, end] = part.split('-').map(Number);
|
| 757 |
+
if (!isNaN(start) && !isNaN(end)) {
|
| 758 |
+
for (let i = start; i <= end; i++) indices.add(i);
|
| 759 |
+
}
|
| 760 |
+
} else {
|
| 761 |
+
const num = Number(part);
|
| 762 |
+
if (!isNaN(num)) indices.add(num);
|
| 763 |
+
}
|
| 764 |
+
}
|
| 765 |
+
return Array.from(indices).filter(i => i >= 1 && i <= maxVal).sort((a, b) => a - b);
|
| 766 |
+
}
|
| 767 |
+
|
| 768 |
+
function startManualClassification() {
|
| 769 |
+
// Get only visible (filtered) rows
|
| 770 |
+
const visibleRows = getVisibleRows();
|
| 771 |
+
const numQuestions = visibleRows.length;
|
| 772 |
+
|
| 773 |
+
if (numQuestions === 0) {
|
| 774 |
+
alert("No questions visible. Please adjust your filter.");
|
| 775 |
+
return;
|
| 776 |
+
}
|
| 777 |
+
|
| 778 |
+
// Build list based on selected mode
|
| 779 |
+
questionSubjectMap.clear();
|
| 780 |
+
if (selectedRangeMode === 'custom') {
|
| 781 |
+
// Use custom slider ranges with their subject settings
|
| 782 |
+
if (customRanges.length === 0) {
|
| 783 |
+
alert("Please add at least one range.");
|
| 784 |
+
return;
|
| 785 |
+
}
|
| 786 |
+
const visibleIndices = visibleRows.map((row, idx) => ({ idx, qIndex: parseInt(row.dataset.index) }));
|
| 787 |
+
const selectedIndices = new Set();
|
| 788 |
+
customRanges.forEach(r => {
|
| 789 |
+
for (let i = r.start; i <= r.end; i++) {
|
| 790 |
+
if (i <= visibleIndices.length) {
|
| 791 |
+
const qIndex = visibleIndices[i - 1].qIndex;
|
| 792 |
+
selectedIndices.add(qIndex);
|
| 793 |
+
// Store subject for this question (if not auto)
|
| 794 |
+
if (r.subject && r.subject !== 'auto') {
|
| 795 |
+
questionSubjectMap.set(qIndex, r.subject);
|
| 796 |
+
}
|
| 797 |
+
}
|
| 798 |
+
}
|
| 799 |
+
});
|
| 800 |
+
manualQuestionsList = Array.from(selectedIndices).sort((a, b) => a - b);
|
| 801 |
+
} else if (selectedRangeMode === 'unclassified') {
|
| 802 |
+
// Only unclassified from visible
|
| 803 |
+
manualQuestionsList = visibleRows
|
| 804 |
+
.filter(row => {
|
| 805 |
+
const topic = row.querySelector('.topic-cell').textContent;
|
| 806 |
+
return !topic || topic === 'Unclassified';
|
| 807 |
+
})
|
| 808 |
+
.map(row => parseInt(row.dataset.index));
|
| 809 |
+
} else {
|
| 810 |
+
// All visible questions
|
| 811 |
+
manualQuestionsList = visibleRows.map(row => parseInt(row.dataset.index));
|
| 812 |
+
}
|
| 813 |
+
|
| 814 |
+
if (manualQuestionsList.length === 0) {
|
| 815 |
+
alert("No questions match the selected criteria.");
|
| 816 |
+
return;
|
| 817 |
+
}
|
| 818 |
+
|
| 819 |
+
currentManualIndex = 0;
|
| 820 |
+
suggestionCache.clear();
|
| 821 |
+
|
| 822 |
+
const modal1 = bootstrap.Modal.getInstance(document.getElementById('manualClassificationModal'));
|
| 823 |
+
modal1.hide();
|
| 824 |
+
|
| 825 |
+
const modal2 = new bootstrap.Modal(document.getElementById('topicSelectionModal'));
|
| 826 |
+
modal2.show();
|
| 827 |
+
|
| 828 |
+
// Setup subject pill click handlers
|
| 829 |
+
document.querySelectorAll('.subject-pill').forEach(pill => {
|
| 830 |
+
pill.onclick = () => {
|
| 831 |
+
setActiveSubject(pill.dataset.subject);
|
| 832 |
+
// Clear suggestions and show loading when subject changes
|
| 833 |
+
const container = document.getElementById('ai_suggestion_container');
|
| 834 |
+
const chipsContainer = document.getElementById('ai_suggestion_chips');
|
| 835 |
+
container.classList.remove('d-none');
|
| 836 |
+
chipsContainer.innerHTML = '<span class="text-muted"><i class="bi bi-hourglass-split me-1"></i>Loading...</span>';
|
| 837 |
+
document.getElementById('topic_input').value = '';
|
| 838 |
+
// Clear cache for current question and refetch
|
| 839 |
+
const qIndex = manualQuestionsList[currentManualIndex];
|
| 840 |
+
const row = document.querySelector(`#questions-table-body tr[data-index="${qIndex}"]`);
|
| 841 |
+
if (row) {
|
| 842 |
+
suggestionCache.delete(row.dataset.id);
|
| 843 |
+
getAiSuggestion();
|
| 844 |
+
}
|
| 845 |
+
};
|
| 846 |
+
});
|
| 847 |
+
|
| 848 |
+
loadTopicQuestion();
|
| 849 |
+
|
| 850 |
+
// Prefetch suggestions for up to 30 questions in background
|
| 851 |
+
prefetchSuggestions(0, 30);
|
| 852 |
+
}
|
| 853 |
+
|
| 854 |
+
function prefetchSuggestions(startIndex, count) {
|
| 855 |
+
// Group questions by their pre-selected subject for batch processing
|
| 856 |
+
const subjectBatches = new Map(); // subject -> [{qIndex, questionId}]
|
| 857 |
+
const autoDetectQueue = []; // Questions without pre-selected subject
|
| 858 |
+
|
| 859 |
+
for (let i = startIndex; i < startIndex + count && i < manualQuestionsList.length; i++) {
|
| 860 |
+
const qIndex = manualQuestionsList[i];
|
| 861 |
+
const row = document.querySelector(`#questions-table-body tr[data-index="${qIndex}"]`);
|
| 862 |
+
if (!row) continue;
|
| 863 |
+
const questionId = row.dataset.id;
|
| 864 |
+
|
| 865 |
+
if (suggestionCache.has(questionId)) continue;
|
| 866 |
+
|
| 867 |
+
const preSelectedSubject = questionSubjectMap.get(qIndex);
|
| 868 |
+
if (preSelectedSubject) {
|
| 869 |
+
// Group by subject for batch processing
|
| 870 |
+
if (!subjectBatches.has(preSelectedSubject)) {
|
| 871 |
+
subjectBatches.set(preSelectedSubject, []);
|
| 872 |
+
}
|
| 873 |
+
subjectBatches.get(preSelectedSubject).push({ qIndex, questionId });
|
| 874 |
+
} else {
|
| 875 |
+
// Auto-detect: process individually
|
| 876 |
+
autoDetectQueue.push({ qIndex, questionId });
|
| 877 |
+
}
|
| 878 |
+
}
|
| 879 |
+
|
| 880 |
+
// Process batched requests (up to 8 per subject per batch)
|
| 881 |
+
for (const [subject, questions] of subjectBatches) {
|
| 882 |
+
for (let i = 0; i < questions.length; i += 8) {
|
| 883 |
+
const batch = questions.slice(i, i + 8);
|
| 884 |
+
const questionIds = batch.map(q => q.questionId);
|
| 885 |
+
|
| 886 |
+
// Create a single promise for the batch
|
| 887 |
+
const batchPromise = fetch('/neetprep/get_suggestions_batch', {
|
| 888 |
+
method: 'POST',
|
| 889 |
+
headers: { 'Content-Type': 'application/json' },
|
| 890 |
+
body: JSON.stringify({ question_ids: questionIds, subject: subject })
|
| 891 |
+
})
|
| 892 |
+
.then(res => res.json())
|
| 893 |
+
.then(data => {
|
| 894 |
+
if (data.success && data.results) {
|
| 895 |
+
return data.results; // Map of questionId -> result
|
| 896 |
+
}
|
| 897 |
+
throw new Error(data.error || 'Batch request failed');
|
| 898 |
+
})
|
| 899 |
+
.catch(err => {
|
| 900 |
+
// Return fallback for all questions in batch
|
| 901 |
+
const fallback = {};
|
| 902 |
+
questionIds.forEach(id => {
|
| 903 |
+
fallback[id] = { status: 'error', suggestions: ['Unclassified'], subject: subject, error: err.message };
|
| 904 |
+
});
|
| 905 |
+
return fallback;
|
| 906 |
+
});
|
| 907 |
+
|
| 908 |
+
// Store promise for each question that resolves to its specific result
|
| 909 |
+
batch.forEach(q => {
|
| 910 |
+
suggestionCache.set(q.questionId, batchPromise.then(results => {
|
| 911 |
+
const result = results[q.questionId];
|
| 912 |
+
if (result) {
|
| 913 |
+
return {
|
| 914 |
+
status: 'done',
|
| 915 |
+
suggestions: result.suggestions || ['Unclassified'],
|
| 916 |
+
subject: result.subject || subject,
|
| 917 |
+
otherSubjects: result.other_possible_subjects || []
|
| 918 |
+
};
|
| 919 |
+
}
|
| 920 |
+
return { status: 'done', suggestions: ['Unclassified'], subject: subject, otherSubjects: [] };
|
| 921 |
+
}));
|
| 922 |
+
});
|
| 923 |
+
}
|
| 924 |
+
}
|
| 925 |
+
|
| 926 |
+
// Process auto-detect questions individually
|
| 927 |
+
for (const q of autoDetectQueue) {
|
| 928 |
+
const promise = fetch(`/neetprep/get_suggestions/${q.questionId}`, {
|
| 929 |
+
method: 'POST',
|
| 930 |
+
headers: { 'Content-Type': 'application/json' },
|
| 931 |
+
body: JSON.stringify({})
|
| 932 |
+
})
|
| 933 |
+
.then(res => res.json())
|
| 934 |
+
.then(data => {
|
| 935 |
+
if (data.success) {
|
| 936 |
+
return {
|
| 937 |
+
status: 'done',
|
| 938 |
+
suggestions: data.suggestions || ['Unclassified'],
|
| 939 |
+
subject: data.subject,
|
| 940 |
+
otherSubjects: data.other_possible_subjects || []
|
| 941 |
+
};
|
| 942 |
+
}
|
| 943 |
+
return { status: 'done', suggestions: ['Unclassified'], subject: 'Biology' };
|
| 944 |
+
})
|
| 945 |
+
.catch(err => ({ status: 'error', suggestions: ['Unclassified'], subject: 'Biology', error: err.message }));
|
| 946 |
+
|
| 947 |
+
suggestionCache.set(q.questionId, promise);
|
| 948 |
+
}
|
| 949 |
+
}
|
| 950 |
+
|
| 951 |
+
function loadTopicQuestion() {
|
| 952 |
+
const qIndex = manualQuestionsList[currentManualIndex];
|
| 953 |
+
const row = document.querySelector(`#questions-table-body tr[data-index="${qIndex}"]`);
|
| 954 |
+
if (!row) return;
|
| 955 |
+
|
| 956 |
+
const questionId = row.dataset.id;
|
| 957 |
+
const questionHtml = row.dataset.html || row.querySelector('.question-text-cell').textContent;
|
| 958 |
+
const questionPlainText = row.querySelector('.question-text-cell').textContent;
|
| 959 |
+
const currentTopic = row.querySelector('.topic-cell').textContent;
|
| 960 |
+
const currentSubjectFromRow = row.querySelector('.subject-cell').textContent;
|
| 961 |
+
|
| 962 |
+
// Check if subject was pre-selected in slider, otherwise auto-detect
|
| 963 |
+
const preSelectedSubject = questionSubjectMap.get(qIndex);
|
| 964 |
+
if (preSelectedSubject) {
|
| 965 |
+
manualSubject = preSelectedSubject;
|
| 966 |
+
} else if (currentSubjectFromRow && currentSubjectFromRow !== 'Unclassified' && ALL_CHAPTERS[currentSubjectFromRow]) {
|
| 967 |
+
manualSubject = currentSubjectFromRow;
|
| 968 |
+
} else {
|
| 969 |
+
const detectedSubjects = detectSubject(questionPlainText);
|
| 970 |
+
manualSubject = detectedSubjects[0];
|
| 971 |
+
}
|
| 972 |
+
setActiveSubject(manualSubject);
|
| 973 |
+
|
| 974 |
+
// Update UI
|
| 975 |
+
document.getElementById('topic_q_num').innerText = `#${qIndex}`;
|
| 976 |
+
document.getElementById('topic_q_text').innerHTML = questionHtml; // Render HTML
|
| 977 |
+
document.getElementById('topic_input').value = (currentTopic && currentTopic !== 'Unclassified') ? currentTopic : '';
|
| 978 |
+
document.getElementById('topic_progress').innerText = `${currentManualIndex + 1}/${manualQuestionsList.length}`;
|
| 979 |
+
|
| 980 |
+
// Update progress bar
|
| 981 |
+
const progressPercent = ((currentManualIndex + 1) / manualQuestionsList.length) * 100;
|
| 982 |
+
document.getElementById('topic_progress_bar').style.width = `${progressPercent}%`;
|
| 983 |
+
|
| 984 |
+
// Clear previous suggestions
|
| 985 |
+
document.getElementById('ai_suggestion_container').classList.add('d-none');
|
| 986 |
+
document.getElementById('ai_suggestion_chips').innerHTML = '';
|
| 987 |
+
document.getElementById('ai_error_msg').classList.add('d-none');
|
| 988 |
+
|
| 989 |
+
// Reset button
|
| 990 |
+
const btn = document.getElementById('btn_get_suggestion');
|
| 991 |
+
btn.disabled = false;
|
| 992 |
+
btn.querySelector('.spinner-border').classList.add('d-none');
|
| 993 |
+
|
| 994 |
+
// Auto-get suggestions if topic is empty
|
| 995 |
+
if (!document.getElementById('topic_input').value) {
|
| 996 |
+
getAiSuggestion();
|
| 997 |
+
}
|
| 998 |
+
}
|
| 999 |
+
|
| 1000 |
+
async function nextTopicQuestion() {
|
| 1001 |
+
// Save in background (don't await - non-blocking)
|
| 1002 |
+
saveCurrentTopic();
|
| 1003 |
+
|
| 1004 |
+
if (currentManualIndex < manualQuestionsList.length - 1) {
|
| 1005 |
+
currentManualIndex++;
|
| 1006 |
+
loadTopicQuestion();
|
| 1007 |
+
// Prefetch more suggestions as we progress (sliding window of 30)
|
| 1008 |
+
prefetchSuggestions(currentManualIndex + 1, 30);
|
| 1009 |
+
} else {
|
| 1010 |
+
const modal2 = bootstrap.Modal.getInstance(document.getElementById('topicSelectionModal'));
|
| 1011 |
+
modal2.hide();
|
| 1012 |
+
alert("Manual classification completed!");
|
| 1013 |
+
// Refresh the filter to show updated results
|
| 1014 |
+
document.getElementById('topic-filter').dispatchEvent(new Event('change'));
|
| 1015 |
+
}
|
| 1016 |
+
}
|
| 1017 |
+
|
| 1018 |
+
function prevTopicQuestion() {
|
| 1019 |
+
if (currentManualIndex > 0) {
|
| 1020 |
+
currentManualIndex--;
|
| 1021 |
+
loadTopicQuestion();
|
| 1022 |
+
}
|
| 1023 |
+
}
|
| 1024 |
+
|
| 1025 |
+
function saveCurrentTopic() {
|
| 1026 |
+
const qIndex = manualQuestionsList[currentManualIndex];
|
| 1027 |
+
const row = document.querySelector(`#questions-table-body tr[data-index="${qIndex}"]`);
|
| 1028 |
+
if (!row) return;
|
| 1029 |
+
|
| 1030 |
+
const questionId = row.dataset.id;
|
| 1031 |
+
const topic = document.getElementById('topic_input').value || 'Unclassified';
|
| 1032 |
+
|
| 1033 |
+
// Update UI immediately
|
| 1034 |
+
row.querySelector('.topic-cell').textContent = topic;
|
| 1035 |
+
row.querySelector('.subject-cell').textContent = manualSubject;
|
| 1036 |
+
row.dataset.topic = topic; // Update data attribute for filtering
|
| 1037 |
+
const editBtn = row.querySelector('.edit-btn');
|
| 1038 |
+
if (editBtn) {
|
| 1039 |
+
editBtn.dataset.topic = topic;
|
| 1040 |
+
editBtn.dataset.subject = manualSubject;
|
| 1041 |
+
}
|
| 1042 |
+
|
| 1043 |
+
// Save to backend in background (fire and forget)
|
| 1044 |
+
fetch(`/neetprep/update_question/${questionId}`, {
|
| 1045 |
+
method: 'POST',
|
| 1046 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1047 |
+
body: JSON.stringify({ topic: topic, subject: manualSubject })
|
| 1048 |
+
}).catch(e => console.error("Failed to save topic", e));
|
| 1049 |
+
}
|
| 1050 |
+
|
| 1051 |
+
async function getAiSuggestion() {
|
| 1052 |
+
const qIndex = manualQuestionsList[currentManualIndex];
|
| 1053 |
+
const row = document.querySelector(`#questions-table-body tr[data-index="${qIndex}"]`);
|
| 1054 |
+
if (!row) return;
|
| 1055 |
+
|
| 1056 |
+
const questionId = row.dataset.id;
|
| 1057 |
+
const btn = document.getElementById('btn_get_suggestion');
|
| 1058 |
+
const container = document.getElementById('ai_suggestion_container');
|
| 1059 |
+
const chipsContainer = document.getElementById('ai_suggestion_chips');
|
| 1060 |
+
const errorDiv = document.getElementById('ai_error_msg');
|
| 1061 |
+
|
| 1062 |
+
btn.disabled = true;
|
| 1063 |
+
btn.querySelector('.spinner-border').classList.remove('d-none');
|
| 1064 |
+
errorDiv.classList.add('d-none');
|
| 1065 |
+
|
| 1066 |
+
try {
|
| 1067 |
+
let result;
|
| 1068 |
+
if (suggestionCache.has(questionId)) {
|
| 1069 |
+
result = await suggestionCache.get(questionId);
|
| 1070 |
+
} else {
|
| 1071 |
+
// Check if subject was pre-selected in slider
|
| 1072 |
+
const preSelectedSubject = questionSubjectMap.get(qIndex);
|
| 1073 |
+
const requestBody = preSelectedSubject
|
| 1074 |
+
? { subject: preSelectedSubject }
|
| 1075 |
+
: {}; // No subject - AI will detect
|
| 1076 |
+
|
| 1077 |
+
const promise = fetch(`/neetprep/get_suggestions/${questionId}`, {
|
| 1078 |
+
method: 'POST',
|
| 1079 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1080 |
+
body: JSON.stringify(requestBody)
|
| 1081 |
+
})
|
| 1082 |
+
.then(res => res.json())
|
| 1083 |
+
.then(data => ({
|
| 1084 |
+
status: 'done',
|
| 1085 |
+
suggestions: data.suggestions || ['Unclassified'],
|
| 1086 |
+
subject: data.subject,
|
| 1087 |
+
otherSubjects: data.other_possible_subjects || []
|
| 1088 |
+
}))
|
| 1089 |
+
.catch(err => ({ status: 'error', suggestions: ['Unclassified'], subject: 'Biology', error: err.message }));
|
| 1090 |
+
|
| 1091 |
+
suggestionCache.set(questionId, promise);
|
| 1092 |
+
result = await promise;
|
| 1093 |
+
}
|
| 1094 |
+
|
| 1095 |
+
// Update subject from AI response
|
| 1096 |
+
if (result.subject) {
|
| 1097 |
+
setActiveSubject(result.subject);
|
| 1098 |
+
}
|
| 1099 |
+
|
| 1100 |
+
container.classList.remove('d-none');
|
| 1101 |
+
chipsContainer.innerHTML = '';
|
| 1102 |
+
|
| 1103 |
+
// Add a label for AI suggestions
|
| 1104 |
+
const label = document.createElement('span');
|
| 1105 |
+
label.className = 'badge bg-info me-2';
|
| 1106 |
+
label.innerText = 'AI';
|
| 1107 |
+
chipsContainer.appendChild(label);
|
| 1108 |
+
|
| 1109 |
+
// Show other possible subjects if detected
|
| 1110 |
+
if (result.otherSubjects && result.otherSubjects.length > 0) {
|
| 1111 |
+
const otherLabel = document.createElement('span');
|
| 1112 |
+
otherLabel.className = 'badge bg-secondary me-2';
|
| 1113 |
+
otherLabel.innerText = `Also: ${result.otherSubjects.join(', ')}`;
|
| 1114 |
+
otherLabel.title = 'This question may also belong to these subjects';
|
| 1115 |
+
chipsContainer.appendChild(otherLabel);
|
| 1116 |
+
}
|
| 1117 |
+
|
| 1118 |
+
result.suggestions.forEach(suggestion => {
|
| 1119 |
+
const chip = document.createElement('button');
|
| 1120 |
+
chip.className = 'btn btn-outline-info btn-sm rounded-pill';
|
| 1121 |
+
chip.innerText = suggestion;
|
| 1122 |
+
chip.onclick = () => {
|
| 1123 |
+
document.getElementById('topic_input').value = suggestion;
|
| 1124 |
+
};
|
| 1125 |
+
chipsContainer.appendChild(chip);
|
| 1126 |
+
});
|
| 1127 |
+
|
| 1128 |
+
// Auto-fill if empty
|
| 1129 |
+
const currentVal = document.getElementById('topic_input').value;
|
| 1130 |
+
if (!currentVal && result.suggestions.length > 0) {
|
| 1131 |
+
document.getElementById('topic_input').value = result.suggestions[0];
|
| 1132 |
+
}
|
| 1133 |
+
|
| 1134 |
+
} catch (e) {
|
| 1135 |
+
errorDiv.innerText = `Error: ${e.message}`;
|
| 1136 |
+
errorDiv.classList.remove('d-none');
|
| 1137 |
+
} finally {
|
| 1138 |
+
btn.disabled = false;
|
| 1139 |
+
btn.querySelector('.spinner-border').classList.add('d-none');
|
| 1140 |
+
}
|
| 1141 |
+
}
|
| 1142 |
</script>
|
| 1143 |
{% endblock %}
|
templates/pdf_manager.html
CHANGED
|
@@ -128,7 +128,6 @@
|
|
| 128 |
</nav>
|
| 129 |
{% endif %}
|
| 130 |
|
| 131 |
-
<form id="pdf-print-form" action="/print_pdfs" method="post" target="_blank">
|
| 132 |
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-4">
|
| 133 |
{% if not all_view %}
|
| 134 |
<!-- Folders -->
|
|
@@ -168,13 +167,6 @@
|
|
| 168 |
<a href="{{ '/view_pdf_v2/' if current_user.v2_default else '/view_pdf/' }}{{ pdf.filename }}" target="_blank" class="text-white text-decoration-none">
|
| 169 |
<span class="fw-bold pdf-filename">{{ pdf.filename }}</span>
|
| 170 |
</a>
|
| 171 |
-
<a href="intent://{{ request.host }}{{ url_for('main.download_file', filename=pdf.filename) }}#Intent;scheme={{ request.scheme }};action=android.intent.action.VIEW;type=application/pdf;end"
|
| 172 |
-
class="ms-2 text-white text-decoration-none"
|
| 173 |
-
title="Open in external app"
|
| 174 |
-
style="display: inline-block; vertical-align: middle;"
|
| 175 |
-
target="_blank">
|
| 176 |
-
<i class="bi bi-phone ms-1"></i>
|
| 177 |
-
</a>
|
| 178 |
</div>
|
| 179 |
<div class="form-check">
|
| 180 |
<input type="checkbox" class="form-check-input item-checkbox" data-item-type="pdf" value="{{ pdf.id }}" style="z-index: 2; position: relative;">
|
|
@@ -183,13 +175,6 @@
|
|
| 183 |
<div class="card-body">
|
| 184 |
<p class="card-text small text-muted">
|
| 185 |
Subject: {{ pdf.subject or 'No subject' }}
|
| 186 |
-
<a href="intent://{{ request.host }}{{ url_for('main.download_file', filename=pdf.filename) }}#Intent;scheme={{ request.scheme }};action=android.intent.action.VIEW;type=application/pdf;end"
|
| 187 |
-
class="ms-1 text-muted text-decoration-none"
|
| 188 |
-
title="Open in external app"
|
| 189 |
-
style="display: inline-block; vertical-align: middle;"
|
| 190 |
-
target="_blank">
|
| 191 |
-
<i class="bi bi-phone"></i>
|
| 192 |
-
</a>
|
| 193 |
</p>
|
| 194 |
<p class="card-text">
|
| 195 |
{% if pdf.tags %}
|
|
@@ -203,13 +188,19 @@
|
|
| 203 |
<small class="text-muted">{{ pdf.created_at.strftime('%Y-%m-%d %I:%M %p') }}</small>
|
| 204 |
<div class="d-flex align-items-center gap-2">
|
| 205 |
{% if pdf.persist %}<i class="bi bi-pin-angle-fill text-primary" title="Persisted"></i>{% endif %}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
<div class="dropdown">
|
| 207 |
<button class="btn btn-sm btn-outline-secondary dropdown-toggle no-caret" type="button" data-bs-toggle="dropdown">
|
| 208 |
<i class="bi bi-three-dots-vertical"></i>
|
| 209 |
</button>
|
| 210 |
<ul class="dropdown-menu dropdown-menu-dark dropdown-menu-end">
|
| 211 |
<li><a class="dropdown-item edit-single-btn" href="#" data-id="{{ pdf.id }}"><i class="bi bi-info-circle me-2"></i> Details</a></li>
|
| 212 |
-
<li><a class="dropdown-item" href="{{ url_for('main.download_file', filename=pdf.filename) }}"><i class="bi bi-download me-2"></i> Download</a></li>
|
| 213 |
<li><hr class="dropdown-divider"></li>
|
| 214 |
<li><a class="dropdown-item text-danger delete-item-btn" href="#" data-item-type="pdf" data-id="{{ pdf.id }}"><i class="bi bi-trash me-2"></i> Delete</a></li>
|
| 215 |
</ul>
|
|
@@ -220,8 +211,7 @@
|
|
| 220 |
</div>
|
| 221 |
{% endfor %}
|
| 222 |
</div>
|
| 223 |
-
|
| 224 |
-
|
| 225 |
<!-- Modals --><!-- Create Folder Modal -->
|
| 226 |
<div class="modal fade" id="createFolderModal" tabindex="-1">
|
| 227 |
<div class="modal-dialog">
|
|
@@ -550,7 +540,10 @@
|
|
| 550 |
|
| 551 |
input.addEventListener('blur', saveChanges);
|
| 552 |
input.addEventListener('keydown', e => {
|
| 553 |
-
if (e.key === 'Enter')
|
|
|
|
|
|
|
|
|
|
| 554 |
if (e.key === 'Escape') titleElement.textContent = currentName;
|
| 555 |
});
|
| 556 |
});
|
|
@@ -920,7 +913,7 @@
|
|
| 920 |
if (card.classList.contains('folder-card')) {
|
| 921 |
window.location.href = `/pdf_manager/browse/${card.dataset.path}`;
|
| 922 |
} else {
|
| 923 |
-
const link = card.querySelector('a.
|
| 924 |
if (link) link.click();
|
| 925 |
}
|
| 926 |
}
|
|
|
|
| 128 |
</nav>
|
| 129 |
{% endif %}
|
| 130 |
|
|
|
|
| 131 |
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-4">
|
| 132 |
{% if not all_view %}
|
| 133 |
<!-- Folders -->
|
|
|
|
| 167 |
<a href="{{ '/view_pdf_v2/' if current_user.v2_default else '/view_pdf/' }}{{ pdf.filename }}" target="_blank" class="text-white text-decoration-none">
|
| 168 |
<span class="fw-bold pdf-filename">{{ pdf.filename }}</span>
|
| 169 |
</a>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
</div>
|
| 171 |
<div class="form-check">
|
| 172 |
<input type="checkbox" class="form-check-input item-checkbox" data-item-type="pdf" value="{{ pdf.id }}" style="z-index: 2; position: relative;">
|
|
|
|
| 175 |
<div class="card-body">
|
| 176 |
<p class="card-text small text-muted">
|
| 177 |
Subject: {{ pdf.subject or 'No subject' }}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
</p>
|
| 179 |
<p class="card-text">
|
| 180 |
{% if pdf.tags %}
|
|
|
|
| 188 |
<small class="text-muted">{{ pdf.created_at.strftime('%Y-%m-%d %I:%M %p') }}</small>
|
| 189 |
<div class="d-flex align-items-center gap-2">
|
| 190 |
{% if pdf.persist %}<i class="bi bi-pin-angle-fill text-primary" title="Persisted"></i>{% endif %}
|
| 191 |
+
<a href="intent://{{ request.host }}{{ url_for('main.download_file', filename=pdf.filename) }}#Intent;scheme={{ request.scheme }};action=android.intent.action.VIEW;type=application/pdf;end"
|
| 192 |
+
class="btn btn-sm btn-outline-info" title="Open in external app" target="_blank">
|
| 193 |
+
<i class="bi bi-box-arrow-up-right"></i>
|
| 194 |
+
</a>
|
| 195 |
+
<a href="{{ url_for('main.download_file', filename=pdf.filename) }}" class="btn btn-sm btn-outline-success" title="Download">
|
| 196 |
+
<i class="bi bi-download"></i>
|
| 197 |
+
</a>
|
| 198 |
<div class="dropdown">
|
| 199 |
<button class="btn btn-sm btn-outline-secondary dropdown-toggle no-caret" type="button" data-bs-toggle="dropdown">
|
| 200 |
<i class="bi bi-three-dots-vertical"></i>
|
| 201 |
</button>
|
| 202 |
<ul class="dropdown-menu dropdown-menu-dark dropdown-menu-end">
|
| 203 |
<li><a class="dropdown-item edit-single-btn" href="#" data-id="{{ pdf.id }}"><i class="bi bi-info-circle me-2"></i> Details</a></li>
|
|
|
|
| 204 |
<li><hr class="dropdown-divider"></li>
|
| 205 |
<li><a class="dropdown-item text-danger delete-item-btn" href="#" data-item-type="pdf" data-id="{{ pdf.id }}"><i class="bi bi-trash me-2"></i> Delete</a></li>
|
| 206 |
</ul>
|
|
|
|
| 211 |
</div>
|
| 212 |
{% endfor %}
|
| 213 |
</div>
|
| 214 |
+
|
|
|
|
| 215 |
<!-- Modals --><!-- Create Folder Modal -->
|
| 216 |
<div class="modal fade" id="createFolderModal" tabindex="-1">
|
| 217 |
<div class="modal-dialog">
|
|
|
|
| 540 |
|
| 541 |
input.addEventListener('blur', saveChanges);
|
| 542 |
input.addEventListener('keydown', e => {
|
| 543 |
+
if (e.key === 'Enter') {
|
| 544 |
+
e.preventDefault();
|
| 545 |
+
saveChanges();
|
| 546 |
+
}
|
| 547 |
if (e.key === 'Escape') titleElement.textContent = currentName;
|
| 548 |
});
|
| 549 |
});
|
|
|
|
| 913 |
if (card.classList.contains('folder-card')) {
|
| 914 |
window.location.href = `/pdf_manager/browse/${card.dataset.path}`;
|
| 915 |
} else {
|
| 916 |
+
const link = card.querySelector('.card-header a.text-white');
|
| 917 |
if (link) link.click();
|
| 918 |
}
|
| 919 |
}
|
templates/question_entry_v2.html
CHANGED
|
@@ -11,6 +11,30 @@
|
|
| 11 |
.status-btn:hover { background: #495057; }
|
| 12 |
.auto-extract-btn { min-width: 120px; }
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
/* Tom Select Dark Theme */
|
| 15 |
.ts-wrapper .ts-control, .ts-wrapper .ts-control input {
|
| 16 |
background: #212529;
|
|
@@ -276,72 +300,163 @@
|
|
| 276 |
|
| 277 |
<!-- Modal 1: Range & Subject -->
|
| 278 |
<div class="modal fade" id="manualClassificationModal" tabindex="-1">
|
| 279 |
-
<div class="modal-dialog">
|
| 280 |
<div class="modal-content bg-dark text-white">
|
| 281 |
-
<div class="modal-header">
|
| 282 |
-
<h5 class="modal-title">
|
| 283 |
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
| 284 |
</div>
|
| 285 |
<div class="modal-body">
|
| 286 |
-
<div class="mb-3">
|
| 287 |
-
<
|
| 288 |
-
|
| 289 |
-
<option value="Biology">Biology</option>
|
| 290 |
-
<option value="Chemistry">Chemistry</option>
|
| 291 |
-
<option value="Physics">Physics</option>
|
| 292 |
-
<option value="Mathematics">Mathematics</option>
|
| 293 |
-
</select>
|
| 294 |
</div>
|
|
|
|
|
|
|
| 295 |
<div class="mb-3">
|
| 296 |
-
<label class="form-label">
|
| 297 |
-
<
|
| 298 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
</div>
|
| 300 |
</div>
|
| 301 |
-
<div class="modal-footer">
|
| 302 |
-
<button type="button" class="btn btn-primary" onclick="startManualClassification()">
|
|
|
|
|
|
|
| 303 |
</div>
|
| 304 |
</div>
|
| 305 |
</div>
|
| 306 |
</div>
|
| 307 |
|
| 308 |
<!-- Modal 2: Topic Wizard -->
|
| 309 |
-
<div class="modal fade" id="topicSelectionModal" tabindex="-1" data-bs-backdrop="static">
|
| 310 |
-
<div class="modal-dialog modal-lg">
|
| 311 |
-
<div class="modal-content bg-dark text-white">
|
| 312 |
-
<div class="modal-header">
|
| 313 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
| 315 |
</div>
|
| 316 |
<div class="modal-body">
|
|
|
|
| 317 |
<div class="text-center mb-3">
|
| 318 |
-
<img id="topic_q_img" src="" class="img-fluid rounded" style="max-height:
|
| 319 |
</div>
|
|
|
|
|
|
|
| 320 |
<div class="mb-3">
|
| 321 |
-
<label class="form-label
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 322 |
</div>
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
|
|
|
|
|
|
| 328 |
<span class="spinner-border spinner-border-sm d-none" role="status"></span>
|
| 329 |
-
|
| 330 |
</button>
|
| 331 |
</div>
|
| 332 |
</div>
|
| 333 |
-
|
| 334 |
-
|
|
|
|
| 335 |
<div id="ai_suggestion_chips" class="d-flex flex-wrap gap-2"></div>
|
| 336 |
</div>
|
| 337 |
-
<div id="ai_error_msg" class="alert alert-danger d-none mt-2"></div>
|
| 338 |
</div>
|
| 339 |
-
<div class="modal-footer
|
| 340 |
-
<button type="button" class="btn btn-secondary" onclick="prevTopicQuestion()">
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
|
|
|
| 345 |
</div>
|
| 346 |
</div>
|
| 347 |
</div>
|
|
@@ -981,8 +1096,257 @@
|
|
| 981 |
// --- Manual Classification Logic ---
|
| 982 |
let manualQuestionsList = [];
|
| 983 |
let currentManualIndex = 0;
|
| 984 |
-
let manualSubject = "";
|
| 985 |
-
let suggestionCache = new Map();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 986 |
|
| 987 |
function parseRange(rangeStr, maxVal) {
|
| 988 |
const indices = new Set();
|
|
@@ -1004,61 +1368,219 @@
|
|
| 1004 |
}
|
| 1005 |
|
| 1006 |
function startManualClassification() {
|
| 1007 |
-
|
| 1008 |
-
|
| 1009 |
-
|
| 1010 |
-
if (
|
| 1011 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1012 |
} else {
|
| 1013 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1014 |
}
|
| 1015 |
|
| 1016 |
if (manualQuestionsList.length === 0) {
|
| 1017 |
-
alert("No
|
| 1018 |
return;
|
| 1019 |
}
|
| 1020 |
|
| 1021 |
currentManualIndex = 0;
|
| 1022 |
-
suggestionCache.clear();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1023 |
|
| 1024 |
const modal1 = bootstrap.Modal.getInstance(document.getElementById('manualClassificationModal'));
|
| 1025 |
modal1.hide();
|
| 1026 |
-
|
| 1027 |
const modal2 = new bootstrap.Modal(document.getElementById('topicSelectionModal'));
|
| 1028 |
modal2.show();
|
| 1029 |
-
|
| 1030 |
loadTopicQuestion();
|
| 1031 |
-
|
| 1032 |
-
//
|
| 1033 |
-
prefetchSuggestions(0,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1034 |
}
|
| 1035 |
|
| 1036 |
async function prefetchSuggestions(startIndex, count) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1037 |
for (let i = startIndex; i < startIndex + count && i < manualQuestionsList.length; i++) {
|
| 1038 |
const globalIndex = manualQuestionsList[i];
|
| 1039 |
const fieldset = document.querySelector(`fieldset[data-question-index="${globalIndex}"]`);
|
| 1040 |
const imageId = fieldset.querySelector('input[name^="image_id_"]').value;
|
| 1041 |
-
|
| 1042 |
-
if (
|
| 1043 |
-
|
| 1044 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1045 |
method: 'POST',
|
| 1046 |
headers: { 'Content-Type': 'application/json' },
|
| 1047 |
-
body: JSON.stringify({
|
| 1048 |
})
|
| 1049 |
.then(res => res.json())
|
| 1050 |
.then(data => {
|
| 1051 |
-
if (data.success) {
|
| 1052 |
-
return
|
| 1053 |
-
} else {
|
| 1054 |
-
throw new Error(data.error);
|
| 1055 |
}
|
|
|
|
| 1056 |
})
|
| 1057 |
-
.catch(err =>
|
| 1058 |
-
|
| 1059 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1060 |
}
|
| 1061 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1062 |
}
|
| 1063 |
|
| 1064 |
function loadTopicQuestion() {
|
|
@@ -1068,9 +1590,19 @@
|
|
| 1068 |
const qNum = fieldset.querySelector('input[name^="question_number_"]').value;
|
| 1069 |
const img = fieldset.querySelector('img');
|
| 1070 |
const imgUrl = img ? img.src : '';
|
| 1071 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1072 |
// UI Updates
|
| 1073 |
-
document.getElementById('topic_q_num').innerText = qNum;
|
| 1074 |
const modalImg = document.getElementById('topic_q_img');
|
| 1075 |
if (imgUrl) {
|
| 1076 |
modalImg.src = imgUrl;
|
|
@@ -1079,50 +1611,38 @@
|
|
| 1079 |
modalImg.style.display = 'none';
|
| 1080 |
}
|
| 1081 |
|
| 1082 |
-
|
| 1083 |
-
|
|
|
|
|
|
|
|
|
|
| 1084 |
const chapterSpan = document.getElementById(`chapter_${imageId}`);
|
| 1085 |
const currentChapter = chapterSpan ? chapterSpan.innerText : '';
|
| 1086 |
document.getElementById('topic_input').value = (currentChapter && currentChapter !== 'N/A' && currentChapter !== 'Unclassified') ? currentChapter : '';
|
| 1087 |
-
|
| 1088 |
-
document.getElementById('topic_progress').innerText = `${currentManualIndex + 1} / ${manualQuestionsList.length}`;
|
| 1089 |
-
|
| 1090 |
// Clear previous suggestions
|
| 1091 |
document.getElementById('ai_suggestion_container').classList.add('d-none');
|
| 1092 |
document.getElementById('ai_suggestion_chips').innerHTML = '';
|
| 1093 |
document.getElementById('ai_error_msg').classList.add('d-none');
|
| 1094 |
-
|
| 1095 |
// Reset button
|
| 1096 |
const btn = document.getElementById('btn_get_suggestion');
|
| 1097 |
btn.disabled = false;
|
| 1098 |
-
btn.innerHTML = `<span class="spinner-border spinner-border-sm d-none" role="status"></span>
|
| 1099 |
-
|
| 1100 |
-
//
|
| 1101 |
-
|
| 1102 |
-
|
| 1103 |
-
if (suggestionCache.has(imageId)) {
|
| 1104 |
-
// If we have it (or it's loading), we can try to show it automatically or just wait for click.
|
| 1105 |
-
// Given "auto get", let's simulate a click if the user hasn't entered a topic yet.
|
| 1106 |
-
if (!document.getElementById('topic_input').value) {
|
| 1107 |
-
getAiSuggestion();
|
| 1108 |
-
}
|
| 1109 |
-
} else {
|
| 1110 |
-
// Not in cache, start fetching now (prefetch logic handles this, but ensuring current is fetched)
|
| 1111 |
-
prefetchSuggestions(currentManualIndex, 1);
|
| 1112 |
-
if (!document.getElementById('topic_input').value) {
|
| 1113 |
-
getAiSuggestion();
|
| 1114 |
-
}
|
| 1115 |
}
|
| 1116 |
}
|
| 1117 |
|
| 1118 |
async function nextTopicQuestion() {
|
| 1119 |
-
|
| 1120 |
-
|
|
|
|
| 1121 |
if (currentManualIndex < manualQuestionsList.length - 1) {
|
| 1122 |
currentManualIndex++;
|
| 1123 |
loadTopicQuestion();
|
| 1124 |
-
// Prefetch next few
|
| 1125 |
-
prefetchSuggestions(currentManualIndex + 1, 2);
|
| 1126 |
} else {
|
| 1127 |
const modal2 = bootstrap.Modal.getInstance(document.getElementById('topicSelectionModal'));
|
| 1128 |
modal2.hide();
|
|
@@ -1137,31 +1657,29 @@
|
|
| 1137 |
}
|
| 1138 |
}
|
| 1139 |
|
| 1140 |
-
|
| 1141 |
const globalIndex = manualQuestionsList[currentManualIndex];
|
| 1142 |
const fieldset = document.querySelector(`fieldset[data-question-index="${globalIndex}"]`);
|
| 1143 |
const imageId = fieldset.querySelector('input[name^="image_id_"]').value;
|
| 1144 |
const topic = document.getElementById('topic_input').value;
|
| 1145 |
|
|
|
|
| 1146 |
const subjectSpan = document.getElementById(`subject_${imageId}`);
|
| 1147 |
if(subjectSpan) subjectSpan.innerText = manualSubject;
|
| 1148 |
-
|
| 1149 |
const chapterSpan = document.getElementById(`chapter_${imageId}`);
|
| 1150 |
if(chapterSpan) chapterSpan.innerText = topic || 'Unclassified';
|
| 1151 |
|
| 1152 |
-
|
| 1153 |
-
|
| 1154 |
-
|
| 1155 |
-
|
| 1156 |
-
|
| 1157 |
-
|
| 1158 |
-
|
| 1159 |
-
|
| 1160 |
-
|
| 1161 |
-
|
| 1162 |
-
} catch (e) {
|
| 1163 |
-
console.error("Failed to save topic", e);
|
| 1164 |
-
}
|
| 1165 |
}
|
| 1166 |
|
| 1167 |
async function getAiSuggestion() {
|
|
@@ -1172,7 +1690,7 @@
|
|
| 1172 |
const container = document.getElementById('ai_suggestion_container');
|
| 1173 |
const chipsContainer = document.getElementById('ai_suggestion_chips');
|
| 1174 |
const errorDiv = document.getElementById('ai_error_msg');
|
| 1175 |
-
|
| 1176 |
btn.disabled = true;
|
| 1177 |
btn.querySelector('.spinner-border').classList.remove('d-none');
|
| 1178 |
errorDiv.classList.add('d-none');
|
|
@@ -1182,27 +1700,51 @@
|
|
| 1182 |
if (suggestionCache.has(imageId)) {
|
| 1183 |
result = await suggestionCache.get(imageId);
|
| 1184 |
} else {
|
| 1185 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1186 |
const promise = fetch('/get_topic_suggestions', {
|
| 1187 |
method: 'POST',
|
| 1188 |
headers: { 'Content-Type': 'application/json' },
|
| 1189 |
-
body: JSON.stringify(
|
| 1190 |
})
|
| 1191 |
.then(res => res.json())
|
| 1192 |
.then(data => {
|
| 1193 |
-
if (data.success) return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1194 |
else throw new Error(data.error);
|
| 1195 |
})
|
| 1196 |
.catch(err => ({ status: 'error', error: err.message }));
|
| 1197 |
-
|
| 1198 |
suggestionCache.set(imageId, promise);
|
| 1199 |
result = await promise;
|
| 1200 |
}
|
| 1201 |
|
| 1202 |
if (result.status === 'done') {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1203 |
container.classList.remove('d-none');
|
| 1204 |
chipsContainer.innerHTML = '';
|
| 1205 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1206 |
result.suggestions.forEach(suggestion => {
|
| 1207 |
const chip = document.createElement('button');
|
| 1208 |
chip.className = 'btn btn-outline-info btn-sm rounded-pill';
|
|
@@ -1212,7 +1754,7 @@
|
|
| 1212 |
};
|
| 1213 |
chipsContainer.appendChild(chip);
|
| 1214 |
});
|
| 1215 |
-
|
| 1216 |
// Auto-fill input if empty and we have a primary suggestion
|
| 1217 |
const currentVal = document.getElementById('topic_input').value;
|
| 1218 |
if (!currentVal && result.suggestions.length > 0) {
|
|
@@ -1235,6 +1777,7 @@
|
|
| 1235 |
initializeTomSelect(); // Initialize Tom Select here
|
| 1236 |
loadSettings();
|
| 1237 |
setupEventListeners();
|
|
|
|
| 1238 |
});
|
| 1239 |
</script>
|
| 1240 |
{% endblock %}
|
|
|
|
| 11 |
.status-btn:hover { background: #495057; }
|
| 12 |
.auto-extract-btn { min-width: 120px; }
|
| 13 |
|
| 14 |
+
/* Subject pills */
|
| 15 |
+
.subject-pill { transition: all 0.2s; }
|
| 16 |
+
.subject-pill.active { transform: scale(1.05); box-shadow: 0 0 10px rgba(255,255,255,0.3); }
|
| 17 |
+
|
| 18 |
+
/* Range toggles */
|
| 19 |
+
.range-toggle { transition: all 0.2s; }
|
| 20 |
+
.range-toggle.active { transform: scale(1.02); box-shadow: 0 0 8px rgba(255,255,255,0.2); }
|
| 21 |
+
.range-toggle.active.btn-outline-primary { background: #0d6efd; color: #fff; }
|
| 22 |
+
.range-toggle.active.btn-outline-warning { background: #ffc107; color: #000; }
|
| 23 |
+
.range-toggle.active.btn-outline-danger { background: #dc3545; color: #fff; }
|
| 24 |
+
.range-toggle.active.btn-outline-secondary { background: #6c757d; color: #fff; }
|
| 25 |
+
.range-toggle.active.btn-outline-info { background: #0dcaf0; color: #000; }
|
| 26 |
+
|
| 27 |
+
/* Range slider styling */
|
| 28 |
+
.range-slider-row { background: #343a40; border-radius: 8px; padding: 12px; margin-bottom: 10px; }
|
| 29 |
+
.range-slider-row:hover { background: #3d444b; }
|
| 30 |
+
.dual-range-container { position: relative; height: 40px; }
|
| 31 |
+
.dual-range-track { position: absolute; top: 50%; left: 0; right: 0; height: 8px; background: #495057; border-radius: 4px; transform: translateY(-50%); }
|
| 32 |
+
.dual-range-highlight { position: absolute; top: 50%; height: 8px; background: linear-gradient(90deg, #0d6efd, #0dcaf0); border-radius: 4px; transform: translateY(-50%); }
|
| 33 |
+
.dual-range-container input[type="range"] { position: absolute; top: 0; left: 0; width: 100%; height: 100%; -webkit-appearance: none; background: transparent; pointer-events: none; }
|
| 34 |
+
.dual-range-container input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 24px; height: 24px; background: #fff; border: 3px solid #0d6efd; border-radius: 50%; cursor: pointer; pointer-events: auto; box-shadow: 0 2px 6px rgba(0,0,0,0.3); transition: transform 0.15s; }
|
| 35 |
+
.dual-range-container input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.15); }
|
| 36 |
+
.dual-range-container input[type="range"]::-moz-range-thumb { width: 24px; height: 24px; background: #fff; border: 3px solid #0d6efd; border-radius: 50%; cursor: pointer; pointer-events: auto; box-shadow: 0 2px 6px rgba(0,0,0,0.3); }
|
| 37 |
+
|
| 38 |
/* Tom Select Dark Theme */
|
| 39 |
.ts-wrapper .ts-control, .ts-wrapper .ts-control input {
|
| 40 |
background: #212529;
|
|
|
|
| 300 |
|
| 301 |
<!-- Modal 1: Range & Subject -->
|
| 302 |
<div class="modal fade" id="manualClassificationModal" tabindex="-1">
|
| 303 |
+
<div class="modal-dialog modal-lg">
|
| 304 |
<div class="modal-content bg-dark text-white">
|
| 305 |
+
<div class="modal-header border-secondary">
|
| 306 |
+
<h5 class="modal-title"><i class="bi bi-list-check me-2"></i>Quick Classification</h5>
|
| 307 |
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
| 308 |
</div>
|
| 309 |
<div class="modal-body">
|
| 310 |
+
<div class="alert alert-info border-0 mb-3">
|
| 311 |
+
<i class="bi bi-lightning-fill me-2"></i>
|
| 312 |
+
Classify questions with AI-powered suggestions. Subject will be auto-detected.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
</div>
|
| 314 |
+
|
| 315 |
+
<!-- Quick Range Toggles -->
|
| 316 |
<div class="mb-3">
|
| 317 |
+
<label class="form-label">Quick Select</label>
|
| 318 |
+
<div class="d-flex flex-wrap gap-2">
|
| 319 |
+
<button type="button" class="btn btn-outline-primary range-toggle active" data-range="all">
|
| 320 |
+
<i class="bi bi-collection me-1"></i>All
|
| 321 |
+
</button>
|
| 322 |
+
<button type="button" class="btn btn-outline-warning range-toggle" data-range="unclassified">
|
| 323 |
+
<i class="bi bi-question-circle me-1"></i>Unclassified
|
| 324 |
+
</button>
|
| 325 |
+
<button type="button" class="btn btn-outline-danger range-toggle" data-range="wrong">
|
| 326 |
+
<i class="bi bi-x-circle me-1"></i>Wrong Only
|
| 327 |
+
</button>
|
| 328 |
+
<button type="button" class="btn btn-outline-secondary range-toggle" data-range="unattempted">
|
| 329 |
+
<i class="bi bi-dash-circle me-1"></i>Unattempted
|
| 330 |
+
</button>
|
| 331 |
+
<button type="button" class="btn btn-outline-info range-toggle" data-range="custom">
|
| 332 |
+
<i class="bi bi-sliders me-1"></i>Custom Range
|
| 333 |
+
</button>
|
| 334 |
+
</div>
|
| 335 |
+
</div>
|
| 336 |
+
|
| 337 |
+
<!-- Custom Range Slider UI -->
|
| 338 |
+
<div id="custom-range-container" class="d-none">
|
| 339 |
+
<div class="card bg-secondary mb-3">
|
| 340 |
+
<div class="card-body">
|
| 341 |
+
<div class="d-flex justify-content-between align-items-center mb-2">
|
| 342 |
+
<label class="form-label mb-0">Select Question Ranges</label>
|
| 343 |
+
<button type="button" class="btn btn-sm btn-success" onclick="addRangeSlider()">
|
| 344 |
+
<i class="bi bi-plus-lg me-1"></i>Add Range
|
| 345 |
+
</button>
|
| 346 |
+
</div>
|
| 347 |
+
|
| 348 |
+
<div id="range-sliders-container">
|
| 349 |
+
<!-- Range sliders will be added here -->
|
| 350 |
+
</div>
|
| 351 |
+
|
| 352 |
+
<div class="mt-3 p-2 bg-dark rounded">
|
| 353 |
+
<small class="text-muted">Selected: </small>
|
| 354 |
+
<span id="selected-ranges-display" class="text-info fw-bold">None</span>
|
| 355 |
+
<small class="text-muted ms-2">(<span id="selected-count">0</span> questions)</small>
|
| 356 |
+
</div>
|
| 357 |
+
</div>
|
| 358 |
+
</div>
|
| 359 |
+
|
| 360 |
+
<!-- Preview Section -->
|
| 361 |
+
<div class="row" id="range-preview-section">
|
| 362 |
+
<div class="col-6">
|
| 363 |
+
<div class="card bg-secondary">
|
| 364 |
+
<div class="card-header py-1 text-center">
|
| 365 |
+
<small class="text-success"><i class="bi bi-arrow-right-circle me-1"></i>Start</small>
|
| 366 |
+
</div>
|
| 367 |
+
<div class="card-body p-2 text-center">
|
| 368 |
+
<img id="preview-start-img" src="" class="img-fluid rounded" style="max-height: 120px; object-fit: contain;">
|
| 369 |
+
<div class="mt-1"><span class="badge bg-primary" id="preview-start-num">#1</span></div>
|
| 370 |
+
</div>
|
| 371 |
+
</div>
|
| 372 |
+
</div>
|
| 373 |
+
<div class="col-6">
|
| 374 |
+
<div class="card bg-secondary">
|
| 375 |
+
<div class="card-header py-1 text-center">
|
| 376 |
+
<small class="text-danger"><i class="bi bi-arrow-left-circle me-1"></i>End</small>
|
| 377 |
+
</div>
|
| 378 |
+
<div class="card-body p-2 text-center">
|
| 379 |
+
<img id="preview-end-img" src="" class="img-fluid rounded" style="max-height: 120px; object-fit: contain;">
|
| 380 |
+
<div class="mt-1"><span class="badge bg-primary" id="preview-end-num">#1</span></div>
|
| 381 |
+
</div>
|
| 382 |
+
</div>
|
| 383 |
+
</div>
|
| 384 |
+
</div>
|
| 385 |
</div>
|
| 386 |
</div>
|
| 387 |
+
<div class="modal-footer border-secondary">
|
| 388 |
+
<button type="button" class="btn btn-lg btn-primary w-100" onclick="startManualClassification()">
|
| 389 |
+
<i class="bi bi-play-fill me-2"></i>Start Classification
|
| 390 |
+
</button>
|
| 391 |
</div>
|
| 392 |
</div>
|
| 393 |
</div>
|
| 394 |
</div>
|
| 395 |
|
| 396 |
<!-- Modal 2: Topic Wizard -->
|
| 397 |
+
<div class="modal fade" id="topicSelectionModal" tabindex="-1" data-bs-backdrop="static" data-bs-keyboard="false">
|
| 398 |
+
<div class="modal-dialog modal-lg modal-dialog-centered">
|
| 399 |
+
<div class="modal-content bg-dark text-white border-secondary">
|
| 400 |
+
<div class="modal-header border-secondary py-2">
|
| 401 |
+
<div class="d-flex align-items-center gap-3">
|
| 402 |
+
<span class="badge bg-primary fs-6" id="topic_q_num">#1</span>
|
| 403 |
+
<div class="progress flex-grow-1" style="width: 150px; height: 6px;">
|
| 404 |
+
<div class="progress-bar" id="topic_progress_bar" role="progressbar" style="width: 0%"></div>
|
| 405 |
+
</div>
|
| 406 |
+
<small id="topic_progress" class="text-muted">1/10</small>
|
| 407 |
+
</div>
|
| 408 |
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
| 409 |
</div>
|
| 410 |
<div class="modal-body">
|
| 411 |
+
<!-- Question Image -->
|
| 412 |
<div class="text-center mb-3">
|
| 413 |
+
<img id="topic_q_img" src="" class="img-fluid rounded border border-secondary" style="max-height: 180px; object-fit: contain;">
|
| 414 |
</div>
|
| 415 |
+
|
| 416 |
+
<!-- Subject Selection (Auto-detected, editable) -->
|
| 417 |
<div class="mb-3">
|
| 418 |
+
<label class="form-label small text-muted mb-2">Subject (click to change)</label>
|
| 419 |
+
<div id="subject_pills" class="d-flex flex-wrap gap-2">
|
| 420 |
+
<button type="button" class="btn btn-outline-success subject-pill" data-subject="Biology">
|
| 421 |
+
<i class="bi bi-flower1 me-1"></i>Biology
|
| 422 |
+
</button>
|
| 423 |
+
<button type="button" class="btn btn-outline-warning subject-pill" data-subject="Chemistry">
|
| 424 |
+
<i class="bi bi-droplet me-1"></i>Chemistry
|
| 425 |
+
</button>
|
| 426 |
+
<button type="button" class="btn btn-outline-info subject-pill" data-subject="Physics">
|
| 427 |
+
<i class="bi bi-lightning me-1"></i>Physics
|
| 428 |
+
</button>
|
| 429 |
+
<button type="button" class="btn btn-outline-danger subject-pill" data-subject="Mathematics">
|
| 430 |
+
<i class="bi bi-calculator me-1"></i>Mathematics
|
| 431 |
+
</button>
|
| 432 |
+
</div>
|
| 433 |
</div>
|
| 434 |
+
|
| 435 |
+
<!-- Chapter/Topic Input -->
|
| 436 |
+
<div class="mb-2">
|
| 437 |
+
<label class="form-label small text-muted mb-2">Chapter / Topic</label>
|
| 438 |
+
<div class="input-group input-group-lg">
|
| 439 |
+
<input type="text" id="topic_input" class="form-control bg-dark text-white border-secondary" placeholder="Start typing or use AI...">
|
| 440 |
+
<button class="btn btn-info" type="button" id="btn_get_suggestion" onclick="getAiSuggestion()">
|
| 441 |
<span class="spinner-border spinner-border-sm d-none" role="status"></span>
|
| 442 |
+
<i class="bi bi-stars"></i>
|
| 443 |
</button>
|
| 444 |
</div>
|
| 445 |
</div>
|
| 446 |
+
|
| 447 |
+
<!-- Suggestions -->
|
| 448 |
+
<div id="ai_suggestion_container" class="d-none">
|
| 449 |
<div id="ai_suggestion_chips" class="d-flex flex-wrap gap-2"></div>
|
| 450 |
</div>
|
| 451 |
+
<div id="ai_error_msg" class="alert alert-danger d-none mt-2 py-2"></div>
|
| 452 |
</div>
|
| 453 |
+
<div class="modal-footer border-secondary py-2">
|
| 454 |
+
<button type="button" class="btn btn-outline-secondary btn-lg px-4" onclick="prevTopicQuestion()">
|
| 455 |
+
<i class="bi bi-chevron-left"></i>
|
| 456 |
+
</button>
|
| 457 |
+
<button type="button" class="btn btn-primary btn-lg px-5" onclick="nextTopicQuestion()">
|
| 458 |
+
Next <i class="bi bi-chevron-right ms-1"></i>
|
| 459 |
+
</button>
|
| 460 |
</div>
|
| 461 |
</div>
|
| 462 |
</div>
|
|
|
|
| 1096 |
// --- Manual Classification Logic ---
|
| 1097 |
let manualQuestionsList = [];
|
| 1098 |
let currentManualIndex = 0;
|
| 1099 |
+
let manualSubject = "Biology"; // Default, will be auto-detected
|
| 1100 |
+
let suggestionCache = new Map();
|
| 1101 |
+
let selectedRangeMode = 'all'; // 'all', 'unclassified', 'wrong', 'unattempted', 'custom'
|
| 1102 |
+
let customRanges = []; // Array of {start, end, subject} for custom slider ranges
|
| 1103 |
+
let questionSubjectMap = new Map(); // Maps question index to pre-selected subject
|
| 1104 |
+
|
| 1105 |
+
// Get question image URL by index
|
| 1106 |
+
function getQuestionImageUrl(index) {
|
| 1107 |
+
const fieldset = document.querySelector(`fieldset[data-question-index="${index}"]`);
|
| 1108 |
+
if (!fieldset) return '';
|
| 1109 |
+
const img = fieldset.querySelector('img');
|
| 1110 |
+
return img ? img.src : '';
|
| 1111 |
+
}
|
| 1112 |
+
|
| 1113 |
+
// Get question number by index
|
| 1114 |
+
function getQuestionNumber(index) {
|
| 1115 |
+
const fieldset = document.querySelector(`fieldset[data-question-index="${index}"]`);
|
| 1116 |
+
if (!fieldset) return index + 1;
|
| 1117 |
+
const input = fieldset.querySelector('input[name^="question_number_"]');
|
| 1118 |
+
return input && input.value ? input.value : (index + 1);
|
| 1119 |
+
}
|
| 1120 |
+
|
| 1121 |
+
// Add a new range slider
|
| 1122 |
+
function addRangeSlider() {
|
| 1123 |
+
const container = document.getElementById('range-sliders-container');
|
| 1124 |
+
const sliderId = Date.now();
|
| 1125 |
+
const maxVal = numImages;
|
| 1126 |
+
|
| 1127 |
+
const sliderHtml = `
|
| 1128 |
+
<div class="range-slider-row" id="slider-row-${sliderId}" data-slider-id="${sliderId}">
|
| 1129 |
+
<div class="d-flex justify-content-between align-items-center mb-2">
|
| 1130 |
+
<div class="d-flex align-items-center gap-2">
|
| 1131 |
+
<span class="badge bg-success" id="start-badge-${sliderId}">Q1</span>
|
| 1132 |
+
<i class="bi bi-arrow-right text-muted"></i>
|
| 1133 |
+
<span class="badge bg-danger" id="end-badge-${sliderId}">Q${maxVal}</span>
|
| 1134 |
+
</div>
|
| 1135 |
+
<div class="d-flex align-items-center gap-2">
|
| 1136 |
+
<select class="form-select form-select-sm bg-dark text-white border-secondary" id="subject-${sliderId}" style="width: auto;">
|
| 1137 |
+
<option value="auto">🤖 Auto-detect</option>
|
| 1138 |
+
<option value="Biology">🌱 Biology</option>
|
| 1139 |
+
<option value="Chemistry">🧪 Chemistry</option>
|
| 1140 |
+
<option value="Physics">⚡ Physics</option>
|
| 1141 |
+
<option value="Mathematics">📐 Mathematics</option>
|
| 1142 |
+
</select>
|
| 1143 |
+
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeRangeSlider(${sliderId})">
|
| 1144 |
+
<i class="bi bi-trash"></i>
|
| 1145 |
+
</button>
|
| 1146 |
+
</div>
|
| 1147 |
+
</div>
|
| 1148 |
+
<div class="dual-range-container">
|
| 1149 |
+
<div class="dual-range-track"></div>
|
| 1150 |
+
<div class="dual-range-highlight" id="highlight-${sliderId}"></div>
|
| 1151 |
+
<input type="range" min="1" max="${maxVal}" value="1" class="range-start" id="start-${sliderId}"
|
| 1152 |
+
oninput="updateRangeSlider(${sliderId})">
|
| 1153 |
+
<input type="range" min="1" max="${maxVal}" value="${maxVal}" class="range-end" id="end-${sliderId}"
|
| 1154 |
+
oninput="updateRangeSlider(${sliderId})">
|
| 1155 |
+
</div>
|
| 1156 |
+
</div>
|
| 1157 |
+
`;
|
| 1158 |
+
|
| 1159 |
+
container.insertAdjacentHTML('beforeend', sliderHtml);
|
| 1160 |
+
customRanges.push({ id: sliderId, start: 1, end: maxVal, subject: 'auto' });
|
| 1161 |
+
|
| 1162 |
+
// Add subject change listener
|
| 1163 |
+
document.getElementById(`subject-${sliderId}`).addEventListener('change', (e) => {
|
| 1164 |
+
const rangeObj = customRanges.find(r => r.id === sliderId);
|
| 1165 |
+
if (rangeObj) rangeObj.subject = e.target.value;
|
| 1166 |
+
});
|
| 1167 |
+
|
| 1168 |
+
updateRangeSlider(sliderId);
|
| 1169 |
+
updateSelectedRangesDisplay();
|
| 1170 |
+
updateRangePreview();
|
| 1171 |
+
}
|
| 1172 |
+
|
| 1173 |
+
// Update range slider visuals and values
|
| 1174 |
+
function updateRangeSlider(sliderId) {
|
| 1175 |
+
const startInput = document.getElementById(`start-${sliderId}`);
|
| 1176 |
+
const endInput = document.getElementById(`end-${sliderId}`);
|
| 1177 |
+
const highlight = document.getElementById(`highlight-${sliderId}`);
|
| 1178 |
+
const startBadge = document.getElementById(`start-badge-${sliderId}`);
|
| 1179 |
+
const endBadge = document.getElementById(`end-badge-${sliderId}`);
|
| 1180 |
+
|
| 1181 |
+
let startVal = parseInt(startInput.value);
|
| 1182 |
+
let endVal = parseInt(endInput.value);
|
| 1183 |
+
|
| 1184 |
+
// Ensure start <= end
|
| 1185 |
+
if (startVal > endVal) {
|
| 1186 |
+
if (startInput === document.activeElement) {
|
| 1187 |
+
endInput.value = startVal;
|
| 1188 |
+
endVal = startVal;
|
| 1189 |
+
} else {
|
| 1190 |
+
startInput.value = endVal;
|
| 1191 |
+
startVal = endVal;
|
| 1192 |
+
}
|
| 1193 |
+
}
|
| 1194 |
+
|
| 1195 |
+
// Update highlight bar
|
| 1196 |
+
const max = parseInt(startInput.max);
|
| 1197 |
+
const leftPercent = ((startVal - 1) / (max - 1)) * 100;
|
| 1198 |
+
const rightPercent = ((endVal - 1) / (max - 1)) * 100;
|
| 1199 |
+
highlight.style.left = leftPercent + '%';
|
| 1200 |
+
highlight.style.width = (rightPercent - leftPercent) + '%';
|
| 1201 |
+
|
| 1202 |
+
// Update badges
|
| 1203 |
+
startBadge.textContent = `Q${getQuestionNumber(startVal - 1)}`;
|
| 1204 |
+
endBadge.textContent = `Q${getQuestionNumber(endVal - 1)}`;
|
| 1205 |
+
|
| 1206 |
+
// Update stored range
|
| 1207 |
+
const rangeObj = customRanges.find(r => r.id === sliderId);
|
| 1208 |
+
if (rangeObj) {
|
| 1209 |
+
rangeObj.start = startVal;
|
| 1210 |
+
rangeObj.end = endVal;
|
| 1211 |
+
}
|
| 1212 |
+
|
| 1213 |
+
updateSelectedRangesDisplay();
|
| 1214 |
+
updateRangePreview();
|
| 1215 |
+
}
|
| 1216 |
+
|
| 1217 |
+
// Remove a range slider
|
| 1218 |
+
function removeRangeSlider(sliderId) {
|
| 1219 |
+
document.getElementById(`slider-row-${sliderId}`)?.remove();
|
| 1220 |
+
customRanges = customRanges.filter(r => r.id !== sliderId);
|
| 1221 |
+
updateSelectedRangesDisplay();
|
| 1222 |
+
updateRangePreview();
|
| 1223 |
+
}
|
| 1224 |
+
|
| 1225 |
+
// Update the display of selected ranges
|
| 1226 |
+
function updateSelectedRangesDisplay() {
|
| 1227 |
+
const display = document.getElementById('selected-ranges-display');
|
| 1228 |
+
const countSpan = document.getElementById('selected-count');
|
| 1229 |
+
|
| 1230 |
+
if (customRanges.length === 0) {
|
| 1231 |
+
display.textContent = 'None';
|
| 1232 |
+
countSpan.textContent = '0';
|
| 1233 |
+
return;
|
| 1234 |
+
}
|
| 1235 |
+
|
| 1236 |
+
const rangeStrs = customRanges.map(r => r.start === r.end ? `${r.start}` : `${r.start}-${r.end}`);
|
| 1237 |
+
display.textContent = rangeStrs.join(', ');
|
| 1238 |
+
|
| 1239 |
+
// Count unique questions
|
| 1240 |
+
const indices = new Set();
|
| 1241 |
+
customRanges.forEach(r => {
|
| 1242 |
+
for (let i = r.start; i <= r.end; i++) indices.add(i);
|
| 1243 |
+
});
|
| 1244 |
+
countSpan.textContent = indices.size;
|
| 1245 |
+
}
|
| 1246 |
+
|
| 1247 |
+
// Update preview images
|
| 1248 |
+
function updateRangePreview() {
|
| 1249 |
+
if (customRanges.length === 0) return;
|
| 1250 |
+
|
| 1251 |
+
// Find first and last question across all ranges
|
| 1252 |
+
let minQ = Infinity, maxQ = -Infinity;
|
| 1253 |
+
customRanges.forEach(r => {
|
| 1254 |
+
if (r.start < minQ) minQ = r.start;
|
| 1255 |
+
if (r.end > maxQ) maxQ = r.end;
|
| 1256 |
+
});
|
| 1257 |
+
|
| 1258 |
+
const startIdx = minQ - 1;
|
| 1259 |
+
const endIdx = maxQ - 1;
|
| 1260 |
+
|
| 1261 |
+
document.getElementById('preview-start-img').src = getQuestionImageUrl(startIdx);
|
| 1262 |
+
document.getElementById('preview-end-img').src = getQuestionImageUrl(endIdx);
|
| 1263 |
+
document.getElementById('preview-start-num').textContent = `#${getQuestionNumber(startIdx)}`;
|
| 1264 |
+
document.getElementById('preview-end-num').textContent = `#${getQuestionNumber(endIdx)}`;
|
| 1265 |
+
}
|
| 1266 |
+
|
| 1267 |
+
// Subject detection keywords
|
| 1268 |
+
const SUBJECT_KEYWORDS = {
|
| 1269 |
+
'Biology': ['cell', 'dna', 'rna', 'protein', 'enzyme', 'mitosis', 'meiosis', 'chromosome', 'gene', 'photosynthesis', 'respiration', 'nucleus', 'cytoplasm', 'membrane', 'tissue', 'organ', 'species', 'evolution', 'ecology', 'bacteria', 'virus', 'plant', 'animal', 'blood', 'heart', 'kidney', 'liver', 'neuron', 'hormone', 'digestion', 'reproduction', 'inheritance', 'mutation', 'allele', 'phenotype', 'genotype', 'ecosystem', 'biodiversity', 'nitrogen cycle', 'carbon cycle', 'food chain', 'lymph', 'antibody', 'antigen', 'vaccine', 'pathogen', 'biomolecule', 'carbohydrate', 'lipid', 'amino acid', 'nucleotide', 'atp', 'chlorophyll', 'stomata', 'xylem', 'phloem', 'transpiration', 'pollination', 'fertilization', 'embryo', 'zygote', 'gamete', 'ovary', 'testis', 'sperm', 'ovum', 'menstrual', 'placenta', 'umbilical', 'gestation', 'lactation', 'immunology', 'homeostasis'],
|
| 1270 |
+
'Chemistry': ['atom', 'molecule', 'ion', 'electron', 'proton', 'neutron', 'orbital', 'bond', 'covalent', 'ionic', 'metallic', 'oxidation', 'reduction', 'redox', 'acid', 'base', 'ph', 'salt', 'solution', 'concentration', 'mole', 'molarity', 'stoichiometry', 'equilibrium', 'catalyst', 'reaction', 'organic', 'inorganic', 'hydrocarbon', 'alkane', 'alkene', 'alkyne', 'alcohol', 'aldehyde', 'ketone', 'carboxylic', 'ester', 'amine', 'amide', 'polymer', 'isomer', 'electrolysis', 'electrochemical', 'thermodynamics', 'enthalpy', 'entropy', 'gibbs', 'periodic table', 'atomic number', 'mass number', 'isotope', 'valence', 'hybridization', 'resonance', 'aromaticity', 'benzene', 'phenol', 'ether', 'haloalkane', 'grignard', 'nucleophile', 'electrophile', 'sn1', 'sn2', 'elimination', 'addition', 'substitution', 'coordination', 'ligand', 'crystal field', 'lanthanide', 'actinide', 'd-block', 'p-block', 's-block', 'f-block', 'buffer', 'titration', 'indicator', 'solubility', 'precipitation', 'colligative', 'osmotic', 'vapour pressure', 'raoult', 'henry', 'nernst', 'faraday', 'electrochemistry', 'galvanic', 'electrolytic'],
|
| 1271 |
+
'Physics': ['force', 'mass', 'acceleration', 'velocity', 'momentum', 'energy', 'work', 'power', 'newton', 'gravity', 'friction', 'tension', 'torque', 'angular', 'rotational', 'oscillation', 'wave', 'frequency', 'wavelength', 'amplitude', 'sound', 'light', 'optics', 'lens', 'mirror', 'reflection', 'refraction', 'diffraction', 'interference', 'polarization', 'electric', 'current', 'voltage', 'resistance', 'capacitor', 'inductor', 'magnetic', 'electromagnetic', 'induction', 'transformer', 'generator', 'motor', 'circuit', 'ohm', 'kirchhoff', 'coulomb', 'gauss', 'ampere', 'faraday', 'lenz', 'maxwell', 'photoelectric', 'quantum', 'photon', 'electron', 'nucleus', 'radioactive', 'decay', 'fission', 'fusion', 'relativity', 'thermodynamics', 'heat', 'temperature', 'entropy', 'carnot', 'adiabatic', 'isothermal', 'isobaric', 'isochoric', 'kinetic theory', 'ideal gas', 'real gas', 'semiconductor', 'diode', 'transistor', 'logic gate', 'communication', 'modulation', 'satellite', 'doppler', 'spectrum', 'laser', 'holography', 'fibre optic', 'ray optics', 'wave optics', 'young', 'single slit', 'double slit', 'grating', 'brewster', 'malus', 'huygen'],
|
| 1272 |
+
'Mathematics': ['equation', 'function', 'derivative', 'integral', 'limit', 'matrix', 'vector', 'determinant', 'polynomial', 'quadratic', 'linear', 'differential', 'probability', 'statistics', 'mean', 'median', 'variance', 'standard deviation', 'permutation', 'combination', 'trigonometry', 'sine', 'cosine', 'tangent', 'logarithm', 'exponential', 'complex number', 'real number', 'set', 'relation', 'sequence', 'series', 'arithmetic progression', 'geometric progression', 'binomial', 'conic', 'parabola', 'ellipse', 'hyperbola', 'circle', 'straight line', 'plane', 'three dimensional', 'coordinate', 'calculus', 'continuity', 'differentiability', 'maxima', 'minima', 'area under curve', 'definite integral', 'indefinite integral', 'inverse trigonometric', 'mathematical induction', 'boolean algebra']
|
| 1273 |
+
};
|
| 1274 |
+
|
| 1275 |
+
// All NCERT chapters for autocomplete
|
| 1276 |
+
const ALL_CHAPTERS = {
|
| 1277 |
+
'Biology': ['The Living World', 'Biological Classification', 'Plant Kingdom', 'Animal Kingdom', 'Morphology of Flowering Plants', 'Anatomy of Flowering Plants', 'Structural Organisation in Animals', 'Cell: The Unit of Life', 'Biomolecules', 'Cell Cycle and Cell Division', 'Photosynthesis in Higher Plants', 'Respiration in Plants', 'Plant Growth and Development', 'Breathing and Exchange of Gases', 'Body Fluids and Circulation', 'Excretory Products and their Elimination', 'Locomotion and Movement', 'Neural Control and Coordination', 'Chemical Coordination and Integration', 'Reproduction in Organisms', 'Sexual Reproduction in Flowering Plants', 'Human Reproduction', 'Reproductive Health', 'Principles of Inheritance and Variation', 'Molecular Basis of Inheritance', 'Evolution', 'Human Health and Disease', 'Strategies for Enhancement in Food Production', 'Microbes in Human Welfare', 'Biotechnology: Principles and Processes', 'Biotechnology and its Applications', 'Organisms and Populations', 'Ecosystem', 'Biodiversity and Conservation', 'Environmental Issues', 'Transport in Plants', 'Mineral Nutrition', 'Digestion and Absorption'],
|
| 1278 |
+
'Chemistry': ['Some Basic Concepts of Chemistry', 'Structure of Atom', 'Classification of Elements and Periodicity in Properties', 'Chemical Bonding and Molecular Structure', 'Thermodynamics', 'Equilibrium', 'Redox Reactions', 'Organic Chemistry – Some Basic Principles and Techniques (GOC)', 'Hydrocarbons', 'Hydrogen', 'The s-Block Elements', 'The p-Block Elements', 'The d- and f-Block Elements', 'Coordination Compounds', 'Haloalkanes and Haloarenes', 'Alcohols, Phenols and Ethers', 'Aldehydes, Ketones and Carboxylic Acids', 'Amines', 'Biomolecules', 'Polymers', 'Chemistry in Everyday Life', 'Electrochemistry', 'Chemical Kinetics', 'Surface Chemistry', 'General Principles and Processes of Isolation of Elements', 'Solutions', 'Solid State', 'States of Matter'],
|
| 1279 |
+
'Physics': ['Physical World', 'Units and Measurements', 'Motion in a Straight Line', 'Motion in a Plane', 'Laws of Motion', 'Work, Energy and Power', 'System of Particles and Rotational Motion', 'Gravitation', 'Mechanical Properties of Solids', 'Mechanical Properties of Fluids', 'Thermal Properties of Matter', 'Thermodynamics', 'Kinetic Theory', 'Oscillations', 'Waves', 'Electric Charges and Fields', 'Electrostatic Potential and Capacitance', 'Current Electricity', 'Moving Charges and Magnetism', 'Magnetism and Matter', 'Electromagnetic Induction', 'Alternating Current', 'Electromagnetic Waves', 'Ray Optics and Optical Instruments', 'Wave Optics', 'Dual Nature of Radiation and Matter', 'Atoms', 'Nuclei', 'Semiconductor Electronics', 'Communication Systems'],
|
| 1280 |
+
'Mathematics': ['Sets', 'Relations and Functions', 'Trigonometric Functions', 'Complex Numbers and Quadratic Equations', 'Linear Inequalities', 'Permutations and Combinations', 'Binomial Theorem', 'Sequences and Series', 'Straight Lines', 'Conic Sections', 'Introduction to Three Dimensional Geometry', 'Limits and Derivatives', 'Statistics', 'Probability', 'Matrices', 'Determinants', 'Continuity and Differentiability', 'Applications of Derivatives', 'Integrals', 'Applications of Integrals', 'Differential Equations', 'Vector Algebra', 'Three Dimensional Geometry', 'Linear Programming', 'Inverse Trigonometric Functions', 'Mathematical Reasoning', 'Principle of Mathematical Induction']
|
| 1281 |
+
};
|
| 1282 |
+
|
| 1283 |
+
// Detect subject from question text
|
| 1284 |
+
function detectSubject(text) {
|
| 1285 |
+
if (!text) return ['Biology'];
|
| 1286 |
+
const lowerText = text.toLowerCase();
|
| 1287 |
+
const scores = {};
|
| 1288 |
+
|
| 1289 |
+
for (const [subject, keywords] of Object.entries(SUBJECT_KEYWORDS)) {
|
| 1290 |
+
scores[subject] = 0;
|
| 1291 |
+
for (const kw of keywords) {
|
| 1292 |
+
if (lowerText.includes(kw.toLowerCase())) {
|
| 1293 |
+
scores[subject]++;
|
| 1294 |
+
}
|
| 1295 |
+
}
|
| 1296 |
+
}
|
| 1297 |
+
|
| 1298 |
+
const sorted = Object.entries(scores)
|
| 1299 |
+
.filter(([_, score]) => score > 0)
|
| 1300 |
+
.sort((a, b) => b[1] - a[1]);
|
| 1301 |
+
|
| 1302 |
+
if (sorted.length === 0) return ['Biology'];
|
| 1303 |
+
if (sorted.length === 1) return [sorted[0][0]];
|
| 1304 |
+
return [sorted[0][0], sorted[1][0]].slice(0, 2);
|
| 1305 |
+
}
|
| 1306 |
+
|
| 1307 |
+
// Set active subject pill
|
| 1308 |
+
function setActiveSubject(subject) {
|
| 1309 |
+
manualSubject = subject;
|
| 1310 |
+
document.querySelectorAll('.subject-pill').forEach(pill => {
|
| 1311 |
+
pill.classList.remove('active', 'btn-success', 'btn-warning', 'btn-info', 'btn-danger');
|
| 1312 |
+
pill.classList.add('btn-outline-success', 'btn-outline-warning', 'btn-outline-info', 'btn-outline-danger');
|
| 1313 |
+
if (pill.dataset.subject === subject) {
|
| 1314 |
+
pill.classList.remove('btn-outline-success', 'btn-outline-warning', 'btn-outline-info', 'btn-outline-danger');
|
| 1315 |
+
const colorMap = { 'Biology': 'btn-success', 'Chemistry': 'btn-warning', 'Physics': 'btn-info', 'Mathematics': 'btn-danger' };
|
| 1316 |
+
pill.classList.add('active', colorMap[subject] || 'btn-primary');
|
| 1317 |
+
}
|
| 1318 |
+
});
|
| 1319 |
+
}
|
| 1320 |
+
|
| 1321 |
+
// Update typing suggestions based on input
|
| 1322 |
+
function updateTypingSuggestions() {
|
| 1323 |
+
const input = document.getElementById('topic_input');
|
| 1324 |
+
const container = document.getElementById('ai_suggestion_container');
|
| 1325 |
+
const chipsContainer = document.getElementById('ai_suggestion_chips');
|
| 1326 |
+
const query = input.value.toLowerCase().trim();
|
| 1327 |
+
|
| 1328 |
+
if (query.length < 2) {
|
| 1329 |
+
container.classList.add('d-none');
|
| 1330 |
+
return;
|
| 1331 |
+
}
|
| 1332 |
+
|
| 1333 |
+
const chapters = ALL_CHAPTERS[manualSubject] || [];
|
| 1334 |
+
const matches = chapters.filter(ch => ch.toLowerCase().includes(query)).slice(0, 5);
|
| 1335 |
+
|
| 1336 |
+
if (matches.length > 0) {
|
| 1337 |
+
container.classList.remove('d-none');
|
| 1338 |
+
chipsContainer.innerHTML = '';
|
| 1339 |
+
matches.forEach(suggestion => {
|
| 1340 |
+
const chip = document.createElement('button');
|
| 1341 |
+
chip.className = 'btn btn-outline-info btn-sm rounded-pill';
|
| 1342 |
+
chip.innerText = suggestion;
|
| 1343 |
+
chip.onclick = () => { input.value = suggestion; container.classList.add('d-none'); };
|
| 1344 |
+
chipsContainer.appendChild(chip);
|
| 1345 |
+
});
|
| 1346 |
+
} else {
|
| 1347 |
+
container.classList.add('d-none');
|
| 1348 |
+
}
|
| 1349 |
+
}
|
| 1350 |
|
| 1351 |
function parseRange(rangeStr, maxVal) {
|
| 1352 |
const indices = new Set();
|
|
|
|
| 1368 |
}
|
| 1369 |
|
| 1370 |
function startManualClassification() {
|
| 1371 |
+
// Build question list based on selected mode
|
| 1372 |
+
manualQuestionsList = [];
|
| 1373 |
+
|
| 1374 |
+
if (selectedRangeMode === 'custom') {
|
| 1375 |
+
// Use custom slider ranges with their subject settings
|
| 1376 |
+
if (customRanges.length === 0) {
|
| 1377 |
+
alert("Please add at least one range.");
|
| 1378 |
+
return;
|
| 1379 |
+
}
|
| 1380 |
+
const indices = new Set();
|
| 1381 |
+
questionSubjectMap.clear();
|
| 1382 |
+
customRanges.forEach(r => {
|
| 1383 |
+
for (let i = r.start; i <= r.end; i++) {
|
| 1384 |
+
const idx = i - 1; // Convert to 0-indexed
|
| 1385 |
+
indices.add(idx);
|
| 1386 |
+
// Store subject for this question (if not auto)
|
| 1387 |
+
if (r.subject && r.subject !== 'auto') {
|
| 1388 |
+
questionSubjectMap.set(idx, r.subject);
|
| 1389 |
+
}
|
| 1390 |
+
}
|
| 1391 |
+
});
|
| 1392 |
+
manualQuestionsList = Array.from(indices).sort((a, b) => a - b);
|
| 1393 |
} else {
|
| 1394 |
+
// Use quick select mode
|
| 1395 |
+
document.querySelectorAll('fieldset[data-question-index]').forEach((fieldset, i) => {
|
| 1396 |
+
const imageId = fieldset.querySelector('input[name^="image_id_"]').value;
|
| 1397 |
+
const chapterSpan = document.getElementById(`chapter_${imageId}`);
|
| 1398 |
+
const chapter = chapterSpan ? chapterSpan.innerText : '';
|
| 1399 |
+
const statusSelect = fieldset.querySelector('select[name^="status_"]');
|
| 1400 |
+
const status = statusSelect ? statusSelect.value : '';
|
| 1401 |
+
|
| 1402 |
+
let include = false;
|
| 1403 |
+
switch (selectedRangeMode) {
|
| 1404 |
+
case 'all':
|
| 1405 |
+
include = true;
|
| 1406 |
+
break;
|
| 1407 |
+
case 'unclassified':
|
| 1408 |
+
include = !chapter || chapter === 'N/A' || chapter === 'Unclassified';
|
| 1409 |
+
break;
|
| 1410 |
+
case 'wrong':
|
| 1411 |
+
include = status === 'wrong';
|
| 1412 |
+
break;
|
| 1413 |
+
case 'unattempted':
|
| 1414 |
+
include = status === 'unattempted';
|
| 1415 |
+
break;
|
| 1416 |
+
}
|
| 1417 |
+
if (include) manualQuestionsList.push(i);
|
| 1418 |
+
});
|
| 1419 |
}
|
| 1420 |
|
| 1421 |
if (manualQuestionsList.length === 0) {
|
| 1422 |
+
alert("No questions match the selected criteria.");
|
| 1423 |
return;
|
| 1424 |
}
|
| 1425 |
|
| 1426 |
currentManualIndex = 0;
|
| 1427 |
+
suggestionCache.clear();
|
| 1428 |
+
|
| 1429 |
+
// Setup subject pill click handlers
|
| 1430 |
+
document.querySelectorAll('.subject-pill').forEach(pill => {
|
| 1431 |
+
pill.onclick = () => {
|
| 1432 |
+
setActiveSubject(pill.dataset.subject);
|
| 1433 |
+
|
| 1434 |
+
// Clear current suggestions and show loading state immediately
|
| 1435 |
+
const container = document.getElementById('ai_suggestion_container');
|
| 1436 |
+
const chipsContainer = document.getElementById('ai_suggestion_chips');
|
| 1437 |
+
container.classList.remove('d-none');
|
| 1438 |
+
chipsContainer.innerHTML = '<span class="text-muted"><i class="bi bi-hourglass-split me-1"></i>Loading suggestions...</span>';
|
| 1439 |
+
|
| 1440 |
+
// Clear input field for fresh suggestions
|
| 1441 |
+
document.getElementById('topic_input').value = '';
|
| 1442 |
+
|
| 1443 |
+
// Clear cache and fetch new suggestions with the new subject
|
| 1444 |
+
const globalIndex = manualQuestionsList[currentManualIndex];
|
| 1445 |
+
const fieldset = document.querySelector(`fieldset[data-question-index="${globalIndex}"]`);
|
| 1446 |
+
const imageId = fieldset.querySelector('input[name^="image_id_"]').value;
|
| 1447 |
+
suggestionCache.delete(imageId);
|
| 1448 |
+
getAiSuggestion();
|
| 1449 |
+
};
|
| 1450 |
+
});
|
| 1451 |
+
|
| 1452 |
+
// Setup typing suggestions
|
| 1453 |
+
document.getElementById('topic_input').addEventListener('input', updateTypingSuggestions);
|
| 1454 |
|
| 1455 |
const modal1 = bootstrap.Modal.getInstance(document.getElementById('manualClassificationModal'));
|
| 1456 |
modal1.hide();
|
| 1457 |
+
|
| 1458 |
const modal2 = new bootstrap.Modal(document.getElementById('topicSelectionModal'));
|
| 1459 |
modal2.show();
|
| 1460 |
+
|
| 1461 |
loadTopicQuestion();
|
| 1462 |
+
|
| 1463 |
+
// Prefetch suggestions for first 30 questions
|
| 1464 |
+
prefetchSuggestions(0, 30);
|
| 1465 |
+
}
|
| 1466 |
+
|
| 1467 |
+
// Setup range toggle handlers
|
| 1468 |
+
function setupRangeToggles() {
|
| 1469 |
+
document.querySelectorAll('.range-toggle').forEach(btn => {
|
| 1470 |
+
btn.addEventListener('click', () => {
|
| 1471 |
+
document.querySelectorAll('.range-toggle').forEach(b => b.classList.remove('active'));
|
| 1472 |
+
btn.classList.add('active');
|
| 1473 |
+
selectedRangeMode = btn.dataset.range;
|
| 1474 |
+
|
| 1475 |
+
// Show/hide custom range container
|
| 1476 |
+
const customContainer = document.getElementById('custom-range-container');
|
| 1477 |
+
if (selectedRangeMode === 'custom') {
|
| 1478 |
+
customContainer.classList.remove('d-none');
|
| 1479 |
+
// Add initial slider if none exist
|
| 1480 |
+
if (customRanges.length === 0) {
|
| 1481 |
+
addRangeSlider();
|
| 1482 |
+
}
|
| 1483 |
+
} else {
|
| 1484 |
+
customContainer.classList.add('d-none');
|
| 1485 |
+
}
|
| 1486 |
+
});
|
| 1487 |
+
});
|
| 1488 |
}
|
| 1489 |
|
| 1490 |
async function prefetchSuggestions(startIndex, count) {
|
| 1491 |
+
// Group questions by their pre-selected subject for batch processing
|
| 1492 |
+
const subjectBatches = new Map(); // subject -> [{globalIndex, imageId}]
|
| 1493 |
+
const autoDetectQueue = []; // Questions without pre-selected subject
|
| 1494 |
+
|
| 1495 |
for (let i = startIndex; i < startIndex + count && i < manualQuestionsList.length; i++) {
|
| 1496 |
const globalIndex = manualQuestionsList[i];
|
| 1497 |
const fieldset = document.querySelector(`fieldset[data-question-index="${globalIndex}"]`);
|
| 1498 |
const imageId = fieldset.querySelector('input[name^="image_id_"]').value;
|
| 1499 |
+
|
| 1500 |
+
if (suggestionCache.has(imageId)) continue;
|
| 1501 |
+
|
| 1502 |
+
const preSelectedSubject = questionSubjectMap.get(globalIndex);
|
| 1503 |
+
if (preSelectedSubject) {
|
| 1504 |
+
// Group by subject for batch processing
|
| 1505 |
+
if (!subjectBatches.has(preSelectedSubject)) {
|
| 1506 |
+
subjectBatches.set(preSelectedSubject, []);
|
| 1507 |
+
}
|
| 1508 |
+
subjectBatches.get(preSelectedSubject).push({ globalIndex, imageId });
|
| 1509 |
+
} else {
|
| 1510 |
+
// Auto-detect: process individually
|
| 1511 |
+
autoDetectQueue.push({ globalIndex, imageId });
|
| 1512 |
+
}
|
| 1513 |
+
}
|
| 1514 |
+
|
| 1515 |
+
// Process batched requests (up to 8 per subject per batch)
|
| 1516 |
+
for (const [subject, questions] of subjectBatches) {
|
| 1517 |
+
for (let i = 0; i < questions.length; i += 8) {
|
| 1518 |
+
const batch = questions.slice(i, i + 8);
|
| 1519 |
+
const imageIds = batch.map(q => q.imageId);
|
| 1520 |
+
|
| 1521 |
+
// Create a single promise for the batch
|
| 1522 |
+
const batchPromise = fetch('/get_topic_suggestions_batch', {
|
| 1523 |
method: 'POST',
|
| 1524 |
headers: { 'Content-Type': 'application/json' },
|
| 1525 |
+
body: JSON.stringify({ image_ids: imageIds, subject: subject })
|
| 1526 |
})
|
| 1527 |
.then(res => res.json())
|
| 1528 |
.then(data => {
|
| 1529 |
+
if (data.success && data.results) {
|
| 1530 |
+
return data.results; // Map of imageId -> result
|
|
|
|
|
|
|
| 1531 |
}
|
| 1532 |
+
throw new Error(data.error || 'Batch request failed');
|
| 1533 |
})
|
| 1534 |
+
.catch(err => {
|
| 1535 |
+
// Return fallback for all images in batch
|
| 1536 |
+
const fallback = {};
|
| 1537 |
+
imageIds.forEach(id => {
|
| 1538 |
+
fallback[id] = { status: 'error', suggestions: ['Unclassified'], subject: subject, error: err.message };
|
| 1539 |
+
});
|
| 1540 |
+
return fallback;
|
| 1541 |
+
});
|
| 1542 |
+
|
| 1543 |
+
// Store promise for each image that resolves to its specific result
|
| 1544 |
+
batch.forEach(q => {
|
| 1545 |
+
suggestionCache.set(q.imageId, batchPromise.then(results => {
|
| 1546 |
+
const result = results[q.imageId];
|
| 1547 |
+
if (result) {
|
| 1548 |
+
return {
|
| 1549 |
+
status: 'done',
|
| 1550 |
+
suggestions: result.suggestions || ['Unclassified'],
|
| 1551 |
+
subject: result.subject || subject,
|
| 1552 |
+
otherSubjects: result.other_possible_subjects || []
|
| 1553 |
+
};
|
| 1554 |
+
}
|
| 1555 |
+
return { status: 'done', suggestions: ['Unclassified'], subject: subject, otherSubjects: [] };
|
| 1556 |
+
}));
|
| 1557 |
+
});
|
| 1558 |
}
|
| 1559 |
}
|
| 1560 |
+
|
| 1561 |
+
// Process auto-detect questions individually
|
| 1562 |
+
for (const q of autoDetectQueue) {
|
| 1563 |
+
const promise = fetch('/get_topic_suggestions', {
|
| 1564 |
+
method: 'POST',
|
| 1565 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1566 |
+
body: JSON.stringify({ image_id: q.imageId })
|
| 1567 |
+
})
|
| 1568 |
+
.then(res => res.json())
|
| 1569 |
+
.then(data => {
|
| 1570 |
+
if (data.success) {
|
| 1571 |
+
return {
|
| 1572 |
+
status: 'done',
|
| 1573 |
+
suggestions: data.suggestions || [data.chapter_title],
|
| 1574 |
+
subject: data.subject,
|
| 1575 |
+
otherSubjects: data.other_possible_subjects || []
|
| 1576 |
+
};
|
| 1577 |
+
}
|
| 1578 |
+
throw new Error(data.error);
|
| 1579 |
+
})
|
| 1580 |
+
.catch(err => ({ status: 'error', error: err.message }));
|
| 1581 |
+
|
| 1582 |
+
suggestionCache.set(q.imageId, promise);
|
| 1583 |
+
}
|
| 1584 |
}
|
| 1585 |
|
| 1586 |
function loadTopicQuestion() {
|
|
|
|
| 1590 |
const qNum = fieldset.querySelector('input[name^="question_number_"]').value;
|
| 1591 |
const img = fieldset.querySelector('img');
|
| 1592 |
const imgUrl = img ? img.src : '';
|
| 1593 |
+
const imgAlt = img ? img.alt : '';
|
| 1594 |
+
|
| 1595 |
+
// Check if subject was pre-selected in slider, otherwise auto-detect
|
| 1596 |
+
const preSelectedSubject = questionSubjectMap.get(globalIndex);
|
| 1597 |
+
if (preSelectedSubject) {
|
| 1598 |
+
setActiveSubject(preSelectedSubject);
|
| 1599 |
+
} else {
|
| 1600 |
+
const detectedSubjects = detectSubject(imgAlt);
|
| 1601 |
+
setActiveSubject(detectedSubjects[0] || 'Biology');
|
| 1602 |
+
}
|
| 1603 |
+
|
| 1604 |
// UI Updates
|
| 1605 |
+
document.getElementById('topic_q_num').innerText = `#${qNum || (currentManualIndex + 1)}`;
|
| 1606 |
const modalImg = document.getElementById('topic_q_img');
|
| 1607 |
if (imgUrl) {
|
| 1608 |
modalImg.src = imgUrl;
|
|
|
|
| 1611 |
modalImg.style.display = 'none';
|
| 1612 |
}
|
| 1613 |
|
| 1614 |
+
// Update progress bar
|
| 1615 |
+
const progressPercent = ((currentManualIndex + 1) / manualQuestionsList.length) * 100;
|
| 1616 |
+
document.getElementById('topic_progress_bar').style.width = `${progressPercent}%`;
|
| 1617 |
+
document.getElementById('topic_progress').innerText = `${currentManualIndex + 1}/${manualQuestionsList.length}`;
|
| 1618 |
+
|
| 1619 |
const chapterSpan = document.getElementById(`chapter_${imageId}`);
|
| 1620 |
const currentChapter = chapterSpan ? chapterSpan.innerText : '';
|
| 1621 |
document.getElementById('topic_input').value = (currentChapter && currentChapter !== 'N/A' && currentChapter !== 'Unclassified') ? currentChapter : '';
|
| 1622 |
+
|
|
|
|
|
|
|
| 1623 |
// Clear previous suggestions
|
| 1624 |
document.getElementById('ai_suggestion_container').classList.add('d-none');
|
| 1625 |
document.getElementById('ai_suggestion_chips').innerHTML = '';
|
| 1626 |
document.getElementById('ai_error_msg').classList.add('d-none');
|
| 1627 |
+
|
| 1628 |
// Reset button
|
| 1629 |
const btn = document.getElementById('btn_get_suggestion');
|
| 1630 |
btn.disabled = false;
|
| 1631 |
+
btn.innerHTML = `<span class="spinner-border spinner-border-sm d-none" role="status"></span> <i class="bi bi-stars"></i>`;
|
| 1632 |
+
|
| 1633 |
+
// Auto-load suggestions if topic is empty
|
| 1634 |
+
if (!document.getElementById('topic_input').value) {
|
| 1635 |
+
getAiSuggestion();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1636 |
}
|
| 1637 |
}
|
| 1638 |
|
| 1639 |
async function nextTopicQuestion() {
|
| 1640 |
+
// Save current topic (fire and forget - non-blocking)
|
| 1641 |
+
saveCurrentTopic();
|
| 1642 |
+
|
| 1643 |
if (currentManualIndex < manualQuestionsList.length - 1) {
|
| 1644 |
currentManualIndex++;
|
| 1645 |
loadTopicQuestion();
|
|
|
|
|
|
|
| 1646 |
} else {
|
| 1647 |
const modal2 = bootstrap.Modal.getInstance(document.getElementById('topicSelectionModal'));
|
| 1648 |
modal2.hide();
|
|
|
|
| 1657 |
}
|
| 1658 |
}
|
| 1659 |
|
| 1660 |
+
function saveCurrentTopic() {
|
| 1661 |
const globalIndex = manualQuestionsList[currentManualIndex];
|
| 1662 |
const fieldset = document.querySelector(`fieldset[data-question-index="${globalIndex}"]`);
|
| 1663 |
const imageId = fieldset.querySelector('input[name^="image_id_"]').value;
|
| 1664 |
const topic = document.getElementById('topic_input').value;
|
| 1665 |
|
| 1666 |
+
// Update UI immediately
|
| 1667 |
const subjectSpan = document.getElementById(`subject_${imageId}`);
|
| 1668 |
if(subjectSpan) subjectSpan.innerText = manualSubject;
|
| 1669 |
+
|
| 1670 |
const chapterSpan = document.getElementById(`chapter_${imageId}`);
|
| 1671 |
if(chapterSpan) chapterSpan.innerText = topic || 'Unclassified';
|
| 1672 |
|
| 1673 |
+
// Save to backend in background (fire and forget)
|
| 1674 |
+
fetch('/classified/update_single', {
|
| 1675 |
+
method: 'POST',
|
| 1676 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1677 |
+
body: JSON.stringify({
|
| 1678 |
+
image_id: imageId,
|
| 1679 |
+
subject: manualSubject,
|
| 1680 |
+
chapter: topic
|
| 1681 |
+
})
|
| 1682 |
+
}).catch(e => console.error("Failed to save topic", e));
|
|
|
|
|
|
|
|
|
|
| 1683 |
}
|
| 1684 |
|
| 1685 |
async function getAiSuggestion() {
|
|
|
|
| 1690 |
const container = document.getElementById('ai_suggestion_container');
|
| 1691 |
const chipsContainer = document.getElementById('ai_suggestion_chips');
|
| 1692 |
const errorDiv = document.getElementById('ai_error_msg');
|
| 1693 |
+
|
| 1694 |
btn.disabled = true;
|
| 1695 |
btn.querySelector('.spinner-border').classList.remove('d-none');
|
| 1696 |
errorDiv.classList.add('d-none');
|
|
|
|
| 1700 |
if (suggestionCache.has(imageId)) {
|
| 1701 |
result = await suggestionCache.get(imageId);
|
| 1702 |
} else {
|
| 1703 |
+
// Check if subject was pre-selected in slider
|
| 1704 |
+
const preSelectedSubject = questionSubjectMap.get(globalIndex);
|
| 1705 |
+
const requestBody = preSelectedSubject
|
| 1706 |
+
? { image_id: imageId, subject: preSelectedSubject }
|
| 1707 |
+
: { image_id: imageId }; // No subject - AI will detect
|
| 1708 |
+
|
| 1709 |
const promise = fetch('/get_topic_suggestions', {
|
| 1710 |
method: 'POST',
|
| 1711 |
headers: { 'Content-Type': 'application/json' },
|
| 1712 |
+
body: JSON.stringify(requestBody)
|
| 1713 |
})
|
| 1714 |
.then(res => res.json())
|
| 1715 |
.then(data => {
|
| 1716 |
+
if (data.success) return {
|
| 1717 |
+
status: 'done',
|
| 1718 |
+
suggestions: data.suggestions || [data.chapter_title],
|
| 1719 |
+
subject: data.subject, // AI-detected subject
|
| 1720 |
+
otherSubjects: data.other_possible_subjects || []
|
| 1721 |
+
};
|
| 1722 |
else throw new Error(data.error);
|
| 1723 |
})
|
| 1724 |
.catch(err => ({ status: 'error', error: err.message }));
|
| 1725 |
+
|
| 1726 |
suggestionCache.set(imageId, promise);
|
| 1727 |
result = await promise;
|
| 1728 |
}
|
| 1729 |
|
| 1730 |
if (result.status === 'done') {
|
| 1731 |
+
// Update subject from AI response
|
| 1732 |
+
if (result.subject) {
|
| 1733 |
+
setActiveSubject(result.subject);
|
| 1734 |
+
}
|
| 1735 |
+
|
| 1736 |
container.classList.remove('d-none');
|
| 1737 |
chipsContainer.innerHTML = '';
|
| 1738 |
+
|
| 1739 |
+
// Show other possible subjects if detected
|
| 1740 |
+
if (result.otherSubjects && result.otherSubjects.length > 0) {
|
| 1741 |
+
const otherLabel = document.createElement('span');
|
| 1742 |
+
otherLabel.className = 'badge bg-secondary me-2';
|
| 1743 |
+
otherLabel.innerText = `Also: ${result.otherSubjects.join(', ')}`;
|
| 1744 |
+
otherLabel.title = 'This question may also belong to these subjects';
|
| 1745 |
+
chipsContainer.appendChild(otherLabel);
|
| 1746 |
+
}
|
| 1747 |
+
|
| 1748 |
result.suggestions.forEach(suggestion => {
|
| 1749 |
const chip = document.createElement('button');
|
| 1750 |
chip.className = 'btn btn-outline-info btn-sm rounded-pill';
|
|
|
|
| 1754 |
};
|
| 1755 |
chipsContainer.appendChild(chip);
|
| 1756 |
});
|
| 1757 |
+
|
| 1758 |
// Auto-fill input if empty and we have a primary suggestion
|
| 1759 |
const currentVal = document.getElementById('topic_input').value;
|
| 1760 |
if (!currentVal && result.suggestions.length > 0) {
|
|
|
|
| 1777 |
initializeTomSelect(); // Initialize Tom Select here
|
| 1778 |
loadSettings();
|
| 1779 |
setupEventListeners();
|
| 1780 |
+
setupRangeToggles();
|
| 1781 |
});
|
| 1782 |
</script>
|
| 1783 |
{% endblock %}
|
templates/quiz_v2.html
CHANGED
|
@@ -12,10 +12,8 @@
|
|
| 12 |
}
|
| 13 |
|
| 14 |
/* --- MAIN CONTAINER (The Hammer) --- */
|
| 15 |
-
/* We force this to be exactly 88% of the screen height.
|
| 16 |
-
The remaining 12% allows for the Navbar above it. */
|
| 17 |
.quiz-container {
|
| 18 |
-
height: 88dvh;
|
| 19 |
max-height: 88dvh;
|
| 20 |
width: 100%;
|
| 21 |
display: flex;
|
|
@@ -39,36 +37,50 @@
|
|
| 39 |
|
| 40 |
/* --- 2. MIDDLE IMAGE AREA (Flexible) --- */
|
| 41 |
.question-display {
|
| 42 |
-
flex: 1;
|
| 43 |
-
min-height: 0;
|
| 44 |
display: flex;
|
| 45 |
justify-content: center;
|
| 46 |
align-items: center;
|
| 47 |
padding: 5px;
|
| 48 |
position: relative;
|
| 49 |
-
overflow: hidden;
|
| 50 |
}
|
| 51 |
|
| 52 |
.question-image {
|
| 53 |
max-width: 100%;
|
| 54 |
-
max-height: 100%;
|
| 55 |
width: auto;
|
| 56 |
height: auto;
|
| 57 |
-
object-fit: contain;
|
| 58 |
border-radius: 6px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
}
|
| 60 |
|
| 61 |
/* --- 3. BOTTOM CONTROLS (Fixed Height) --- */
|
| 62 |
.quiz-controls {
|
| 63 |
-
flex-shrink: 0;
|
| 64 |
-
height: 70px;
|
| 65 |
display: grid;
|
| 66 |
grid-template-columns: 1fr auto 1fr;
|
| 67 |
align-items: center;
|
| 68 |
padding: 0 15px;
|
| 69 |
background-color: #212529;
|
| 70 |
border-top: 1px solid #495057;
|
| 71 |
-
/* Ensure it sits above everything */
|
| 72 |
z-index: 50;
|
| 73 |
}
|
| 74 |
|
|
@@ -77,7 +89,7 @@
|
|
| 77 |
display: none;
|
| 78 |
background-color: #343a40;
|
| 79 |
border-top: 1px solid #6c757d;
|
| 80 |
-
max-height: 30%;
|
| 81 |
overflow-y: auto;
|
| 82 |
padding: 15px;
|
| 83 |
font-size: 0.9rem;
|
|
@@ -86,7 +98,7 @@
|
|
| 86 |
|
| 87 |
/* --- BUTTON STYLES --- */
|
| 88 |
.btn-pill { border-radius: 50px; }
|
| 89 |
-
|
| 90 |
/* --- OVERLAYS --- */
|
| 91 |
.answer-overlay {
|
| 92 |
position: absolute;
|
|
@@ -104,6 +116,28 @@
|
|
| 104 |
#your-answer-overlay { left: 10px; background: rgba(13, 110, 253, 0.9); }
|
| 105 |
#correct-answer-overlay { right: 10px; background: rgba(25, 135, 84, 0.9); }
|
| 106 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
</style>
|
| 108 |
{% endblock %}
|
| 109 |
|
|
@@ -116,6 +150,11 @@
|
|
| 116 |
|
| 117 |
<!-- 2. Image (Will auto-resize to fit remaining space) -->
|
| 118 |
<div class="question-display">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
<img src="" id="question-image" class="question-image" alt="Question">
|
| 120 |
<div id="your-answer-overlay" class="answer-overlay"></div>
|
| 121 |
<div id="correct-answer-overlay" class="answer-overlay"></div>
|
|
@@ -130,12 +169,15 @@
|
|
| 130 |
<!-- 4. Controls -->
|
| 131 |
<div class="quiz-controls">
|
| 132 |
<!-- Left -->
|
| 133 |
-
<div>
|
| 134 |
<button id="prev-btn" class="btn btn-secondary btn-sm btn-pill">
|
| 135 |
<i class="bi bi-arrow-left"></i> Prev
|
| 136 |
</button>
|
|
|
|
|
|
|
|
|
|
| 137 |
</div>
|
| 138 |
-
|
| 139 |
<!-- Center -->
|
| 140 |
<div class="text-center">
|
| 141 |
<button id="spoiler-btn" class="btn btn-warning btn-sm btn-pill px-3">Check Answer</button>
|
|
@@ -151,6 +193,35 @@
|
|
| 151 |
</div>
|
| 152 |
</div>
|
| 153 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
<script>
|
| 155 |
const questions = {{ questions | tojson | safe }};
|
| 156 |
</script>
|
|
@@ -161,23 +232,87 @@
|
|
| 161 |
document.addEventListener('DOMContentLoaded', () => {
|
| 162 |
let currentQuestionIndex = 0;
|
| 163 |
const img = document.getElementById('question-image');
|
|
|
|
| 164 |
const counter = document.getElementById('question-counter');
|
| 165 |
const pBar = document.getElementById('progress-bar');
|
| 166 |
const spoiler = document.getElementById('details-spoiler');
|
| 167 |
const spoilerBtn = document.getElementById('spoiler-btn');
|
| 168 |
const yourOv = document.getElementById('your-answer-overlay');
|
| 169 |
const corrOv = document.getElementById('correct-answer-overlay');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
|
| 171 |
function loadQ(i) {
|
| 172 |
if(i < 0 || i >= questions.length) return;
|
| 173 |
currentQuestionIndex = i;
|
| 174 |
const q = questions[i];
|
|
|
|
| 175 |
|
| 176 |
-
//
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
|
| 182 |
// UI Updates
|
| 183 |
counter.textContent = `${i+1} / ${questions.length}`;
|
|
@@ -203,8 +338,194 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
| 203 |
|
| 204 |
document.getElementById('prev-btn').disabled = i === 0;
|
| 205 |
document.getElementById('next-btn').disabled = i === questions.length - 1;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
}
|
| 207 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
spoilerBtn.onclick = () => {
|
| 209 |
if(spoiler.style.display === 'none') {
|
| 210 |
spoiler.style.display = 'block';
|
|
@@ -225,12 +546,29 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
| 225 |
document.getElementById('next-btn').onclick = () => loadQ(currentQuestionIndex + 1);
|
| 226 |
|
| 227 |
document.addEventListener('keydown', e => {
|
|
|
|
|
|
|
|
|
|
| 228 |
if(e.key === 'ArrowLeft') loadQ(currentQuestionIndex - 1);
|
| 229 |
if(e.key === 'ArrowRight') loadQ(currentQuestionIndex + 1);
|
| 230 |
-
if(e.key === ' ' || e.key === 'Enter' || e.key === 'c') spoilerBtn.click();
|
|
|
|
| 231 |
});
|
| 232 |
|
| 233 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
});
|
| 235 |
</script>
|
| 236 |
{% endblock %}
|
|
|
|
| 12 |
}
|
| 13 |
|
| 14 |
/* --- MAIN CONTAINER (The Hammer) --- */
|
|
|
|
|
|
|
| 15 |
.quiz-container {
|
| 16 |
+
height: 88dvh;
|
| 17 |
max-height: 88dvh;
|
| 18 |
width: 100%;
|
| 19 |
display: flex;
|
|
|
|
| 37 |
|
| 38 |
/* --- 2. MIDDLE IMAGE AREA (Flexible) --- */
|
| 39 |
.question-display {
|
| 40 |
+
flex: 1;
|
| 41 |
+
min-height: 0;
|
| 42 |
display: flex;
|
| 43 |
justify-content: center;
|
| 44 |
align-items: center;
|
| 45 |
padding: 5px;
|
| 46 |
position: relative;
|
| 47 |
+
overflow: hidden;
|
| 48 |
}
|
| 49 |
|
| 50 |
.question-image {
|
| 51 |
max-width: 100%;
|
| 52 |
+
max-height: 100%;
|
| 53 |
width: auto;
|
| 54 |
height: auto;
|
| 55 |
+
object-fit: contain;
|
| 56 |
border-radius: 6px;
|
| 57 |
+
opacity: 0;
|
| 58 |
+
transition: opacity 0.3s ease-in;
|
| 59 |
+
}
|
| 60 |
+
.question-image.loaded {
|
| 61 |
+
opacity: 1;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
/* Loading spinner for lazy load */
|
| 65 |
+
.image-loading {
|
| 66 |
+
position: absolute;
|
| 67 |
+
display: none;
|
| 68 |
+
color: #6c757d;
|
| 69 |
+
}
|
| 70 |
+
.image-loading.show {
|
| 71 |
+
display: block;
|
| 72 |
}
|
| 73 |
|
| 74 |
/* --- 3. BOTTOM CONTROLS (Fixed Height) --- */
|
| 75 |
.quiz-controls {
|
| 76 |
+
flex-shrink: 0;
|
| 77 |
+
height: 70px;
|
| 78 |
display: grid;
|
| 79 |
grid-template-columns: 1fr auto 1fr;
|
| 80 |
align-items: center;
|
| 81 |
padding: 0 15px;
|
| 82 |
background-color: #212529;
|
| 83 |
border-top: 1px solid #495057;
|
|
|
|
| 84 |
z-index: 50;
|
| 85 |
}
|
| 86 |
|
|
|
|
| 89 |
display: none;
|
| 90 |
background-color: #343a40;
|
| 91 |
border-top: 1px solid #6c757d;
|
| 92 |
+
max-height: 30%;
|
| 93 |
overflow-y: auto;
|
| 94 |
padding: 15px;
|
| 95 |
font-size: 0.9rem;
|
|
|
|
| 98 |
|
| 99 |
/* --- BUTTON STYLES --- */
|
| 100 |
.btn-pill { border-radius: 50px; }
|
| 101 |
+
|
| 102 |
/* --- OVERLAYS --- */
|
| 103 |
.answer-overlay {
|
| 104 |
position: absolute;
|
|
|
|
| 116 |
#your-answer-overlay { left: 10px; background: rgba(13, 110, 253, 0.9); }
|
| 117 |
#correct-answer-overlay { right: 10px; background: rgba(25, 135, 84, 0.9); }
|
| 118 |
|
| 119 |
+
/* Bookmark button states */
|
| 120 |
+
.bookmark-btn.bookmarked {
|
| 121 |
+
background-color: #ffc107 !important;
|
| 122 |
+
border-color: #ffc107 !important;
|
| 123 |
+
color: #000 !important;
|
| 124 |
+
}
|
| 125 |
+
.bookmark-btn.bookmarked i::before {
|
| 126 |
+
content: "\F147"; /* bi-bookmark-fill */
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
/* Collection list in modal */
|
| 130 |
+
.collection-item {
|
| 131 |
+
cursor: pointer;
|
| 132 |
+
transition: background-color 0.15s;
|
| 133 |
+
}
|
| 134 |
+
.collection-item:hover {
|
| 135 |
+
background-color: #3d444b;
|
| 136 |
+
}
|
| 137 |
+
.collection-item.selected {
|
| 138 |
+
background-color: #0d6efd;
|
| 139 |
+
color: white;
|
| 140 |
+
}
|
| 141 |
</style>
|
| 142 |
{% endblock %}
|
| 143 |
|
|
|
|
| 150 |
|
| 151 |
<!-- 2. Image (Will auto-resize to fit remaining space) -->
|
| 152 |
<div class="question-display">
|
| 153 |
+
<div class="image-loading" id="image-loading">
|
| 154 |
+
<div class="spinner-border text-secondary" role="status">
|
| 155 |
+
<span class="visually-hidden">Loading...</span>
|
| 156 |
+
</div>
|
| 157 |
+
</div>
|
| 158 |
<img src="" id="question-image" class="question-image" alt="Question">
|
| 159 |
<div id="your-answer-overlay" class="answer-overlay"></div>
|
| 160 |
<div id="correct-answer-overlay" class="answer-overlay"></div>
|
|
|
|
| 169 |
<!-- 4. Controls -->
|
| 170 |
<div class="quiz-controls">
|
| 171 |
<!-- Left -->
|
| 172 |
+
<div class="d-flex gap-2">
|
| 173 |
<button id="prev-btn" class="btn btn-secondary btn-sm btn-pill">
|
| 174 |
<i class="bi bi-arrow-left"></i> Prev
|
| 175 |
</button>
|
| 176 |
+
<button id="bookmark-btn" class="btn btn-outline-warning btn-sm btn-pill bookmark-btn" title="Add to Collection">
|
| 177 |
+
<i class="bi bi-bookmark"></i>
|
| 178 |
+
</button>
|
| 179 |
</div>
|
| 180 |
+
|
| 181 |
<!-- Center -->
|
| 182 |
<div class="text-center">
|
| 183 |
<button id="spoiler-btn" class="btn btn-warning btn-sm btn-pill px-3">Check Answer</button>
|
|
|
|
| 193 |
</div>
|
| 194 |
</div>
|
| 195 |
|
| 196 |
+
<!-- Bookmark Collection Modal -->
|
| 197 |
+
<div class="modal fade" id="bookmarkModal" tabindex="-1">
|
| 198 |
+
<div class="modal-dialog modal-dialog-centered">
|
| 199 |
+
<div class="modal-content bg-dark text-white">
|
| 200 |
+
<div class="modal-header border-secondary">
|
| 201 |
+
<h5 class="modal-title"><i class="bi bi-bookmark me-2"></i>Add to Collection</h5>
|
| 202 |
+
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
| 203 |
+
</div>
|
| 204 |
+
<div class="modal-body">
|
| 205 |
+
<div id="collections-list" class="mb-3">
|
| 206 |
+
<div class="text-muted text-center py-3">Loading collections...</div>
|
| 207 |
+
</div>
|
| 208 |
+
<div class="border-top border-secondary pt-3">
|
| 209 |
+
<div class="input-group">
|
| 210 |
+
<input type="text" id="new-collection-name" class="form-control bg-secondary text-white border-secondary" placeholder="New collection name...">
|
| 211 |
+
<button class="btn btn-success" id="create-collection-btn">
|
| 212 |
+
<i class="bi bi-plus-lg"></i> Create
|
| 213 |
+
</button>
|
| 214 |
+
</div>
|
| 215 |
+
</div>
|
| 216 |
+
</div>
|
| 217 |
+
<div class="modal-footer border-secondary">
|
| 218 |
+
<span id="bookmark-status" class="text-muted small me-auto"></span>
|
| 219 |
+
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
| 220 |
+
</div>
|
| 221 |
+
</div>
|
| 222 |
+
</div>
|
| 223 |
+
</div>
|
| 224 |
+
|
| 225 |
<script>
|
| 226 |
const questions = {{ questions | tojson | safe }};
|
| 227 |
</script>
|
|
|
|
| 232 |
document.addEventListener('DOMContentLoaded', () => {
|
| 233 |
let currentQuestionIndex = 0;
|
| 234 |
const img = document.getElementById('question-image');
|
| 235 |
+
const imgLoading = document.getElementById('image-loading');
|
| 236 |
const counter = document.getElementById('question-counter');
|
| 237 |
const pBar = document.getElementById('progress-bar');
|
| 238 |
const spoiler = document.getElementById('details-spoiler');
|
| 239 |
const spoilerBtn = document.getElementById('spoiler-btn');
|
| 240 |
const yourOv = document.getElementById('your-answer-overlay');
|
| 241 |
const corrOv = document.getElementById('correct-answer-overlay');
|
| 242 |
+
const bookmarkBtn = document.getElementById('bookmark-btn');
|
| 243 |
+
|
| 244 |
+
// Lazy loading: preload adjacent images
|
| 245 |
+
const imageCache = new Map();
|
| 246 |
+
let collections = [];
|
| 247 |
+
let questionBookmarks = new Map(); // questionId -> [collectionIds]
|
| 248 |
+
|
| 249 |
+
function preloadImage(path) {
|
| 250 |
+
if (imageCache.has(path)) return imageCache.get(path);
|
| 251 |
+
const promise = new Promise((resolve, reject) => {
|
| 252 |
+
const img = new Image();
|
| 253 |
+
img.onload = () => resolve(path);
|
| 254 |
+
img.onerror = reject;
|
| 255 |
+
img.src = path;
|
| 256 |
+
});
|
| 257 |
+
imageCache.set(path, promise);
|
| 258 |
+
return promise;
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
function getImagePath(q) {
|
| 262 |
+
let path = q.image_path;
|
| 263 |
+
// If path already starts with /, it's already an absolute URL path
|
| 264 |
+
if (path.startsWith('/')) {
|
| 265 |
+
// Convert /processed/ or /tmp/ to /neetprep/processed/ or /neetprep/tmp/
|
| 266 |
+
if (path.startsWith('/processed/')) return `/neetprep${path}`;
|
| 267 |
+
if (path.startsWith('/tmp/')) return `/neetprep${path}`;
|
| 268 |
+
return path;
|
| 269 |
+
}
|
| 270 |
+
// Handle relative paths (legacy)
|
| 271 |
+
if(path.includes('/processed/')) path = `/neetprep/processed/${path.split('/processed/')[1]}`;
|
| 272 |
+
else if(path.includes('/tmp/')) path = `/neetprep/tmp/${path.split('/tmp/')[1]}`;
|
| 273 |
+
return path;
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
// Preload images around current index
|
| 277 |
+
function preloadAround(index) {
|
| 278 |
+
const toPreload = [index - 1, index, index + 1, index + 2, index + 3];
|
| 279 |
+
toPreload.forEach(i => {
|
| 280 |
+
if (i >= 0 && i < questions.length) {
|
| 281 |
+
preloadImage(getImagePath(questions[i]));
|
| 282 |
+
}
|
| 283 |
+
});
|
| 284 |
+
}
|
| 285 |
|
| 286 |
function loadQ(i) {
|
| 287 |
if(i < 0 || i >= questions.length) return;
|
| 288 |
currentQuestionIndex = i;
|
| 289 |
const q = questions[i];
|
| 290 |
+
const path = getImagePath(q);
|
| 291 |
|
| 292 |
+
// Show loading, hide image
|
| 293 |
+
img.classList.remove('loaded');
|
| 294 |
+
imgLoading.classList.add('show');
|
| 295 |
+
|
| 296 |
+
// Check cache or load
|
| 297 |
+
if (imageCache.has(path)) {
|
| 298 |
+
imageCache.get(path).then(() => {
|
| 299 |
+
img.src = path;
|
| 300 |
+
img.classList.add('loaded');
|
| 301 |
+
imgLoading.classList.remove('show');
|
| 302 |
+
});
|
| 303 |
+
} else {
|
| 304 |
+
preloadImage(path).then(() => {
|
| 305 |
+
img.src = path;
|
| 306 |
+
img.classList.add('loaded');
|
| 307 |
+
imgLoading.classList.remove('show');
|
| 308 |
+
}).catch(() => {
|
| 309 |
+
img.src = path; // Try anyway
|
| 310 |
+
imgLoading.classList.remove('show');
|
| 311 |
+
});
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
// Preload adjacent images
|
| 315 |
+
preloadAround(i);
|
| 316 |
|
| 317 |
// UI Updates
|
| 318 |
counter.textContent = `${i+1} / ${questions.length}`;
|
|
|
|
| 338 |
|
| 339 |
document.getElementById('prev-btn').disabled = i === 0;
|
| 340 |
document.getElementById('next-btn').disabled = i === questions.length - 1;
|
| 341 |
+
|
| 342 |
+
// Fetch and update bookmark button state
|
| 343 |
+
const qId = q.details?.id;
|
| 344 |
+
if (qId && !questionBookmarks.has(qId)) {
|
| 345 |
+
// Fetch bookmark status if not already cached
|
| 346 |
+
fetchQuestionBookmarks(qId);
|
| 347 |
+
} else {
|
| 348 |
+
updateBookmarkButton();
|
| 349 |
+
}
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
function updateBookmarkButton() {
|
| 353 |
+
const q = questions[currentQuestionIndex];
|
| 354 |
+
const qId = q.details?.id;
|
| 355 |
+
if (qId && questionBookmarks.has(qId) && questionBookmarks.get(qId).length > 0) {
|
| 356 |
+
bookmarkBtn.classList.add('bookmarked');
|
| 357 |
+
bookmarkBtn.title = 'Bookmarked';
|
| 358 |
+
} else {
|
| 359 |
+
bookmarkBtn.classList.remove('bookmarked');
|
| 360 |
+
bookmarkBtn.title = 'Add to Collection';
|
| 361 |
+
}
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
// Fetch collections
|
| 365 |
+
async function fetchCollections() {
|
| 366 |
+
try {
|
| 367 |
+
const res = await fetch('/neetprep/collections');
|
| 368 |
+
const data = await res.json();
|
| 369 |
+
if (data.success) {
|
| 370 |
+
collections = data.collections;
|
| 371 |
+
}
|
| 372 |
+
} catch (e) {
|
| 373 |
+
console.error('Failed to fetch collections:', e);
|
| 374 |
+
}
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
// Fetch which collections the current question is in
|
| 378 |
+
async function fetchQuestionBookmarks(questionId) {
|
| 379 |
+
if (!questionId) return;
|
| 380 |
+
try {
|
| 381 |
+
const res = await fetch(`/neetprep/question/${questionId}/collections`);
|
| 382 |
+
const data = await res.json();
|
| 383 |
+
if (data.success) {
|
| 384 |
+
questionBookmarks.set(questionId, data.collections.map(c => c.id));
|
| 385 |
+
updateBookmarkButton();
|
| 386 |
+
}
|
| 387 |
+
} catch (e) {
|
| 388 |
+
console.error('Failed to fetch question bookmarks:', e);
|
| 389 |
+
}
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
// Batch fetch bookmark status for all questions
|
| 393 |
+
async function fetchAllBookmarkStatuses() {
|
| 394 |
+
const questionIds = questions
|
| 395 |
+
.map(q => q.details?.id)
|
| 396 |
+
.filter(id => id != null);
|
| 397 |
+
|
| 398 |
+
if (questionIds.length === 0) return;
|
| 399 |
+
|
| 400 |
+
try {
|
| 401 |
+
const res = await fetch('/neetprep/bookmarks/batch', {
|
| 402 |
+
method: 'POST',
|
| 403 |
+
headers: { 'Content-Type': 'application/json' },
|
| 404 |
+
body: JSON.stringify({ question_ids: questionIds })
|
| 405 |
+
});
|
| 406 |
+
const data = await res.json();
|
| 407 |
+
if (data.success && data.bookmarks) {
|
| 408 |
+
// Populate the questionBookmarks map
|
| 409 |
+
for (const [qId, collectionIds] of Object.entries(data.bookmarks)) {
|
| 410 |
+
questionBookmarks.set(qId, collectionIds);
|
| 411 |
+
}
|
| 412 |
+
updateBookmarkButton();
|
| 413 |
+
}
|
| 414 |
+
} catch (e) {
|
| 415 |
+
console.error('Failed to batch fetch bookmarks:', e);
|
| 416 |
+
}
|
| 417 |
}
|
| 418 |
|
| 419 |
+
function renderCollectionsList() {
|
| 420 |
+
const list = document.getElementById('collections-list');
|
| 421 |
+
const q = questions[currentQuestionIndex];
|
| 422 |
+
const qId = q.details?.id;
|
| 423 |
+
const bookmarkedIn = questionBookmarks.get(qId) || [];
|
| 424 |
+
|
| 425 |
+
if (collections.length === 0) {
|
| 426 |
+
list.innerHTML = '<div class="text-muted text-center py-3">No collections yet. Create one below!</div>';
|
| 427 |
+
return;
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
list.innerHTML = collections.map(c => {
|
| 431 |
+
const isBookmarked = bookmarkedIn.includes(c.id);
|
| 432 |
+
return `
|
| 433 |
+
<div class="collection-item d-flex justify-content-between align-items-center p-2 rounded mb-1 ${isBookmarked ? 'selected' : ''}"
|
| 434 |
+
data-id="${c.id}">
|
| 435 |
+
<span>
|
| 436 |
+
<i class="bi bi-folder${isBookmarked ? '-fill' : ''} me-2"></i>${c.name}
|
| 437 |
+
<small class="text-muted">(${c.question_count || 0})</small>
|
| 438 |
+
</span>
|
| 439 |
+
<span>
|
| 440 |
+
${isBookmarked
|
| 441 |
+
? '<i class="bi bi-check-circle-fill text-success"></i>'
|
| 442 |
+
: '<i class="bi bi-plus-circle text-secondary"></i>'}
|
| 443 |
+
</span>
|
| 444 |
+
</div>
|
| 445 |
+
`;
|
| 446 |
+
}).join('');
|
| 447 |
+
|
| 448 |
+
// Add click handlers
|
| 449 |
+
list.querySelectorAll('.collection-item').forEach(item => {
|
| 450 |
+
item.onclick = async () => {
|
| 451 |
+
const collectionId = item.dataset.id;
|
| 452 |
+
const isBookmarked = bookmarkedIn.includes(collectionId);
|
| 453 |
+
const questionType = q.details?.source || 'neetprep';
|
| 454 |
+
|
| 455 |
+
try {
|
| 456 |
+
if (isBookmarked) {
|
| 457 |
+
// Remove bookmark
|
| 458 |
+
await fetch('/neetprep/bookmark', {
|
| 459 |
+
method: 'DELETE',
|
| 460 |
+
headers: { 'Content-Type': 'application/json' },
|
| 461 |
+
body: JSON.stringify({ question_id: qId, session_id: collectionId, question_type: questionType })
|
| 462 |
+
});
|
| 463 |
+
questionBookmarks.set(qId, bookmarkedIn.filter(id => id !== collectionId));
|
| 464 |
+
document.getElementById('bookmark-status').textContent = 'Removed from collection';
|
| 465 |
+
} else {
|
| 466 |
+
// Add bookmark
|
| 467 |
+
await fetch('/neetprep/bookmark', {
|
| 468 |
+
method: 'POST',
|
| 469 |
+
headers: { 'Content-Type': 'application/json' },
|
| 470 |
+
body: JSON.stringify({ question_id: qId, session_id: collectionId, question_type: questionType })
|
| 471 |
+
});
|
| 472 |
+
questionBookmarks.set(qId, [...bookmarkedIn, collectionId]);
|
| 473 |
+
document.getElementById('bookmark-status').textContent = 'Added to collection';
|
| 474 |
+
}
|
| 475 |
+
// Refresh collections count and UI
|
| 476 |
+
await fetchCollections();
|
| 477 |
+
renderCollectionsList();
|
| 478 |
+
updateBookmarkButton();
|
| 479 |
+
} catch (e) {
|
| 480 |
+
console.error('Bookmark operation failed:', e);
|
| 481 |
+
document.getElementById('bookmark-status').textContent = 'Error: ' + e.message;
|
| 482 |
+
}
|
| 483 |
+
};
|
| 484 |
+
});
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
// Create new collection
|
| 488 |
+
document.getElementById('create-collection-btn').onclick = async () => {
|
| 489 |
+
const nameInput = document.getElementById('new-collection-name');
|
| 490 |
+
const name = nameInput.value.trim();
|
| 491 |
+
if (!name) return;
|
| 492 |
+
|
| 493 |
+
try {
|
| 494 |
+
const res = await fetch('/neetprep/collections/create', {
|
| 495 |
+
method: 'POST',
|
| 496 |
+
headers: { 'Content-Type': 'application/json' },
|
| 497 |
+
body: JSON.stringify({ name })
|
| 498 |
+
});
|
| 499 |
+
const data = await res.json();
|
| 500 |
+
if (data.success) {
|
| 501 |
+
nameInput.value = '';
|
| 502 |
+
await fetchCollections();
|
| 503 |
+
renderCollectionsList();
|
| 504 |
+
document.getElementById('bookmark-status').textContent = `Created "${name}"`;
|
| 505 |
+
}
|
| 506 |
+
} catch (e) {
|
| 507 |
+
console.error('Failed to create collection:', e);
|
| 508 |
+
}
|
| 509 |
+
};
|
| 510 |
+
|
| 511 |
+
// Bookmark button click
|
| 512 |
+
bookmarkBtn.onclick = async () => {
|
| 513 |
+
const q = questions[currentQuestionIndex];
|
| 514 |
+
const qId = q.details?.id;
|
| 515 |
+
if (!qId) {
|
| 516 |
+
alert('This question cannot be bookmarked (no ID)');
|
| 517 |
+
return;
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
// Fetch current bookmarks for this question
|
| 521 |
+
await fetchQuestionBookmarks(qId);
|
| 522 |
+
await fetchCollections();
|
| 523 |
+
renderCollectionsList();
|
| 524 |
+
|
| 525 |
+
const modal = new bootstrap.Modal(document.getElementById('bookmarkModal'));
|
| 526 |
+
modal.show();
|
| 527 |
+
};
|
| 528 |
+
|
| 529 |
spoilerBtn.onclick = () => {
|
| 530 |
if(spoiler.style.display === 'none') {
|
| 531 |
spoiler.style.display = 'block';
|
|
|
|
| 546 |
document.getElementById('next-btn').onclick = () => loadQ(currentQuestionIndex + 1);
|
| 547 |
|
| 548 |
document.addEventListener('keydown', e => {
|
| 549 |
+
// Ignore if typing in input
|
| 550 |
+
if (e.target.tagName === 'INPUT') return;
|
| 551 |
+
|
| 552 |
if(e.key === 'ArrowLeft') loadQ(currentQuestionIndex - 1);
|
| 553 |
if(e.key === 'ArrowRight') loadQ(currentQuestionIndex + 1);
|
| 554 |
+
if(e.key === ' ' || e.key === 'Enter' || e.key === 'c') { e.preventDefault(); spoilerBtn.click(); }
|
| 555 |
+
if(e.key === 'b') bookmarkBtn.click();
|
| 556 |
});
|
| 557 |
|
| 558 |
+
// Initial load
|
| 559 |
+
if(questions.length) {
|
| 560 |
+
fetchCollections();
|
| 561 |
+
// Batch fetch all bookmark statuses, then load first question
|
| 562 |
+
fetchAllBookmarkStatuses().then(() => {
|
| 563 |
+
// After batch fetch, ensure first question shows correct status
|
| 564 |
+
updateBookmarkButton();
|
| 565 |
+
});
|
| 566 |
+
loadQ(0);
|
| 567 |
+
// Prefetch first few questions on initial load
|
| 568 |
+
for (let i = 0; i < Math.min(5, questions.length); i++) {
|
| 569 |
+
preloadImage(getImagePath(questions[i]));
|
| 570 |
+
}
|
| 571 |
+
}
|
| 572 |
});
|
| 573 |
</script>
|
| 574 |
{% endblock %}
|