import os from flask import current_app, request, jsonify, render_template, flash, redirect, url_for from .common import main_bp, get_db_connection, login_required, current_user from processing import resize_image_if_needed, call_nim_ocr_api, extract_question_number_from_ocr_result from strings import ROUTE_SAVE_QUESTIONS, ROUTE_EXTRACT_QUESTION_NUMBER, ROUTE_EXTRACT_ALL_QUESTION_NUMBERS, METHOD_POST import requests import json from nvidia_prompts import BIOLOGY_PROMPT_TEMPLATE, CHEMISTRY_PROMPT_TEMPLATE, PHYSICS_PROMPT_TEMPLATE, MATHEMATICS_PROMPT_TEMPLATE, GENERAL_CLASSIFICATION_PROMPT NVIDIA_API_KEY = os.getenv("NVIDIA_API_KEY") NVIDIA_NIM_AVAILABLE = bool(NVIDIA_API_KEY) def get_nvidia_prompt(subject, input_questions): if not subject or subject.lower() == 'auto': return GENERAL_CLASSIFICATION_PROMPT.format(input_questions=input_questions) if subject.lower() == 'biology': return BIOLOGY_PROMPT_TEMPLATE.format(input_questions=input_questions) if subject.lower() == 'chemistry': return CHEMISTRY_PROMPT_TEMPLATE.format(input_questions=input_questions) if subject.lower() == 'physics': return PHYSICS_PROMPT_TEMPLATE.format(input_questions=input_questions) if subject.lower() == 'mathematics': return MATHEMATICS_PROMPT_TEMPLATE.format(input_questions=input_questions) # Default to general prompt for unknown subjects return GENERAL_CLASSIFICATION_PROMPT.format(input_questions=input_questions) @main_bp.route('/get_topic_suggestions', methods=['POST']) @login_required def get_topic_suggestions(): data = request.json question_text, image_id, subject = data.get('question_text'), data.get('image_id'), data.get('subject') # Subject is now optional - will use general prompt if not provided if not question_text and image_id: try: conn = get_db_connection() # First check if we already have question_text cached q_row = conn.execute('SELECT question_text FROM questions WHERE image_id = ?', (image_id,)).fetchone() if q_row and q_row['question_text']: question_text = q_row['question_text'] else: # Get image directly (works even if questions not saved yet) img_row = conn.execute('SELECT processed_filename, session_id FROM images WHERE id = ?', (image_id,)).fetchone() if img_row and img_row['processed_filename']: image_path = os.path.join(current_app.config['PROCESSED_FOLDER'], img_row['processed_filename']) if os.path.exists(image_path): question_text = " ".join(item['text_prediction']['text'] for item in call_nim_ocr_api(resize_image_if_needed(image_path))['data'][0]['text_detections']) # Cache the OCR result if question record exists if q_row: conn.execute('UPDATE questions SET question_text = ? WHERE image_id = ?', (question_text, image_id)) conn.commit() conn.close() except Exception as e: return jsonify({'error': f"OCR failed: {str(e)}"}), 500 if not question_text: return jsonify({'error': 'Could not obtain question text.'}), 400 prompt_content = get_nvidia_prompt(subject, f"1. {question_text}") if not NVIDIA_API_KEY: return jsonify({'error': 'NVIDIA_API_KEY not set'}), 500 try: 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) res.raise_for_status() content = res.json()['choices'][0]['message']['content'] if "```json" in content: content = content.split("```json")[1].split("```")[0].strip() elif "```" in content: content = content.split("```")[1].split("```")[0].strip() parsed_data = json.loads(content) suggestions = [] detected_subject = subject # Default to input subject other_subjects = [] if parsed_data.get('data'): item = parsed_data['data'][0] # Extract detected subject from AI response detected_subject = item.get('subject', subject) or subject # Extract other possible subjects other_subjects = item.get('other_possible_subjects', []) if isinstance(other_subjects, str): other_subjects = [other_subjects] primary = item.get('chapter_title') if primary and primary != 'Unclassified': suggestions.append(primary) if 'other_possible_chapters' in item: others = item['other_possible_chapters'] if isinstance(others, list): suggestions.extend([c for c in others if c and c != 'Unclassified']) # Always return at least one suggestion with fallback if not suggestions: fallback_topics = { 'biology': ['Cell: The Unit of Life', 'Biomolecules', 'Human Reproduction', 'Molecular Basis of Inheritance', 'Ecology'], 'chemistry': ['Organic Chemistry – Some Basic Principles and Techniques (GOC)', 'Chemical Bonding and Molecular Structure', 'Thermodynamics', 'Electrochemistry'], 'physics': ['Laws of Motion', 'Work, Energy and Power', 'Current Electricity', 'Electromagnetic Induction'], 'mathematics': ['Calculus', 'Algebra', 'Coordinate Geometry', 'Probability'] } subj_key = (detected_subject or 'biology').lower() suggestions = fallback_topics.get(subj_key, ['Unclassified']) return jsonify({ 'success': True, 'suggestions': suggestions[:5], 'subject': detected_subject, 'other_possible_subjects': other_subjects, 'full_response': parsed_data }) except Exception as e: return jsonify({ 'success': True, 'suggestions': ['Unclassified'], 'subject': subject or 'Biology', 'other_possible_subjects': [], 'error': str(e) }) @main_bp.route('/get_topic_suggestions_batch', methods=['POST']) @login_required def get_topic_suggestions_batch(): """Batch endpoint for getting topic suggestions for multiple images at once. Requires a subject to be specified. Processes up to 8 questions in a single API call.""" data = request.json image_ids = data.get('image_ids', []) subject = data.get('subject') current_app.logger.info(f"[BATCH] Received request for {len(image_ids)} images, subject={subject}") if not image_ids: return jsonify({'error': 'No image_ids provided'}), 400 if not subject or subject.lower() == 'auto': return jsonify({'error': 'Subject must be specified for batch requests'}), 400 if len(image_ids) > 8: return jsonify({'error': 'Maximum 8 images per batch'}), 400 if not NVIDIA_API_KEY: return jsonify({'error': 'NVIDIA_API_KEY not set'}), 500 try: conn = get_db_connection() questions_data = [] # List of {image_id, index, question_text} for idx, image_id in enumerate(image_ids): # Verify ownership 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() if not owner or owner['user_id'] != current_user.id: current_app.logger.warning(f"[BATCH] Skipping image {image_id}: unauthorized") continue # Skip unauthorized images question_text = None # First check if we already have question_text cached q_row = conn.execute('SELECT question_text FROM questions WHERE image_id = ?', (image_id,)).fetchone() if q_row and q_row['question_text']: question_text = q_row['question_text'] current_app.logger.debug(f"[BATCH] Image {image_id}: using cached question_text") else: # Get image directly img_row = conn.execute('SELECT processed_filename FROM images WHERE id = ?', (image_id,)).fetchone() if img_row and img_row['processed_filename']: image_path = os.path.join(current_app.config['PROCESSED_FOLDER'], img_row['processed_filename']) if os.path.exists(image_path): try: current_app.logger.info(f"[BATCH] Image {image_id}: running OCR on {img_row['processed_filename']}") question_text = " ".join(item['text_prediction']['text'] for item in call_nim_ocr_api(resize_image_if_needed(image_path))['data'][0]['text_detections']) # Cache the OCR result if question record exists if q_row: conn.execute('UPDATE questions SET question_text = ? WHERE image_id = ?', (question_text, image_id)) conn.commit() except Exception as ocr_err: current_app.logger.error(f"[BATCH] Image {image_id}: OCR failed - {ocr_err}") else: current_app.logger.warning(f"[BATCH] Image {image_id}: file not found at {image_path}") else: current_app.logger.warning(f"[BATCH] Image {image_id}: no processed_filename in DB") if question_text: questions_data.append({'image_id': image_id, 'index': idx + 1, 'question_text': question_text}) else: current_app.logger.warning(f"[BATCH] Image {image_id}: no question_text obtained") conn.close() current_app.logger.info(f"[BATCH] Got question text for {len(questions_data)}/{len(image_ids)} images") if not questions_data: return jsonify({'error': 'Could not obtain question text for any images'}), 400 # Build multi-question prompt input_questions = "\n".join(f"{q['index']}. {q['question_text']}" for q in questions_data) prompt_content = get_nvidia_prompt(subject, input_questions) current_app.logger.info(f"[BATCH] Sending {len(questions_data)} questions to NVIDIA API") current_app.logger.debug(f"[BATCH] Prompt preview: {input_questions[:500]}...") # Make single API call for all questions 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": 2048, "stream": False}, timeout=60 ) res.raise_for_status() content = res.json()['choices'][0]['message']['content'] current_app.logger.info(f"[BATCH] NVIDIA API response length: {len(content)} chars") current_app.logger.debug(f"[BATCH] Raw response: {content[:1000]}...") # Parse JSON from response if "```json" in content: content = content.split("```json")[1].split("```")[0].strip() elif "```" in content: content = content.split("```")[1].split("```")[0].strip() parsed_data = json.loads(content) current_app.logger.info(f"[BATCH] Parsed data has {len(parsed_data.get('data', []))} items") # Build results for each image results = {} fallback_topics = { 'biology': ['Cell: The Unit of Life', 'Biomolecules', 'Human Reproduction'], 'chemistry': ['Organic Chemistry – Some Basic Principles and Techniques (GOC)', 'Chemical Bonding and Molecular Structure'], 'physics': ['Laws of Motion', 'Work, Energy and Power', 'Current Electricity'], 'mathematics': ['Calculus', 'Algebra', 'Coordinate Geometry'] } if parsed_data.get('data'): data_items = parsed_data['data'] current_app.logger.info(f"[BATCH] AI returned {len(data_items)} items, we have {len(questions_data)} questions") # Try to match by index first, then fall back to order-based matching matched_by_index = 0 for item in data_items: item_index = item.get('index', 0) current_app.logger.debug(f"[BATCH] Processing item with index={item_index}: {item}") # Find the matching question by our sequential index matching_q = next((q for q in questions_data if q['index'] == item_index), None) if matching_q: matched_by_index += 1 suggestions = [] primary = item.get('chapter_title') if primary and primary != 'Unclassified': suggestions.append(primary) if 'other_possible_chapters' in item: others = item['other_possible_chapters'] if isinstance(others, list): suggestions.extend([c for c in others if c and c != 'Unclassified']) if not suggestions: suggestions = fallback_topics.get(subject.lower(), ['Unclassified']) results[matching_q['image_id']] = { 'success': True, 'suggestions': suggestions[:5], 'subject': subject, 'other_possible_subjects': [] } current_app.logger.info(f"[BATCH] Image {matching_q['image_id']}: matched by index, suggestions={suggestions[:3]}") # If index matching failed, try order-based matching if matched_by_index == 0 and len(data_items) > 0: current_app.logger.warning(f"[BATCH] Index matching failed, trying order-based matching") for i, item in enumerate(data_items): if i < len(questions_data): q = questions_data[i] if q['image_id'] not in results: suggestions = [] primary = item.get('chapter_title') if primary and primary != 'Unclassified': suggestions.append(primary) if 'other_possible_chapters' in item: others = item['other_possible_chapters'] if isinstance(others, list): suggestions.extend([c for c in others if c and c != 'Unclassified']) if not suggestions: suggestions = fallback_topics.get(subject.lower(), ['Unclassified']) results[q['image_id']] = { 'success': True, 'suggestions': suggestions[:5], 'subject': subject, 'other_possible_subjects': [] } current_app.logger.info(f"[BATCH] Image {q['image_id']}: matched by order, suggestions={suggestions[:3]}") # Fill in any missing results for q in questions_data: if q['image_id'] not in results: current_app.logger.warning(f"[BATCH] Image {q['image_id']}: using fallback (no match in API response)") results[q['image_id']] = { 'success': True, 'suggestions': fallback_topics.get(subject.lower(), ['Unclassified']), 'subject': subject, 'other_possible_subjects': [] } return jsonify({'success': True, 'results': results}) except Exception as e: # Return error but with fallback results for each image fallback_result = { 'success': True, 'suggestions': ['Unclassified'], 'subject': subject, 'other_possible_subjects': [], 'error': str(e) } return jsonify({ 'success': False, 'error': str(e), 'results': {img_id: fallback_result for img_id in image_ids} }) @main_bp.route('/classified/update_single', methods=['POST']) @login_required def update_question_classification_single(): data = request.json image_id, subject, chapter = data.get('image_id'), data.get('subject'), data.get('chapter') if not image_id: return jsonify({'error': 'Image ID is required'}), 400 try: conn = get_db_connection() # Get image info and verify ownership img_info = conn.execute(""" SELECT i.session_id, s.user_id FROM images i JOIN sessions s ON i.session_id = s.id WHERE i.id = ? """, (image_id,)).fetchone() if not img_info or img_info['user_id'] != current_user.id: conn.close(); return jsonify({'error': 'Unauthorized'}), 403 # Check if question row exists existing = conn.execute('SELECT id FROM questions WHERE image_id = ?', (image_id,)).fetchone() if existing: conn.execute('UPDATE questions SET subject = ?, chapter = ? WHERE image_id = ?', (subject, chapter, image_id)) else: # Create question row if it doesn't exist conn.execute(''' INSERT INTO questions (session_id, image_id, question_number, subject, chapter, status) VALUES (?, ?, '', ?, ?, 'unattempted') ''', (img_info['session_id'], image_id, subject, chapter)) conn.commit(); conn.close() return jsonify({'success': True}) except Exception as e: current_app.logger.error(f"Error updating classification: {e}") return jsonify({'error': str(e)}), 500 @main_bp.route('/question_entry_v2/') @login_required def question_entry_v2(session_id): conn = get_db_connection() session_data = conn.execute('SELECT original_filename, subject, tags, notes FROM sessions WHERE id = ? AND user_id = ?', (session_id, current_user.id)).fetchone() if not session_data: conn.close(); flash("Session not found or you don't have permission to access it.", "warning"); return redirect(url_for('dashboard.dashboard')) images = conn.execute("""SELECT i.id, i.processed_filename, i.note_filename, i.include_note_in_pdf, q.question_number, q.status, q.marked_solution, q.actual_solution FROM images i LEFT JOIN questions q ON i.id = q.image_id WHERE i.session_id = ? AND i.image_type = 'cropped' ORDER BY i.id""", (session_id,)).fetchall() classified_count = conn.execute("""SELECT COUNT(*) as count FROM images i LEFT JOIN questions q ON i.id = q.image_id WHERE i.session_id = ? AND i.image_type = 'cropped' AND q.subject IS NOT NULL AND q.chapter IS NOT NULL""", (session_id,)).fetchone()['count'] conn.close() if not images: return "No questions were created from the PDF. Please go back and draw crop boxes.", 404 return render_template('question_entry_v2.html', session_id=session_id, images=[dict(img) for img in images], session_data=dict(session_data) if session_data else {}, classified_count=classified_count, total_questions=len(images), nvidia_nim_available=NVIDIA_NIM_AVAILABLE) @main_bp.route(ROUTE_SAVE_QUESTIONS, methods=[METHOD_POST]) @login_required def save_questions(): data = request.json session_id, questions = data['session_id'], data['questions'] conn = get_db_connection() session_owner = conn.execute('SELECT user_id FROM sessions WHERE id = ?', (session_id,)).fetchone() if not session_owner or session_owner['user_id'] != current_user.id: conn.close(); return jsonify({'error': 'Unauthorized'}), 403 conn.execute('UPDATE sessions SET subject = ?, tags = ?, notes = ? WHERE id = ?', (data.get('pdf_subject', ''), data.get('pdf_tags', ''), data.get('pdf_notes', ''), session_id)) # Use UPSERT to preserve subject and chapter data from classification for q in questions: existing = conn.execute('SELECT id, subject, chapter FROM questions WHERE image_id = ?', (q['image_id'],)).fetchone() if existing: # Update existing, preserve subject and chapter conn.execute(''' UPDATE questions SET question_number = ?, status = ?, marked_solution = ?, actual_solution = ?, tags = ? WHERE image_id = ? ''', (q['question_number'], q['status'], q.get('marked_solution', ''), q.get('actual_solution', ''), data.get('pdf_tags', ''), q['image_id'])) else: # Insert new conn.execute(''' INSERT INTO questions (session_id, image_id, question_number, subject, status, marked_solution, actual_solution, time_taken, tags) VALUES (?, ?, ?, '', ?, ?, ?, ?, ?) ''', (session_id, q['image_id'], q['question_number'], q['status'], q.get('marked_solution', ''), q.get('actual_solution', ''), q.get('time_taken', ''), data.get('pdf_tags', ''))) conn.commit(); conn.close() return jsonify({'success': True, 'message': 'Questions saved successfully.'}) @main_bp.route('/autosave_question', methods=['POST']) @login_required def autosave_question(): """Auto-save a single question's data without affecting other questions.""" data = request.json session_id = data.get('session_id') question = data.get('question', {}) if not session_id or not question.get('image_id'): return jsonify({'error': 'Missing session_id or image_id'}), 400 conn = get_db_connection() try: # Verify session ownership session_owner = conn.execute('SELECT user_id FROM sessions WHERE id = ?', (session_id,)).fetchone() if not session_owner or session_owner['user_id'] != current_user.id: conn.close() return jsonify({'error': 'Unauthorized'}), 403 image_id = question['image_id'] question_number = question.get('question_number', '') status = question.get('status', 'unattempted') marked_solution = question.get('marked_solution', '') actual_solution = question.get('actual_solution', '') # Check if question already exists existing = conn.execute('SELECT id, subject, chapter, tags FROM questions WHERE image_id = ?', (image_id,)).fetchone() if existing: # Update existing question, preserving subject and chapter conn.execute(''' UPDATE questions SET question_number = ?, status = ?, marked_solution = ?, actual_solution = ? WHERE image_id = ? ''', (question_number, status, marked_solution, actual_solution, image_id)) else: # Insert new question conn.execute(''' INSERT INTO questions (session_id, image_id, question_number, subject, status, marked_solution, actual_solution, time_taken, tags) VALUES (?, ?, ?, '', ?, ?, ?, '', '') ''', (session_id, image_id, question_number, status, marked_solution, actual_solution)) conn.commit() conn.close() return jsonify({'success': True}) except Exception as e: conn.close() current_app.logger.error(f"Auto-save question error: {e}") return jsonify({'error': str(e)}), 500 @main_bp.route('/autosave_session_metadata', methods=['POST']) @login_required def autosave_session_metadata(): """Auto-save session metadata (PDF subject, tags, notes).""" data = request.json session_id = data.get('session_id') if not session_id: return jsonify({'error': 'Missing session_id'}), 400 conn = get_db_connection() try: # Verify session ownership session_owner = conn.execute('SELECT user_id FROM sessions WHERE id = ?', (session_id,)).fetchone() if not session_owner or session_owner['user_id'] != current_user.id: conn.close() return jsonify({'error': 'Unauthorized'}), 403 # Update session metadata conn.execute(''' UPDATE sessions SET subject = ?, tags = ?, notes = ? WHERE id = ? ''', ( data.get('pdf_subject', ''), data.get('pdf_tags', ''), data.get('pdf_notes', ''), session_id )) conn.commit() conn.close() return jsonify({'success': True}) except Exception as e: conn.close() current_app.logger.error(f"Auto-save metadata error: {e}") return jsonify({'error': str(e)}), 500 @main_bp.route(ROUTE_EXTRACT_QUESTION_NUMBER, methods=[METHOD_POST]) @login_required def extract_question_number(): if not NVIDIA_NIM_AVAILABLE: return jsonify({'error': 'NVIDIA NIM feature is not available.'}), 400 data = request.json image_id = data.get('image_id') if not image_id: return jsonify({'error': 'Missing image_id parameter'}), 400 try: conn = get_db_connection() image_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() if not image_owner or image_owner['user_id'] != current_user.id: conn.close(); return jsonify({'error': 'Unauthorized'}), 403 image_info = conn.execute('SELECT processed_filename FROM images WHERE id = ?', (image_id,)).fetchone() conn.close() if not image_info or not image_info['processed_filename']: return jsonify({'error': 'Image not found or not processed'}), 404 image_path = os.path.join(current_app.config['PROCESSED_FOLDER'], image_info['processed_filename']) if not os.path.exists(image_path): return jsonify({'error': 'Image file not found on disk'}), 404 image_bytes = resize_image_if_needed(image_path) ocr_result = call_nim_ocr_api(image_bytes) question_number = extract_question_number_from_ocr_result(ocr_result) return jsonify({'success': True, 'question_number': question_number, 'image_id': image_id}) except Exception as e: return jsonify({'error': f'Failed to extract question number: {str(e)}'}), 500 @main_bp.route(ROUTE_EXTRACT_ALL_QUESTION_NUMBERS, methods=[METHOD_POST]) @login_required def extract_all_question_numbers(): if not NVIDIA_NIM_AVAILABLE: return jsonify({'error': 'NVIDIA NIM feature is not available.'}), 400 data = request.json session_id = data.get('session_id') if not session_id: return jsonify({'error': 'Missing session_id parameter'}), 400 try: conn = get_db_connection() session_owner = conn.execute('SELECT user_id FROM sessions WHERE id = ?', (session_id,)).fetchone() if not session_owner or session_owner['user_id'] != current_user.id: conn.close(); return jsonify({'error': 'Unauthorized'}), 403 images = conn.execute("SELECT id, processed_filename FROM images WHERE session_id = ? AND image_type = 'cropped' ORDER BY id", (session_id,)).fetchall() conn.close() if not images: return jsonify({'error': 'No cropped images found in session'}), 404 results, errors = [], [] MAX_CONCURRENT_REQUESTS = 5 processed_count = 0 for image in images: if processed_count >= MAX_CONCURRENT_REQUESTS: import time; time.sleep(1); processed_count = 0 try: image_id, processed_filename = image['id'], image['processed_filename'] if not processed_filename: errors.append({'image_id': image_id, 'error': 'Image not processed'}); continue image_path = os.path.join(current_app.config['PROCESSED_FOLDER'], processed_filename) if not os.path.exists(image_path): errors.append({'image_id': image_id, 'error': 'Image file not found on disk'}); continue image_bytes = resize_image_if_needed(image_path) ocr_result = call_nim_ocr_api(image_bytes) question_number = extract_question_number_from_ocr_result(ocr_result) results.append({'image_id': image_id, 'question_number': question_number}) processed_count += 1 except Exception as e: errors.append({'image_id': image['id'], 'error': str(e)}) return jsonify({'success': True, 'results': results, 'errors': errors}) except Exception as e: return jsonify({'error': f'Failed to extract question numbers: {str(e)}'}), 500 @main_bp.route('/delete_question/', methods=['DELETE']) @login_required def delete_question(image_id): try: conn = get_db_connection() image_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() if not image_owner or image_owner['user_id'] != current_user.id: conn.close(); return jsonify({'error': 'Unauthorized'}), 403 image_info = conn.execute('SELECT session_id, filename, processed_filename FROM images WHERE id = ?', (image_id,)).fetchone() if not image_info: conn.close(); return jsonify({'error': 'Question not found'}), 404 conn.execute('DELETE FROM questions WHERE image_id = ?', (image_id,)) conn.execute('DELETE FROM images WHERE id = ?', (image_id,)) conn.commit(); conn.close() return jsonify({'success': True}) except Exception as e: return jsonify({'error': str(e)}), 500