t commited on
Commit
d3d811e
·
1 Parent(s): 24388f8

new features

Browse files
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
- sessions_rows = conn.execute("""
138
- SELECT s.id, s.created_at, s.original_filename, s.persist, s.name, s.session_type,
139
- COUNT(CASE WHEN i.image_type = 'original' THEN 1 END) as page_count,
140
- COUNT(CASE WHEN i.image_type = 'cropped' THEN 1 END) as question_count
141
- FROM sessions s
142
- LEFT JOIN images i ON s.id = i.session_id
143
- WHERE s.user_id = ? AND (s.session_type IS NULL OR s.session_type != 'final_pdf')
144
- GROUP BY s.id, s.created_at, s.original_filename, s.persist, s.name, s.session_type
145
- ORDER BY s.created_at DESC
146
- """, (current_user.id,)).fetchall()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- session_owner = conn.execute('SELECT user_id FROM sessions WHERE id = ?', (session_id,)).fetchone()
178
- if not session_owner or session_owner['user_id'] != current_user.id:
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
- # Delete associated files
 
 
 
 
 
 
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
- # Only fetch NeetPrep questions if the feature is enabled for the user
320
- if current_user.neetprep_enabled:
 
 
 
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
- img_path = os.path.join(current_app.config['TEMP_FOLDER'], f"neetprep_{q['id']}.jpg")
 
328
  imgkit.from_string(html_content, img_path, options={'width': 800})
329
  all_questions.append({
330
- 'image_path': img_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
- # Always fetch the user's own classified questions if topics are selected or if it's a quiz
348
- if topics or pdf_type == 'quiz':
349
- # If no topics are selected for a quiz/selection, this should not run or fetch all
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({'image_path': os.path.join(current_app.config['PROCESSED_FOLDER'], image_info['processed_filename']),'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']}})
 
 
 
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
- # For 'all' type, also include user's classified questions
370
- if pdf_type == 'all':
371
- classified_questions_from_db = conn.execute("""
372
- SELECT q.* FROM questions q JOIN sessions s ON q.session_id = s.id
373
- WHERE s.user_id = ? AND q.subject IS NOT NULL AND q.chapter IS NOT NULL
374
- """, (current_user.id,)).fetchall()
375
- for q in classified_questions_from_db:
376
- image_info = conn.execute("SELECT processed_filename FROM images WHERE id = ?", (q['image_id'],)).fetchone()
377
- if image_info and image_info['processed_filename']:
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 other chapters are also plausible, list up to 2 alternatives in 'other_possible_chapters'.
47
- 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'.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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": <integer matching question number>,
56
- "subject": "Biology",
57
- "chapter_index": <chapter number or 0>,
58
- "chapter_title": "<exact chapter title from list or 'Unclassified'>",
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 other chapters are also plausible, list up to 2 alternatives in 'other_possible_chapters'.
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 other chapters are also plausible, list up to 2 alternatives in 'other_possible_chapters'.
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 other chapters are also plausible, list up to 2 alternatives in 'other_possible_chapters'.
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
- return jsonify(dict(pdf)) if pdf else jsonify({'error': 'PDF not found'}), 404
 
 
 
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'], images_per_page=ipp, 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")
 
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
- return None
 
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
- if not subject: return jsonify({'error': 'Subject is required'}), 400
26
  if not question_text and image_id:
27
  try:
28
  conn = get_db_connection()
29
- row = conn.execute('SELECT question_text, processed_filename, i.session_id FROM questions q JOIN images i ON q.image_id = i.id WHERE i.id = ?', (image_id,)).fetchone()
30
- if row:
31
- if row['question_text']: question_text = row['question_text']
32
- else:
33
- image_path = os.path.join(current_app.config['PROCESSED_FOLDER'], row['processed_filename'])
 
 
 
 
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
- conn.execute('UPDATE questions SET question_text = ? WHERE image_id = ?', (question_text, image_id))
37
- conn.commit()
 
 
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
- data = json.loads(content)
 
51
  suggestions = []
52
- if data.get('data'):
53
- suggestions.append(data['data'][0].get('chapter_title', 'Unclassified'))
54
- if 'other_possible_chapters' in data['data'][0]:
55
- others = data['data'][0]['other_possible_chapters']
56
- if isinstance(others, list): suggestions.extend(others)
57
- return jsonify({'success': True, 'suggestions': suggestions, 'full_response': data})
58
- except Exception as e: return jsonify({'error': str(e)}), 500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 == 'color_rm' %}
 
 
 
 
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
- .name-column {
8
- word-wrap: break-word;
9
- white-space: normal;
10
- max-width: 250px;
11
- }
12
- @media (max-width: 768px) {
13
- .name-column {
14
- max-width: 25%;
15
- }
16
  }
17
- .table-responsive {
18
- overflow-x: auto;
 
 
 
 
 
19
  }
20
- th:first-child, td:first-child {
21
- position: sticky;
22
- left: 0;
23
- z-index: 2;
24
- background-color: #212529;
25
  }
26
- th:nth-child(2), td:nth-child(2) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  position: sticky;
28
- left: 40px;
29
- z-index: 2;
30
  background-color: #212529;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  }
32
  </style>
33
  {% endblock %}
34
 
35
  {% block content %}
36
- <div class="container-fluid mt-5" style="width: 90%; margin: auto;">
37
- <div class="card bg-dark text-white">
38
- <div class="card-header">
39
- <h2>NeetPrep Incorrect Question Manager</h2>
 
 
 
 
 
 
 
40
  </div>
41
- <div class="card-body">
 
 
 
 
42
  {% if neetprep_enabled %}
43
- <div class="d-flex flex-wrap gap-2 align-items-center mb-3">
44
- <button id="sync-btn" class="btn btn-primary">Sync/Fetch Latest Incorrect Questions</button>
45
- <div class="form-check">
46
- <input class="form-check-input" type="checkbox" id="force-sync-checkbox">
47
- <label class="form-check-label" for="force-sync-checkbox">
48
- Force full re-sync
49
- </label>
 
 
 
 
 
 
 
 
 
 
 
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="alert alert-info" role="alert">
58
- NeetPrep Sync is currently disabled. You can enable it in <a href="{{ url_for('settings.settings') }}" class="alert-link">Settings</a>.
 
 
 
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
- <h4>Generate PDF</h4>
81
- <form id="generate-pdf-form">
82
- <div class="d-flex flex-wrap gap-2 mb-3">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  {% if neetprep_enabled %}
84
- <button type="submit" name="pdf_type" value="all" class="btn btn-success">Generate PDF of All Incorrect Questions</button>
85
- <a href="/neetprep/edit" class="btn btn-secondary">Edit Synced Questions</a>
86
  {% endif %}
87
- <button type="submit" name="pdf_type" value="quiz" class="btn btn-primary">Start Quiz</button>
88
- <a href="/classified/edit" class="btn btn-secondary">Edit Classified Questions</a>
 
89
  </div>
 
90
 
91
- <h5>Or, Generate by Topic:</h5>
92
- <div class="table-responsive" style="max-height: 400px;">
93
- <table class="table table-sm table-hover table-striped">
94
- <thead>
95
- <tr>
96
- <th scope="col" style="width: 3%;">S.No.</th>
97
- <th scope="col" style="width: 3%;"></th>
98
- <th scope="col" class="name-column">Topic</th>
99
- {% if neetprep_enabled %}<th>NeetPrep Questions</th>{% endif %}
100
- <th>My Questions</th>
101
- </tr>
102
- </thead>
103
- <tbody id="topic-list">
104
- {% if topics %}
105
- {% for item in topics %}
106
- <tr>
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
- </tbody>
120
- </table>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 input[type="checkbox"]:checked')).map(cb => cb.value);
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; // Stop the rest of the function
229
  }
230
 
231
- // Existing logic for PDF generation
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 input[type="checkbox"]:checked')).map(cb => cb.value);
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
- console.log("Payload sent to server:", payload);
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
- const link = document.createElement('a');
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
- <a href="/neetprep" class="btn btn-outline-light">Back to NeetPrep Home</a>
 
 
 
 
 
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>{{ q.question_text_plain }}</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
- </form>
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') saveChanges();
 
 
 
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.stretched-link');
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">Manual Classification Setup</h5>
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
- <label class="form-label">Select Subject</label>
288
- <select id="manual_subject" class="form-select bg-dark text-white border-secondary">
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">Question Range (e.g., "1-5, 8, 10-12")</label>
297
- <input type="text" id="manual_range" class="form-control bg-dark text-white border-secondary" placeholder="Leave empty for all questions">
298
- <small class="text-muted">Enter ranges like 1-5 or comma separated values.</small>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
299
  </div>
300
  </div>
301
- <div class="modal-footer">
302
- <button type="button" class="btn btn-primary" onclick="startManualClassification()">Start Classification</button>
 
 
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
- <h5 class="modal-title">Classify Question <span id="topic_q_num"></span></h5>
 
 
 
 
 
 
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: 200px; object-fit: contain;">
319
  </div>
 
 
320
  <div class="mb-3">
321
- <label class="form-label">Subject: <span id="topic_subject_display" class="fw-bold text-info"></span></label>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
322
  </div>
323
- <div class="mb-3">
324
- <label class="form-label">Chapter / Topic</label>
325
- <div class="input-group">
326
- <input type="text" id="topic_input" class="form-control bg-dark text-white border-secondary" placeholder="Enter topic...">
327
- <button class="btn btn-outline-info" type="button" id="btn_get_suggestion" onclick="getAiSuggestion()">
 
 
328
  <span class="spinner-border spinner-border-sm d-none" role="status"></span>
329
- Get AI Suggestion
330
  </button>
331
  </div>
332
  </div>
333
- <div id="ai_suggestion_container" class="d-none mt-2">
334
- <label class="form-label text-muted small">AI Suggestions:</label>
 
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 d-flex justify-content-between">
340
- <button type="button" class="btn btn-secondary" onclick="prevTopicQuestion()">Previous</button>
341
- <div>
342
- <span id="topic_progress" class="me-3 text-muted"></span>
343
- <button type="button" class="btn btn-primary" onclick="nextTopicQuestion()">Next</button>
344
- </div>
 
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(); // Stores promises or results: imageId -> {suggestions: [], status: 'pending'|'done'}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
986
 
987
  function parseRange(rangeStr, maxVal) {
988
  const indices = new Set();
@@ -1004,61 +1368,219 @@
1004
  }
1005
 
1006
  function startManualClassification() {
1007
- manualSubject = document.getElementById('manual_subject').value;
1008
- const rangeStr = document.getElementById('manual_range').value;
1009
-
1010
- if (rangeStr.trim() === "") {
1011
- manualQuestionsList = Array.from({length: numImages}, (_, i) => i);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1012
  } else {
1013
- manualQuestionsList = parseRange(rangeStr, numImages);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1014
  }
1015
 
1016
  if (manualQuestionsList.length === 0) {
1017
- alert("No valid questions selected in range.");
1018
  return;
1019
  }
1020
 
1021
  currentManualIndex = 0;
1022
- suggestionCache.clear(); // Clear cache on new run
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- // Trigger prefetch for the first few questions
1033
- prefetchSuggestions(0, 2);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 (!suggestionCache.has(imageId)) {
1043
- // Initiate fetch and store the promise
1044
- const promise = fetch('/get_topic_suggestions', {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1045
  method: 'POST',
1046
  headers: { 'Content-Type': 'application/json' },
1047
- body: JSON.stringify({ image_id: imageId, subject: manualSubject })
1048
  })
1049
  .then(res => res.json())
1050
  .then(data => {
1051
- if (data.success) {
1052
- return { status: 'done', suggestions: data.suggestions || [data.chapter_title] };
1053
- } else {
1054
- throw new Error(data.error);
1055
  }
 
1056
  })
1057
- .catch(err => ({ status: 'error', error: err.message }));
1058
-
1059
- suggestionCache.set(imageId, promise);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- document.getElementById('topic_subject_display').innerText = manualSubject;
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> Get AI Suggestion`;
1099
-
1100
- // Check cache for instant load (optional: auto-show if cached?)
1101
- // Let's stick to user clicking for now, or we can auto-load.
1102
- // The user asked "auto get multiple ai suggestions". So we should try to show them if available.
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
- await saveCurrentTopic();
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
- async function saveCurrentTopic() {
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
- try {
1153
- await fetch('/classified/update_single', {
1154
- method: 'POST',
1155
- headers: { 'Content-Type': 'application/json' },
1156
- body: JSON.stringify({
1157
- image_id: imageId,
1158
- subject: manualSubject,
1159
- chapter: topic
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
- // Should be covered by prefetch, but fallback just in case
 
 
 
 
 
1186
  const promise = fetch('/get_topic_suggestions', {
1187
  method: 'POST',
1188
  headers: { 'Content-Type': 'application/json' },
1189
- body: JSON.stringify({ image_id: imageId, subject: manualSubject })
1190
  })
1191
  .then(res => res.json())
1192
  .then(data => {
1193
- if (data.success) return { status: 'done', suggestions: data.suggestions || [data.chapter_title] };
 
 
 
 
 
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; /* Fill all available space between progress and buttons */
43
- min-height: 0; /* CRITICAL: Allows this box to shrink if image is huge */
44
  display: flex;
45
  justify-content: center;
46
  align-items: center;
47
  padding: 5px;
48
  position: relative;
49
- overflow: hidden; /* Cut off anything spilling out */
50
  }
51
 
52
  .question-image {
53
  max-width: 100%;
54
- max-height: 100%; /* Force image to fit INSIDE the .question-display box */
55
  width: auto;
56
  height: auto;
57
- object-fit: contain; /* Maintain aspect ratio, never crop */
58
  border-radius: 6px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  }
60
 
61
  /* --- 3. BOTTOM CONTROLS (Fixed Height) --- */
62
  .quiz-controls {
63
- flex-shrink: 0; /* Never shrink buttons */
64
- height: 70px; /* Fixed height for stability */
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%; /* Only take up 30% of container when open */
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
- // Path Fix
177
- let path = q.image_path;
178
- if(path.includes('/processed/')) path = `/neetprep/processed/${path.split('/processed/')[1]}`;
179
- else if(path.includes('/tmp/')) path = `/neetprep/tmp/${path.split('/tmp/')[1]}`;
180
- img.src = path;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- if(questions.length) loadQ(0);
 
 
 
 
 
 
 
 
 
 
 
 
 
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 %}