Report-Generator / templates /question_entry_v2.html
root
feat: add session duplication as collection and flatten question entry UI
4d801c0
{% extends "base.html" %}
{% block title %}Enter Question Details (V2){% endblock %}
{% block head %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/5.3.1/fabric.min.js"></script>
<style>
/* === UNIFIED DESIGN SYSTEM === */
:root {
--bg-dark: #212529;
--bg-card: #2b3035;
--bg-elevated: #343a40;
--bg-hover: #3d444b;
--border-subtle: #495057;
--border-muted: #6c757d;
--text-primary: #e9ecef;
--text-muted: #adb5bd;
--accent-primary: #0d6efd;
--accent-info: #0dcaf0;
--accent-success: #198754;
--accent-warning: #ffc107;
--accent-danger: #dc3545;
--transition-fast: 0.15s ease;
--transition-normal: 0.25s ease;
--shadow-sm: 0 2px 4px rgba(0,0,0,0.3);
--shadow-md: 0 4px 12px rgba(0,0,0,0.4);
}
.keyboard-hint { font-size: 0.8em; color: var(--text-muted); margin-top: 2px; }
.status-buttons { display: flex; gap: 0.25rem; margin-top: 0.25rem; }
.status-btn {
flex: 1;
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border: 1px solid var(--border-subtle);
background: transparent;
color: #fff;
border-radius: 4px;
cursor: pointer;
transition: all var(--transition-fast);
}
.status-btn.active { background: var(--accent-primary); border-color: var(--accent-primary); }
.status-btn:hover { background: var(--bg-hover); }
.auto-extract-btn { min-width: 120px; }
/* Subject pills */
.subject-pill {
transition: all var(--transition-fast);
border-radius: 4px;
}
.subject-pill.active {
box-shadow: 0 0 8px rgba(255,255,255,0.2);
}
/* Range toggles */
.range-toggle {
transition: all var(--transition-fast);
border-radius: 4px;
}
.range-toggle:hover {
background: var(--bg-hover);
}
.range-toggle.active { box-shadow: 0 0 4px rgba(255,255,255,0.1); }
.range-toggle.active.btn-outline-primary { background: var(--accent-primary); color: #fff; }
.range-toggle.active.btn-outline-warning { background: var(--accent-warning); color: #000; }
.range-toggle.active.btn-outline-danger { background: var(--accent-danger); color: #fff; }
.range-toggle.active.btn-outline-secondary { background: var(--border-muted); color: #fff; }
.range-toggle.active.btn-outline-info { background: var(--accent-info); color: #000; }
/* Range slider styling */
.range-slider-row { background: var(--bg-elevated); border-radius: 4px; padding: 12px; margin-bottom: 10px; transition: all var(--transition-fast); }
.range-slider-row:hover { background: var(--bg-hover); }
.dual-range-container { position: relative; height: 40px; }
.dual-range-track { position: absolute; top: 50%; left: 0; right: 0; height: 8px; background: var(--border-subtle); border-radius: 2px; transform: translateY(-50%); }
.dual-range-highlight { position: absolute; top: 50%; height: 8px; background: var(--accent-primary); border-radius: 2px; transform: translateY(-50%); }
.dual-range-container input[type="range"] { position: absolute; top: 0; left: 0; width: 100%; height: 100%; -webkit-appearance: none; background: transparent; pointer-events: none; }
.dual-range-container input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 24px; background: #fff; border: 2px solid var(--accent-primary); border-radius: 2px; cursor: pointer; pointer-events: auto; box-shadow: var(--shadow-sm); }
.dual-range-container input[type="range"]::-webkit-slider-thumb:hover { background: var(--accent-info); }
.dual-range-container input[type="range"]::-moz-range-thumb { width: 16px; height: 24px; background: #fff; border: 2px solid var(--accent-primary); border-radius: 2px; cursor: pointer; pointer-events: auto; box-shadow: var(--shadow-sm); }
/* Tom Select Dark Theme */
.ts-wrapper .ts-control, .ts-wrapper .ts-control input {
background: var(--bg-dark);
color: #fff;
border-color: var(--border-muted);
}
.ts-dropdown {
background: var(--bg-dark);
color: #fff;
border-color: var(--border-muted);
}
.ts-dropdown .option {
color: #fff;
}
.ts-dropdown .option:hover, .ts-dropdown .active {
background: var(--border-subtle);
}
/* --- UNIFIED NOTE CARD STYLES --- */
.note-card {
background: rgba(13, 202, 240, 0.05);
border: 1px solid rgba(13, 202, 240, 0.2);
border-radius: 4px;
padding: 10px;
margin-bottom: 8px;
transition: all var(--transition-fast);
}
.note-card:hover {
border-color: var(--accent-info);
}
.note-thumbnail {
max-height: 80px;
object-fit: contain;
border-radius: 4px;
border: 1px solid rgba(13, 202, 240, 0.2);
}
.note-actions {
display: flex;
gap: 4px;
margin-top: 6px;
}
.include-pdf-toggle {
cursor: pointer;
}
.include-pdf-toggle input:checked + .form-check-label {
color: var(--accent-info);
}
/* --- UNIFIED BUTTON STYLES --- */
.btn-pill {
border-radius: 4px;
font-weight: 500;
transition: all var(--transition-fast);
}
.btn-pill:hover {
box-shadow: var(--shadow-sm);
}
</style>
{% endblock %}
{% block content %}
<div class="container mt-3 mb-5">
<div class="card bg-dark text-white">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<h1 class="h3 mb-0">Step 3: Enter Question Details</h1>
<div class="keyboard-hint">Shortcuts: Shift+Q for Next | Shift+C (Correct) | Shift+W (Wrong) | Shift+U (Unattempted)</div>
{% if classified_count is defined and total_questions is defined %}
<div class="d-flex align-items-center mt-1">
<span class="badge bg-info">
<i class="bi bi-tags me-1"></i>Classification:
<strong>{{ classified_count }}</strong>/{{ total_questions }} questions
</span>
</div>
{% endif %}
</div>
<div>
<a href="/cropv2/{{ session_id }}/0" class="btn btn-secondary"><i class="bi bi-arrow-left me-1"></i> Go Back to Cropping</a>
</div>
</div>
<div class="card-body">
<fieldset class="mb-4">
<legend class="h5">Upload Answer Key (Optional)</legend>
<div class="mb-3">
<label for="json-upload" class="form-label">Upload a JSON file to auto-fill answers.</label>
<input class="form-control" type="file" id="json-upload" accept=".json">
</div>
</fieldset>
<form id="questions-form">
{% if not nvidia_nim_available %}
<div class="alert alert-warning alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle me-2"></i>
NVIDIA NIM OCR feature is not available. To enable automatic question number extraction, please set the <code>NVIDIA_API_KEY</code> environment variable.
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% else %}
<div class="mb-3">
<button id="auto-extract-all" class="btn btn-primary">
<span class="spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>
<span class="extract-text">Auto Extract All Question Numbers</span>
</button>
<button id="extract-classify-all" class="btn btn-info">
<span class="spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>
<span class="extract-text">Extract & Classify All</span>
</button>
<button type="button" class="btn btn-warning ms-2" data-bs-toggle="modal" data-bs-target="#manualClassificationModal">
<i class="bi bi-list-check"></i> Manual Classification
</button>
</div>
{% endif %}
{% for image in images %}
<fieldset class="row mb-4 border-bottom pb-3" data-question-index="{{ loop.index0 }}" id="question-fieldset-{{ image.id }}">
<input type="hidden" name="image_id_{{ loop.index0 }}" value="{{ image.id }}">
<legend class="h5 col-12 mb-3">Question {{ loop.index }}
<button type="button" class="btn btn-sm btn-outline-danger float-end delete-question-btn" data-image-id="{{ image.id }}" data-index="{{ loop.index0 }}">
<i class="bi bi-trash"></i> Delete
</button>
</legend>
<div class="col-md-3 mb-3 text-center">
<img src="/image/processed/{{ session_id }}/{{ image.processed_filename }}" class="img-fluid rounded mb-2" alt="Cropped Question {{ loop.index }}">
{% if image.note_json %}
<div class="note-card">
<div class="d-flex align-items-center justify-content-center gap-2 py-2 text-success">
<i class="bi bi-check-circle-fill"></i>
<span class="small">Notes saved</span>
</div>
<div class="note-actions flex-wrap justify-content-center">
<div class="form-check form-switch include-pdf-toggle">
<input class="form-check-input" type="checkbox" id="include_note_{{ image.id }}"
{% if image.include_note_in_pdf is none or image.include_note_in_pdf %}checked{% endif %}
onchange="toggleNoteInPdf('{{ image.id }}', this.checked)">
<label class="form-check-label small" for="include_note_{{ image.id }}">In PDF</label>
</div>
<button type="button" class="btn btn-sm btn-outline-info btn-pill" onclick="openNotesModal('{{ image.id }}', '/image/processed/{{ session_id }}/{{ image.processed_filename }}')" title="Edit Note">
<i class="bi bi-pencil"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-danger btn-pill" onclick="deleteNote('{{ image.id }}')" title="Delete Note">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
{% else %}
<button type="button" class="btn btn-sm btn-outline-info btn-pill w-100" onclick="openNotesModal('{{ image.id }}', '/image/processed/{{ session_id }}/{{ image.processed_filename }}')">
<i class="bi bi-pencil-square me-1"></i>Add Revision Notes
</button>
{% endif %}
</div>
<div class="col-md-9">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="question_number_{{ loop.index0 }}">Question Number *</label>
{% if nvidia_nim_available %}
<div class="input-group">
<input type="number" class="form-control" id="question_number_{{ loop.index0 }}" name="question_number_{{ loop.index0 }}" data-image-id="{{ image.id }}" value="{{ image.question_number or '' }}" required>
<button class="btn btn-outline-secondary auto-extract-btn" type="button" data-image-id="{{ image.id }}" data-index="{{ loop.index0 }}">
<span class="extract-spinner spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>
<span class="extract-text">Auto Extract</span>
</button>
</div>
{% else %}
<input type="number" class="form-control" id="question_number_{{ loop.index0 }}" name="question_number_{{ loop.index0 }}" data-image-id="{{ image.id }}" value="{{ image.question_number or '' }}" required>
{% endif %}
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="marked_solution_{{ loop.index0 }}">Your Answer</label>
<input type="text" class="form-control" id="marked_solution_{{ loop.index0 }}" name="marked_solution_{{ loop.index0 }}" value="{{ image.marked_solution or '' }}">
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="actual_solution_{{ loop.index0 }}">Correct Answer</label>
<input type="text" class="form-control" id="actual_solution_{{ loop.index0 }}" name="actual_solution_{{ loop.index0 }}" value="{{ image.actual_solution or '' }}">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Status</label>
<select class="form-select d-none" id="status_{{ loop.index0 }}" name="status_{{ loop.index0 }}">
<option value="correct" {% if image.status and image.status.lower() == 'correct' %}selected{% endif %}>Correct</option>
<option value="wrong" {% if image.status and image.status.lower() == 'wrong' %}selected{% endif %}>Wrong</option>
<option value="unattempted" {% if image.status and image.status.lower() == 'unattempted' or not image.status %}selected{% endif %}>Unattempted</option>
</select>
<div class="status-buttons" data-index="{{ loop.index0 }}">
<button type="button" class="status-btn {% if image.status and image.status.lower() == 'correct' %}active{% endif %}" data-status="correct">✓ Correct</button>
<button type="button" class="status-btn {% if image.status and image.status.lower() == 'wrong' %}active{% endif %}" data-status="wrong">✗ Wrong</button>
<button type="button" class="status-btn {% if image.status and image.status.lower() == 'unattempted' or not image.status %}active{% endif %}" data-status="unattempted">? Unattempted</button>
</div>
</div>
<div class="row mt-2">
<div class="col-md-6">
<p class="mb-0"><strong>Subject:</strong> <span id="subject_{{ image.id }}">{{ image.subject or 'N/A' }}</span></p>
</div>
<div class="col-md-6">
<p class="mb-0"><strong>Chapter:</strong> <span id="chapter_{{ image.id }}">{{ image.chapter or 'N/A' }}</span></p>
</div>
</div>
</fieldset>
{% endfor %}
<fieldset>
<legend class="h4">Generate PDF</legend>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="pdf_subject">PDF Subject *</label>
<input type="text" id="pdf_subject" class="form-control" value="{{ session_data.subject or session_data.original_filename or '' }}" required>
</div>
<div class="col-md-6 mb-3" id="pdf-name-div">
<label class="form-label" for="pdf_name">PDF Name</label>
<input type="text" id="pdf_name" class="form-control" value="{{ session_data.original_filename or '' }}">
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="pdf_tags">Tags (comma-separated)</label>
<input type="text" id="pdf_tags" class="form-control" value="{{ session_data.tags or '' }}">
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="pdf_notes">Notes</label>
<textarea id="pdf_notes" class="form-control" rows="1">{{ session_data.notes or '' }}</textarea>
</div>
</div>
<hr>
<legend class="h5">Layout Options</legend>
<div class="row align-items-end">
<div class="col-md-3 mb-3">
<label class="form-label" for="practice_mode">Practice Mode</label>
<select id="practice_mode" class="form-select">
<option value="none" selected>None</option>
<option value="portrait_2">Portrait 2</option>
<option value="portrait_3">Portrait 3</option>
<option value="landscape_2">Landscape 2</option>
<option value="portrait_2_spacious">Portrait 2 - Spacious</option>
</select>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="orientation">Orientation</label>
<select id="orientation" class="form-select">
<option value="portrait" selected>Portrait</option>
<option value="landscape">Landscape</option>
</select>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="filter_type">Filter Questions</label>
<select id="filter_type" class="form-select">
<option value="all">All</option>
<option value="wrong">Wrong Only</option>
<option value="unattempted">Unattempted Only</option>
</select>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="font_size_scale">Font Size Scale: <span id="font_size_val">1.0</span>x</label>
<input type="range" class="form-range" id="font_size_scale" min="0.5" max="2.0" step="0.1" value="1.0" oninput="document.getElementById('font_size_val').innerText = this.value">
</div>
</div>
<div id="standard-layout-options">
<div class="row">
<div class="col-md-3 mb-3">
<label class="form-label" for="images_per_page">Images per Page</label>
<input type="number" id="images_per_page" class="form-control" value="4" min="1" max="20">
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="grid_rows">Grid Rows</label>
<input type="number" id="grid_rows" class="form-control" value="4" min="1">
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="grid_cols">Grid Cols</label>
<input type="number" id="grid_cols" class="form-control" value="1" min="1">
</div>
</div>
</div>
</fieldset>
<div class="d-flex gap-2 mt-3">
<button type="button" id="preview-btn" class="btn btn-secondary w-100">See Preview</button>
<button type="submit" class="btn btn-primary w-100">Generate PDF</button>
</div>
</form>
<!-- Preview Modal -->
<div class="modal fade" id="previewModal" tabindex="-1" aria-labelledby="previewModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="previewModalLabel">PDF Preview</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-center">
<img id="preview-img" src="" class="img-fluid" alt="PDF Preview">
</div>
</div>
</div>
</div>
<!-- Revision Notes Modal (from partial) -->
{% include '_revision_notes.html' %}
<div class="accordion mt-4" id="misc-accordion">
<div class="accordion-item bg-dark">
<h2 class="accordion-header" id="headingOne">
<button class="accordion-button bg-secondary text-white collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseMisc" aria-expanded="false" aria-controls="collapseMisc">
Add Miscellaneous Questions (Temporary)
</button>
</h2>
<div id="collapseMisc" class="accordion-collapse collapse" aria-labelledby="headingOne" data-bs-parent="#misc-accordion">
<div class="accordion-body">
<form id="misc-question-form">
<div class="row">
<div class="col-md-6 mb-3"><label class="form-label">Question Number</label><input type="text" id="misc_question_number" class="form-control"></div>
<div class="col-md-6 mb-3"><label class="form-label">Subject</label><input type="text" id="misc_subject" class="form-control"></div>
<div class="col-md-6 mb-3"><label class="form-label">Your Answer</label><input type="text" id="misc_marked_solution" class="form-control"></div>
<div class="col-md-6 mb-3"><label class="form-label">Correct Answer</label><input type="text" id="misc_actual_solution" class="form-control"></div>
<div class="col-md-6 mb-3"><label class="form-label">Status</label><select id="misc_status" class="form-select"><option value="correct">Correct</option><option value="wrong">Wrong</option><option value="unattempted" selected>Unattempted</option></select></div>
<div class="col-md-6 mb-3"><label class="form-label">Image (Optional)</label><input type="file" id="misc_image" class="form-control" accept="image/*"></div>
</div>
<button type="button" id="add-misc-question" class="btn btn-info">Add Miscellaneous Question</button>
</form>
<hr class="my-4">
<h4 class="h5">Added Miscellaneous Questions:</h4>
<div id="misc-questions-list"></div>
</div>
</div>
</div>
</div>
<div id="status" class="mt-3"></div>
</div>
</div>
</div>
<!-- Modal 1: Range & Subject -->
<div class="modal fade" id="manualClassificationModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content bg-dark text-white">
<div class="modal-header border-secondary" style="background: var(--bg-card);">
<h5 class="modal-title"><i class="bi bi-list-check me-2"></i>Quick Classification</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-info border-0 mb-3">
<i class="bi bi-lightning-fill me-2"></i>
Classify questions with AI-powered suggestions. Subject will be auto-detected.
</div>
<!-- Quick Range Toggles -->
<div class="mb-3">
<label class="form-label">Quick Select</label>
<div class="d-flex flex-wrap gap-2">
<button type="button" class="btn btn-outline-primary range-toggle active" data-range="all">
<i class="bi bi-collection me-1"></i>All
</button>
<button type="button" class="btn btn-outline-warning range-toggle" data-range="unclassified">
<i class="bi bi-question-circle me-1"></i>Unclassified
</button>
<button type="button" class="btn btn-outline-danger range-toggle" data-range="wrong">
<i class="bi bi-x-circle me-1"></i>Wrong Only
</button>
<button type="button" class="btn btn-outline-secondary range-toggle" data-range="unattempted">
<i class="bi bi-dash-circle me-1"></i>Unattempted
</button>
<button type="button" class="btn btn-outline-info range-toggle" data-range="custom">
<i class="bi bi-sliders me-1"></i>Custom Range
</button>
</div>
</div>
<!-- Custom Range Slider UI -->
<div id="custom-range-container" class="d-none">
<div class="card bg-secondary mb-3">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label mb-0">Select Question Ranges</label>
<button type="button" class="btn btn-sm btn-success" onclick="addRangeSlider()">
<i class="bi bi-plus-lg me-1"></i>Add Range
</button>
</div>
<div id="range-sliders-container">
<!-- Range sliders will be added here -->
</div>
<div class="mt-3 p-2 bg-dark rounded">
<small class="text-muted">Selected: </small>
<span id="selected-ranges-display" class="text-info fw-bold">None</span>
<small class="text-muted ms-2">(<span id="selected-count">0</span> questions)</small>
</div>
</div>
</div>
<!-- Preview Section -->
<div class="row" id="range-preview-section">
<div class="col-6">
<div class="card bg-secondary">
<div class="card-header py-1 text-center">
<small class="text-success"><i class="bi bi-arrow-right-circle me-1"></i>Start</small>
</div>
<div class="card-body p-2 text-center">
<img id="preview-start-img" src="" class="img-fluid rounded" style="max-height: 120px; object-fit: contain;">
<div class="mt-1"><span class="badge bg-primary" id="preview-start-num">#1</span></div>
</div>
</div>
</div>
<div class="col-6">
<div class="card bg-secondary">
<div class="card-header py-1 text-center">
<small class="text-danger"><i class="bi bi-arrow-left-circle me-1"></i>End</small>
</div>
<div class="card-body p-2 text-center">
<img id="preview-end-img" src="" class="img-fluid rounded" style="max-height: 120px; object-fit: contain;">
<div class="mt-1"><span class="badge bg-primary" id="preview-end-num">#1</span></div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-lg btn-primary btn-pill w-100" onclick="startManualClassification()">
<i class="bi bi-play-fill me-2"></i>Start Classification
</button>
</div>
</div>
</div>
</div>
<!-- Modal 2: Topic Wizard -->
<div class="modal fade" id="topicSelectionModal" tabindex="-1" data-bs-backdrop="static" data-bs-keyboard="false">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content bg-dark text-white border-secondary">
<div class="modal-header border-secondary py-2" style="background: var(--bg-card);">
<div class="d-flex align-items-center gap-3">
<span class="badge bg-primary fs-6" id="topic_q_num">#1</span>
<div class="progress flex-grow-1" style="width: 150px; height: 6px;">
<div class="progress-bar" id="topic_progress_bar" role="progressbar" style="width: 0%; background: var(--accent-primary);"></div>
</div>
<small id="topic_progress" class="text-muted">1/10</small>
</div>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<!-- Question Image -->
<div class="text-center mb-3">
<img id="topic_q_img" src="" class="img-fluid rounded border border-secondary" style="max-height: 180px; object-fit: contain;">
</div>
<!-- Subject Selection (Auto-detected, editable) -->
<div class="mb-3">
<label class="form-label small text-muted mb-2">Subject</label>
<div id="subject_pills" class="d-flex flex-wrap gap-2">
<button type="button" class="btn btn-sm btn-outline-success subject-pill" data-subject="Biology">Biology</button>
<button type="button" class="btn btn-sm btn-outline-warning subject-pill" data-subject="Chemistry">Chemistry</button>
<button type="button" class="btn btn-sm btn-outline-info subject-pill" data-subject="Physics">Physics</button>
<button type="button" class="btn btn-sm btn-outline-danger subject-pill" data-subject="Mathematics">Maths</button>
</div>
</div>
<!-- Chapter/Topic Input -->
<div class="mb-2">
<label class="form-label small text-muted mb-2">Chapter / Topic</label>
<div class="input-group input-group-lg">
<input type="text" id="topic_input" class="form-control bg-dark text-white border-secondary" placeholder="Start typing or use AI...">
<button class="btn btn-info" type="button" id="btn_get_suggestion" onclick="getAiSuggestion()">
<span class="spinner-border spinner-border-sm d-none" role="status"></span>
<i class="bi bi-stars"></i>
</button>
</div>
</div>
<!-- Suggestions -->
<div id="ai_suggestion_container" class="d-none">
<div id="ai_suggestion_chips" class="d-flex flex-wrap gap-2"></div>
</div>
<div id="ai_error_msg" class="alert alert-danger d-none mt-2 py-2"></div>
</div>
<div class="modal-footer border-secondary py-2">
<button type="button" class="btn btn-outline-secondary btn-lg btn-pill px-4" onclick="prevTopicQuestion()">
<i class="bi bi-chevron-left"></i>
</button>
<button type="button" class="btn btn-primary btn-lg btn-pill px-5" onclick="nextTopicQuestion()">
Next <i class="bi bi-chevron-right ms-1"></i>
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const numImages = {{ images|length }};
const sessionId = '{{ session_id }}';
let answerKeyData = new Map();
let miscellaneousQuestions = [];
async function initializeTomSelect() {
try {
const response = await fetch('/get_metadata_suggestions');
const suggestions = await response.json();
const subjectOptions = suggestions.subjects.map(s => ({value: s, text: s}));
const tagOptions = suggestions.tags.map(t => ({value: t, text: t}));
new TomSelect('#pdf_subject',{
create: true,
persist: false,
options: subjectOptions
});
new TomSelect('#pdf_tags',{
persist: false,
createOnBlur: true,
create: true,
plugins: ['remove_button'],
options: tagOptions
});
} catch (err) {
console.error('Error initializing Tom Select:', err);
}
}
// --- AUTO-SAVE FUNCTIONALITY ---
let autoSaveTimeout = null;
let pendingSaves = new Set();
// Debounced auto-save for a single question
async function autoSaveQuestion(fieldset) {
if (!fieldset) return;
const imageId = fieldset.querySelector('input[name^="image_id_"]')?.value;
if (!imageId) return;
// Skip if already pending
if (pendingSaves.has(imageId)) return;
pendingSaves.add(imageId);
const questionData = {
image_id: imageId,
question_number: fieldset.querySelector('input[name^="question_number_"]')?.value || '',
status: fieldset.querySelector('select[name^="status_"]')?.value || 'unattempted',
marked_solution: fieldset.querySelector('input[name^="marked_solution_"]')?.value || '',
actual_solution: fieldset.querySelector('input[name^="actual_solution_"]')?.value || ''
};
try {
const response = await fetch('/autosave_question', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: sessionId,
question: questionData
})
});
const result = await response.json();
if (result.success) {
// Show subtle save indicator on the fieldset
showAutoSaveIndicator(fieldset, true);
} else {
showAutoSaveIndicator(fieldset, false);
}
} catch (e) {
console.error('Auto-save failed:', e);
showAutoSaveIndicator(fieldset, false);
} finally {
pendingSaves.delete(imageId);
}
}
// Auto-save session metadata (PDF subject, tags, notes)
async function autoSaveSessionMetadata() {
try {
const response = await fetch('/autosave_session_metadata', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: sessionId,
pdf_subject: document.getElementById('pdf_subject')?.value || '',
pdf_tags: document.getElementById('pdf_tags')?.value || '',
pdf_notes: document.getElementById('pdf_notes')?.value || '',
pdf_name: document.getElementById('pdf_name')?.value || ''
})
});
const result = await response.json();
if (result.success) {
// Show subtle indicator near the generate PDF section
const legend = document.querySelector('legend.h4');
if (legend && !legend.querySelector('.autosave-badge')) {
const badge = document.createElement('span');
badge.className = 'autosave-badge badge bg-success ms-2';
badge.innerHTML = '<i class="bi bi-check"></i> Saved';
legend.appendChild(badge);
setTimeout(() => badge.remove(), 2000);
}
}
} catch (e) {
console.error('Metadata auto-save failed:', e);
}
}
// Show a subtle save indicator on the fieldset
function showAutoSaveIndicator(fieldset, success) {
// Remove any existing indicator
const existing = fieldset.querySelector('.autosave-indicator');
if (existing) existing.remove();
const indicator = document.createElement('span');
indicator.className = 'autosave-indicator position-absolute';
indicator.style.cssText = 'top: 5px; right: 100px; font-size: 0.75rem;';
if (success) {
indicator.innerHTML = '<span class="badge bg-success"><i class="bi bi-check"></i> Saved</span>';
} else {
indicator.innerHTML = '<span class="badge bg-danger"><i class="bi bi-x"></i> Error</span>';
}
// Position relative to the legend
const legend = fieldset.querySelector('legend');
if (legend) {
legend.style.position = 'relative';
legend.appendChild(indicator);
}
// Remove after 2 seconds
setTimeout(() => indicator.remove(), 2000);
}
// Auto-save status when changed via status buttons
function autoSaveOnStatusChange(fieldset) {
autoSaveQuestion(fieldset);
}
function saveSettings() {
// Session-specific settings
const sessionSettings = {
pdf_subject: document.getElementById('pdf_subject').value,
pdf_tags: document.getElementById('pdf_tags').value,
pdf_notes: document.getElementById('pdf_notes').value,
pdf_name: document.getElementById('pdf_name').value,
};
localStorage.setItem('pdfGeneratorSettings-{{ session_id }}', JSON.stringify(sessionSettings));
// Global layout settings
const globalLayoutSettings = {
images_per_page: document.getElementById('images_per_page').value,
orientation: document.getElementById('orientation').value,
grid_rows: document.getElementById('grid_rows').value,
grid_cols: document.getElementById('grid_cols').value,
filter_type: document.getElementById('filter_type').value,
practice_mode: document.getElementById('practice_mode').value,
font_size_scale: document.getElementById('font_size_scale').value
};
localStorage.setItem('pdfGeneratorLayoutSettings', JSON.stringify(globalLayoutSettings));
}
function loadSettings() {
// Load session-specific settings (subject, tags, etc.)
const sessionSettings = JSON.parse(localStorage.getItem('pdfGeneratorSettings-{{ session_id }}'));
if (sessionSettings) {
const pdfSubjectInput = document.getElementById('pdf_subject');
if (!pdfSubjectInput.value) {
pdfSubjectInput.value = sessionSettings.pdf_subject || '';
}
const pdfNameInput = document.getElementById('pdf_name');
if (!pdfNameInput.value) {
pdfNameInput.value = sessionSettings.pdf_name || 'My-Test-Analysis';
}
const pdfTagsInput = document.getElementById('pdf_tags');
if (!pdfTagsInput.value) {
pdfTagsInput.value = sessionSettings.pdf_tags || '';
}
const pdfNotesInput = document.getElementById('pdf_notes');
if (!pdfNotesInput.value) {
pdfNotesInput.value = sessionSettings.pdf_notes || '';
}
}
// Load global layout settings
const globalLayoutSettings = JSON.parse(localStorage.getItem('pdfGeneratorLayoutSettings'));
if (globalLayoutSettings) {
document.getElementById('images_per_page').value = globalLayoutSettings.images_per_page || 4;
document.getElementById('orientation').value = globalLayoutSettings.orientation || 'portrait';
document.getElementById('grid_rows').value = globalLayoutSettings.grid_rows || 4;
document.getElementById('grid_cols').value = globalLayoutSettings.grid_cols || 1;
document.getElementById('filter_type').value = globalLayoutSettings.filter_type || 'all';
document.getElementById('practice_mode').value = globalLayoutSettings.practice_mode || 'none';
if (globalLayoutSettings.font_size_scale) {
document.getElementById('font_size_scale').value = globalLayoutSettings.font_size_scale;
document.getElementById('font_size_val').innerText = globalLayoutSettings.font_size_scale;
}
}
handlePracticeModeChange(); // Apply visibility changes on load
}
function handlePracticeModeChange() {
const practiceMode = document.getElementById('practice_mode').value;
const standardOptions = document.getElementById('standard-layout-options');
const orientationSelect = document.getElementById('orientation');
const pdfNameDiv = document.getElementById('pdf-name-div');
if (practiceMode === 'none') {
standardOptions.style.display = 'block';
pdfNameDiv.style.display = 'block';
} else {
standardOptions.style.display = 'none';
pdfNameDiv.style.display = 'none';
if (practiceMode === 'landscape_2') {
orientationSelect.value = 'landscape';
} else {
orientationSelect.value = 'portrait';
}
}
}
function updateGridDefaults() {
const imagesPerPage = parseInt(document.getElementById('images_per_page').value, 10);
const orientation = document.getElementById('orientation').value;
const rowsInput = document.getElementById('grid_rows');
const colsInput = document.getElementById('grid_cols');
if (imagesPerPage === 4) {
if (orientation === 'portrait') {
rowsInput.value = 4;
colsInput.value = 1;
} else { // landscape
rowsInput.value = 2;
colsInput.value = 2;
}
} else {
const cols = Math.ceil(Math.sqrt(imagesPerPage));
const rows = Math.ceil(imagesPerPage / cols);
rowsInput.value = rows;
colsInput.value = cols;
}
}
function setupEventListeners() {
const jsonUpload = document.getElementById('json-upload');
if (jsonUpload) jsonUpload.addEventListener('change', handleJsonUpload);
const imagesPerPage = document.getElementById('images_per_page');
if (imagesPerPage) imagesPerPage.addEventListener('change', updateGridDefaults);
const orientation = document.getElementById('orientation');
if (orientation) orientation.addEventListener('change', updateGridDefaults);
const practiceMode = document.getElementById('practice_mode');
if (practiceMode) practiceMode.addEventListener('change', handlePracticeModeChange);
document.querySelectorAll('input[id^="question_number_"]').forEach(input => {
input.addEventListener('change', handleQuestionNumberChange);
input.addEventListener('blur', () => autoSaveQuestion(input.closest('fieldset')));
});
// Auto-save on marked_solution and actual_solution change
document.querySelectorAll('input[id^="marked_solution_"], input[id^="actual_solution_"]').forEach(input => {
input.addEventListener('blur', () => autoSaveQuestion(input.closest('fieldset')));
});
document.addEventListener('keydown', handleShortcuts);
document.querySelectorAll('.status-buttons').forEach(setupStatusButtons);
const questionsForm = document.getElementById('questions-form');
if (questionsForm) questionsForm.addEventListener('submit', handleFormSubmit);
const previewBtn = document.getElementById('preview-btn');
if (previewBtn) previewBtn.addEventListener('click', handlePreview);
// Auto-save PDF metadata fields on blur
['pdf_subject', 'pdf_tags', 'pdf_notes', 'pdf_name'].forEach(fieldId => {
const field = document.getElementById(fieldId);
if (field) field.addEventListener('blur', autoSaveSessionMetadata);
});
const addMiscBtn = document.getElementById('add-misc-question');
if (addMiscBtn) addMiscBtn.addEventListener('click', handleAddMiscQuestion);
const extractBtn = document.getElementById('extract-classify-all');
if (extractBtn) {
extractBtn.addEventListener('click', async () => {
const spinner = extractBtn.querySelector('.spinner-border');
const text = extractBtn.querySelector('.extract-text');
spinner.classList.remove('d-none');
text.textContent = 'Processing...';
extractBtn.disabled = true;
// First, save the questions
const questions = [];
document.querySelectorAll('fieldset[data-question-index]').forEach((fieldset, i) => {
questions.push({
image_id: fieldset.querySelector('input[name^="image_id_"]').value,
question_number: fieldset.querySelector('input[name^="question_number_"]').value,
status: fieldset.querySelector('select[name^="status_"]').value,
marked_solution: fieldset.querySelector('input[name^="marked_solution_"]').value,
actual_solution: fieldset.querySelector('input[name^="actual_solution_"]').value
});
});
try {
const saveResponse = await fetch('/save_questions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: sessionId,
questions: questions,
pdf_subject: document.getElementById('pdf_subject').value,
pdf_tags: document.getElementById('pdf_tags').value,
pdf_notes: document.getElementById('pdf_notes').value
})
}); if (!saveResponse.ok) throw new Error((await saveResponse.json()).error || 'Failed to save questions.');
// Now, run the classification
const response = await fetch(`/extract_and_classify_all/${sessionId}`, {
method: 'POST'
});
const result = await response.json();
if (result.success) {
showStatus(result.message, 'success');
// Reload the page to show the updated data
setTimeout(() => {
location.reload();
}, 2000);
} else {
showStatus(`Error: ${result.error}`, 'danger');
}
} catch (err) {
showStatus(`Error: ${err.message}`, 'danger');
} finally {
spinner.classList.add('d-none');
text.textContent = 'Extract & Classify All';
extractBtn.disabled = false;
}
});
}
// Add event listeners for auto-extract buttons if NVIDIA NIM is available
{% if nvidia_nim_available %}
document.querySelectorAll('.auto-extract-btn').forEach(button => {
button.addEventListener('click', function() {
const imageId = this.dataset.imageId;
const index = this.dataset.index;
autoExtractQuestionNumber(this, imageId, index);
});
});
// Add event listener for auto-extract all button
const autoExtractAllBtn = document.getElementById('auto-extract-all');
if (autoExtractAllBtn) autoExtractAllBtn.addEventListener('click', autoExtractAllQuestionNumbers);
// Add event listeners for delete buttons
document.querySelectorAll('.delete-question-btn').forEach(button => {
button.addEventListener('click', function() {
const imageId = this.dataset.imageId;
const index = this.dataset.index;
deleteQuestion(imageId, index);
});
});
{% endif %}
}
function handleJsonUpload(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const json = JSON.parse(e.target.result);
if (!json.data || !Array.isArray(json.data)) throw new Error('Invalid format');
answerKeyData.clear();
json.data.forEach(item => answerKeyData.set(String(item.question_number), String(item.answer)));
showStatus(`Loaded ${answerKeyData.size} answers from key.`, 'success');
// Auto-fill answers for existing question numbers
document.querySelectorAll('input[id^="question_number_"]').forEach(input => {
if(input.value) handleQuestionNumberChange.call(input);
});
} catch (err) {
showStatus('Error reading or parsing JSON file.', 'danger');
}
};
reader.readAsText(file);
}
function handleQuestionNumberChange() {
const qNum = this.value;
if (answerKeyData.has(qNum)) {
const index = this.id.split('_').pop();
document.getElementById(`actual_solution_${index}`).value = answerKeyData.get(qNum);
}
}
function getCurrentQuestionIndex() {
const activeElement = document.activeElement;
const parentFieldset = activeElement?.closest('fieldset[data-question-index]');
return parentFieldset ? parseInt(parentFieldset.dataset.questionIndex, 10) : -1;
}
function setStatusForCurrentQuestion(status) {
const index = getCurrentQuestionIndex();
if (index !== -1) {
document.querySelector(`.status-buttons[data-index='${index}'] .status-btn[data-status='${status}']`)?.click();
}
}
function moveToNextQuestion() {
let index = getCurrentQuestionIndex();
if (index === -1) index = 0;
const nextIndex = (index + 1) % numImages;
document.getElementById(`question_number_${nextIndex}`)?.focus();
}
function handleShortcuts(e) {
if (e.shiftKey) {
const key = e.key.toLowerCase();
if (['q', 'c', 'w', 'u'].includes(key)) e.preventDefault();
switch(key) {
case 'q': moveToNextQuestion(); break;
case 'c': setStatusForCurrentQuestion('correct'); break;
case 'w': setStatusForCurrentQuestion('wrong'); break;
case 'u': setStatusForCurrentQuestion('unattempted'); break;
}
}
}
function setupStatusButtons(group) {
const buttons = group.querySelectorAll('.status-btn');
const index = group.dataset.index;
const select = document.getElementById(`status_${index}`);
buttons.forEach(btn => {
btn.addEventListener('click', () => {
buttons.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
select.value = btn.dataset.status;
// Auto-save when status changes
const fieldset = group.closest('fieldset');
if (fieldset) autoSaveOnStatusChange(fieldset);
});
});
}
async function handlePreview(e) {
e.preventDefault();
saveSettings(); // Save settings for consistency
const statusDiv = document.getElementById('status');
showStatus('Generating preview...', 'info');
const questions = [];
document.querySelectorAll('fieldset[data-question-index]').forEach((fieldset, i) => {
questions.push({
image_id: fieldset.querySelector('input[name^="image_id_"]').value,
question_number: fieldset.querySelector('input[name^="question_number_"]').value,
status: fieldset.querySelector('select[name^="status_"]').value,
marked_solution: fieldset.querySelector('input[name^="marked_solution_"]').value,
actual_solution: fieldset.querySelector('input[name^="actual_solution_"]').value,
});
});
try {
const response = await fetch('/generate_preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: sessionId,
subject: document.getElementById('pdf_subject').value,
images_per_page: parseInt(document.getElementById('images_per_page').value, 10),
filter_type: document.getElementById('filter_type').value,
orientation: document.getElementById('orientation').value,
grid_rows: parseInt(document.getElementById('grid_rows').value, 10),
grid_cols: parseInt(document.getElementById('grid_cols').value, 10),
practice_mode: document.getElementById('practice_mode').value,
font_size_scale: parseFloat(document.getElementById('font_size_scale').value),
miscellaneous_questions: miscellaneousQuestions,
questions: questions // Pass the main questions too
})
});
const result = await response.json();
if (result.error) throw new Error(result.error);
const previewImg = document.getElementById('preview-img');
previewImg.src = result.preview_image;;
const previewModal = new bootstrap.Modal(document.getElementById('previewModal'));
previewModal.show();
showStatus('Preview generated.', 'success');
} catch (err) {
showStatus(`Error: ${err.message}`, 'danger');
}
}
async function handleFormSubmit(e) {
e.preventDefault();
saveSettings(); // Save settings on submit
const statusDiv = document.getElementById('status');
showStatus('Saving questions and generating PDF...', 'info');
const questions = [];
document.querySelectorAll('fieldset[data-question-index]').forEach((fieldset, i) => {
questions.push({
image_id: fieldset.querySelector('input[name^="image_id_"]').value,
question_number: fieldset.querySelector('input[name^="question_number_"]').value,
status: fieldset.querySelector('select[name^="status_"]').value,
marked_solution: fieldset.querySelector('input[name^="marked_solution_"]').value,
actual_solution: fieldset.querySelector('input[name^="actual_solution_"]').value
});
});
try {
// First, save the question data.
const saveResponse = await fetch('/save_questions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: sessionId,
questions: questions,
pdf_subject: document.getElementById('pdf_subject').value,
pdf_tags: document.getElementById('pdf_tags').value,
pdf_notes: document.getElementById('pdf_notes').value
})
});
if (!saveResponse.ok) throw new Error((await saveResponse.json()).error || 'Failed to save questions.');
// Then, generate the PDF.
const pdfResponse = await fetch('/generate_pdf', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: sessionId,
pdf_name: document.getElementById('pdf_name').value,
subject: document.getElementById('pdf_subject').value,
tags: document.getElementById('pdf_tags').value,
notes: document.getElementById('pdf_notes').value,
images_per_page: parseInt(document.getElementById('images_per_page').value, 10),
filter_type: document.getElementById('filter_type').value,
orientation: document.getElementById('orientation').value,
grid_rows: parseInt(document.getElementById('grid_rows').value, 10),
grid_cols: parseInt(document.getElementById('grid_cols').value, 10),
practice_mode: document.getElementById('practice_mode').value,
font_size_scale: parseFloat(document.getElementById('font_size_scale').value),
miscellaneous_questions: miscellaneousQuestions
})
});
const result = await pdfResponse.json();
if (result.error) throw new Error(result.error);
statusDiv.innerHTML = `<a href="/download/${result.pdf_filename}" class="btn btn-success w-100">Download PDF: ${result.pdf_filename}</a>`;
} catch (err) {
showStatus(`Error: ${err.message}`, 'danger');
}
}
function showStatus(message, type = 'info', duration = 4000) {
const statusContainer = document.getElementById('status');
const alertId = `alert-${Date.now()}`;
const alert = `<div id="${alertId}" class="alert alert-${type} alert-dismissible fade show" role="alert">
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>`;
statusContainer.innerHTML = alert;
}
function handleAddMiscQuestion() {
const imageInput = document.getElementById('misc_image');
const file = imageInput.files[0];
const question = {
id: `misc_${Date.now()}`,
question_number: document.getElementById('misc_question_number').value,
subject: document.getElementById('misc_subject').value,
status: document.getElementById('misc_status').value,
marked_solution: document.getElementById('misc_marked_solution').value,
actual_solution: document.getElementById('misc_actual_solution').value,
image_data: null
};
if (file) {
const reader = new FileReader();
reader.onload = function(e) {
question.image_data = e.target.result;
miscellaneousQuestions.push(question);
renderMiscellaneousQuestions();
document.getElementById('misc-question-form').reset();
};
reader.readAsDataURL(file);
} else {
miscellaneousQuestions.push(question);
renderMiscellaneousQuestions();
document.getElementById('misc-question-form').reset();
}
}
function renderMiscellaneousQuestions() {
const listDiv = document.getElementById('misc-questions-list');
listDiv.innerHTML = '';
miscellaneousQuestions.forEach((q, index) => {
const item = document.createElement('div');
item.className = 'alert alert-secondary';
item.innerHTML = `
<strong>Q: ${q.question_number}</strong> - ${q.subject}
<button type="button" class="btn-close float-end" data-index="${index}"></button>
`;
listDiv.appendChild(item);
});
document.querySelectorAll('#misc-questions-list .btn-close').forEach(button => {
button.addEventListener('click', handleRemoveMiscQuestion);
});
}
function handleRemoveMiscQuestion(event) {
const index = event.target.dataset.index;
miscellaneousQuestions.splice(index, 1);
renderMiscellaneousQuestions();
}
// Auto-extract question number function
async function autoExtractQuestionNumber(button, imageId, index) {
// Check if NVIDIA NIM is available
{% if not nvidia_nim_available %}
showStatus('NVIDIA NIM OCR feature is not available. Please set the NVIDIA_API_KEY environment variable.', 'warning');
return;
{% endif %}
const spinner = button.querySelector('.extract-spinner');
const text = button.querySelector('.extract-text');
const questionInput = document.getElementById(`question_number_${index}`);
// Show loading state
spinner.classList.remove('d-none');
text.classList.add('d-none');
button.disabled = true;
try {
const response = await fetch('/extract_question_number', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_id: imageId })
});
const result = await response.json();
if (result.success) {
if (result.question_number) {
questionInput.value = result.question_number;
showStatus(`Question number ${result.question_number} extracted successfully!`, 'success');
} else {
showStatus('No question number found in the image.', 'warning');
}
} else {
showStatus(`Error: ${result.error}`, 'danger');
}
} catch (err) {
showStatus(`Error extracting question number: ${err.message}`, 'danger');
} finally {
// Restore button state
spinner.classList.add('d-none');
text.classList.remove('d-none');
button.disabled = false;
}
}
// Auto-extract all question numbers function
async function autoExtractAllQuestionNumbers() {
// Check if NVIDIA NIM is available
{% if not nvidia_nim_available %}
showStatus('NVIDIA NIM OCR feature is not available. Please set the NVIDIA_API_KEY environment variable.', 'warning');
return;
{% endif %}
const extractAllButton = document.getElementById('auto-extract-all');
const extractAllSpinner = extractAllButton.querySelector('.spinner-border');
const extractAllText = extractAllButton.querySelector('.extract-text');
// Show loading state for the main button
extractAllSpinner.classList.remove('d-none');
extractAllText.textContent = 'Extracting...';
extractAllButton.disabled = true;
try {
const response = await fetch('/extract_all_question_numbers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId })
});
const result = await response.json();
if (result.success) {
// Process successful results
result.results.forEach(item => {
if (item.question_number) {
// Find the correct input by using the data-image-id attribute
const questionInput = document.querySelector(`input[data-image-id="${item.image_id}"]`);
if (questionInput) {
questionInput.value = item.question_number;
}
}
});
// Show status message
let message = `Successfully extracted ${result.results.length} question numbers.`;
if (result.errors && result.errors.length > 0) {
message += ` Failed to extract ${result.errors.length} questions.`;
}
showStatus(message, 'success');
// Show individual errors if any
if (result.errors && result.errors.length > 0) {
result.errors.forEach(error => {
showStatus(`Error for image ${error.image_id}: ${error.error}`, 'danger');
});
}
} else {
showStatus(`Error: ${result.error}`, 'danger');
}
} catch (err) {
showStatus(`Error extracting question numbers: ${err.message}`, 'danger');
} finally {
// Restore button state
extractAllSpinner.classList.add('d-none');
extractAllText.textContent = 'Auto Extract All Question Numbers';
extractAllButton.disabled = false;
}
}
// Delete question function
async function deleteQuestion(imageId, index) {
if (!confirm('Are you sure you want to delete this question? This action cannot be undone.')) {
return;
}
try {
const response = await fetch(`/delete_question/${imageId}`, {
method: 'DELETE'
});
const result = await response.json();
if (result.success) {
// Remove the fieldset from the DOM
const fieldset = document.getElementById(`question-fieldset-${imageId}`);
if (fieldset) {
fieldset.remove();
}
showStatus('Question deleted successfully!', 'success');
} else {
showStatus(`Error: ${result.error}`, 'danger');
}
} catch (err) {
showStatus(`Error deleting question: ${err.message}`, 'danger');
}
}
// --- Manual Classification Logic ---
let manualQuestionsList = [];
let currentManualIndex = 0;
let manualSubject = "Biology"; // Default, will be auto-detected
let suggestionCache = new Map();
let selectedRangeMode = 'all'; // 'all', 'unclassified', 'wrong', 'unattempted', 'custom'
let customRanges = []; // Array of {start, end, subject} for custom slider ranges
let questionSubjectMap = new Map(); // Maps question index to pre-selected subject
// Get question image URL by index
function getQuestionImageUrl(index) {
const fieldset = document.querySelector(`fieldset[data-question-index="${index}"]`);
if (!fieldset) return '';
const img = fieldset.querySelector('img');
return img ? img.src : '';
}
// Get question number by index
function getQuestionNumber(index) {
const fieldset = document.querySelector(`fieldset[data-question-index="${index}"]`);
if (!fieldset) return index + 1;
const input = fieldset.querySelector('input[name^="question_number_"]');
return input && input.value ? input.value : (index + 1);
}
// Add a new range slider
function addRangeSlider() {
const container = document.getElementById('range-sliders-container');
const sliderId = Date.now();
const maxVal = numImages;
const sliderHtml = `
<div class="range-slider-row" id="slider-row-${sliderId}" data-slider-id="${sliderId}">
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="d-flex align-items-center gap-2">
<span class="badge bg-success" id="start-badge-${sliderId}">Q1</span>
<i class="bi bi-arrow-right text-muted"></i>
<span class="badge bg-danger" id="end-badge-${sliderId}">Q${maxVal}</span>
</div>
<div class="d-flex align-items-center gap-2">
<select class="form-select form-select-sm bg-dark text-white border-secondary" id="subject-${sliderId}" style="width: auto;">
<option value="auto">🤖 Auto-detect</option>
<option value="Biology">🌱 Biology</option>
<option value="Chemistry">🧪 Chemistry</option>
<option value="Physics">⚡ Physics</option>
<option value="Mathematics">📐 Mathematics</option>
</select>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeRangeSlider(${sliderId})">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
<div class="dual-range-container">
<div class="dual-range-track"></div>
<div class="dual-range-highlight" id="highlight-${sliderId}"></div>
<input type="range" min="1" max="${maxVal}" value="1" class="range-start" id="start-${sliderId}"
oninput="updateRangeSlider(${sliderId})">
<input type="range" min="1" max="${maxVal}" value="${maxVal}" class="range-end" id="end-${sliderId}"
oninput="updateRangeSlider(${sliderId})">
</div>
</div>
`;
container.insertAdjacentHTML('beforeend', sliderHtml);
customRanges.push({ id: sliderId, start: 1, end: maxVal, subject: 'auto' });
// Add subject change listener
document.getElementById(`subject-${sliderId}`).addEventListener('change', (e) => {
const rangeObj = customRanges.find(r => r.id === sliderId);
if (rangeObj) rangeObj.subject = e.target.value;
});
updateRangeSlider(sliderId);
updateSelectedRangesDisplay();
updateRangePreview();
}
// Update range slider visuals and values
function updateRangeSlider(sliderId) {
const startInput = document.getElementById(`start-${sliderId}`);
const endInput = document.getElementById(`end-${sliderId}`);
const highlight = document.getElementById(`highlight-${sliderId}`);
const startBadge = document.getElementById(`start-badge-${sliderId}`);
const endBadge = document.getElementById(`end-badge-${sliderId}`);
let startVal = parseInt(startInput.value);
let endVal = parseInt(endInput.value);
// Ensure start <= end
if (startVal > endVal) {
if (startInput === document.activeElement) {
endInput.value = startVal;
endVal = startVal;
} else {
startInput.value = endVal;
startVal = endVal;
}
}
// Update highlight bar
const max = parseInt(startInput.max);
const leftPercent = ((startVal - 1) / (max - 1)) * 100;
const rightPercent = ((endVal - 1) / (max - 1)) * 100;
highlight.style.left = leftPercent + '%';
highlight.style.width = (rightPercent - leftPercent) + '%';
// Update badges
startBadge.textContent = `Q${getQuestionNumber(startVal - 1)}`;
endBadge.textContent = `Q${getQuestionNumber(endVal - 1)}`;
// Update stored range
const rangeObj = customRanges.find(r => r.id === sliderId);
if (rangeObj) {
rangeObj.start = startVal;
rangeObj.end = endVal;
}
updateSelectedRangesDisplay();
updateRangePreview();
}
// Remove a range slider
function removeRangeSlider(sliderId) {
document.getElementById(`slider-row-${sliderId}`)?.remove();
customRanges = customRanges.filter(r => r.id !== sliderId);
updateSelectedRangesDisplay();
updateRangePreview();
}
// Update the display of selected ranges
function updateSelectedRangesDisplay() {
const display = document.getElementById('selected-ranges-display');
const countSpan = document.getElementById('selected-count');
if (customRanges.length === 0) {
display.textContent = 'None';
countSpan.textContent = '0';
return;
}
const rangeStrs = customRanges.map(r => r.start === r.end ? `${r.start}` : `${r.start}-${r.end}`);
display.textContent = rangeStrs.join(', ');
// Count unique questions
const indices = new Set();
customRanges.forEach(r => {
for (let i = r.start; i <= r.end; i++) indices.add(i);
});
countSpan.textContent = indices.size;
}
// Update preview images
function updateRangePreview() {
if (customRanges.length === 0) return;
// Find first and last question across all ranges
let minQ = Infinity, maxQ = -Infinity;
customRanges.forEach(r => {
if (r.start < minQ) minQ = r.start;
if (r.end > maxQ) maxQ = r.end;
});
const startIdx = minQ - 1;
const endIdx = maxQ - 1;
document.getElementById('preview-start-img').src = getQuestionImageUrl(startIdx);
document.getElementById('preview-end-img').src = getQuestionImageUrl(endIdx);
document.getElementById('preview-start-num').textContent = `#${getQuestionNumber(startIdx)}`;
document.getElementById('preview-end-num').textContent = `#${getQuestionNumber(endIdx)}`;
}
// Subject detection keywords
const SUBJECT_KEYWORDS = {
'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'],
'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'],
'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'],
'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']
};
// All NCERT chapters for autocomplete
const ALL_CHAPTERS = {
'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'],
'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'],
'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'],
'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']
};
// Detect subject from question text
function detectSubject(text) {
if (!text) return ['Biology'];
const lowerText = text.toLowerCase();
const scores = {};
for (const [subject, keywords] of Object.entries(SUBJECT_KEYWORDS)) {
scores[subject] = 0;
for (const kw of keywords) {
if (lowerText.includes(kw.toLowerCase())) {
scores[subject]++;
}
}
}
const sorted = Object.entries(scores)
.filter(([_, score]) => score > 0)
.sort((a, b) => b[1] - a[1]);
if (sorted.length === 0) return ['Biology'];
if (sorted.length === 1) return [sorted[0][0]];
return [sorted[0][0], sorted[1][0]].slice(0, 2);
}
// Set active subject pill
function setActiveSubject(subject) {
manualSubject = subject;
document.querySelectorAll('.subject-pill').forEach(pill => {
pill.classList.remove('active', 'btn-success', 'btn-warning', 'btn-info', 'btn-danger');
pill.classList.add('btn-outline-success', 'btn-outline-warning', 'btn-outline-info', 'btn-outline-danger');
if (pill.dataset.subject === subject) {
pill.classList.remove('btn-outline-success', 'btn-outline-warning', 'btn-outline-info', 'btn-outline-danger');
const colorMap = { 'Biology': 'btn-success', 'Chemistry': 'btn-warning', 'Physics': 'btn-info', 'Mathematics': 'btn-danger' };
pill.classList.add('active', colorMap[subject] || 'btn-primary');
}
});
}
// Update typing suggestions based on input
function updateTypingSuggestions() {
const input = document.getElementById('topic_input');
const container = document.getElementById('ai_suggestion_container');
const chipsContainer = document.getElementById('ai_suggestion_chips');
const query = input.value.toLowerCase().trim();
if (query.length < 2) {
container.classList.add('d-none');
return;
}
const chapters = ALL_CHAPTERS[manualSubject] || [];
const matches = chapters.filter(ch => ch.toLowerCase().includes(query)).slice(0, 5);
if (matches.length > 0) {
container.classList.remove('d-none');
chipsContainer.innerHTML = '';
matches.forEach(suggestion => {
const chip = document.createElement('button');
chip.className = 'btn btn-outline-info btn-sm rounded-1';
chip.innerText = suggestion;
chip.onclick = () => { input.value = suggestion; container.classList.add('d-none'); };
chipsContainer.appendChild(chip);
});
} else {
container.classList.add('d-none');
}
}
function parseRange(rangeStr, maxVal) {
const indices = new Set();
const parts = rangeStr.split(',');
for (let part of parts) {
part = part.trim();
if (!part) continue;
if (part.includes('-')) {
const [start, end] = part.split('-').map(Number);
if (!isNaN(start) && !isNaN(end)) {
for (let i = start; i <= end; i++) indices.add(i);
}
} else {
const num = Number(part);
if (!isNaN(num)) indices.add(num);
}
}
return Array.from(indices).filter(i => i >= 1 && i <= maxVal).map(i => i - 1).sort((a, b) => a - b);
}
function startManualClassification() {
// Build question list based on selected mode
manualQuestionsList = [];
if (selectedRangeMode === 'custom') {
// Use custom slider ranges with their subject settings
if (customRanges.length === 0) {
alert("Please add at least one range.");
return;
}
const indices = new Set();
questionSubjectMap.clear();
customRanges.forEach(r => {
for (let i = r.start; i <= r.end; i++) {
const idx = i - 1; // Convert to 0-indexed
indices.add(idx);
// Store subject for this question (if not auto)
if (r.subject && r.subject !== 'auto') {
questionSubjectMap.set(idx, r.subject);
}
}
});
manualQuestionsList = Array.from(indices).sort((a, b) => a - b);
} else {
// Use quick select mode
document.querySelectorAll('fieldset[data-question-index]').forEach((fieldset, i) => {
const imageId = fieldset.querySelector('input[name^="image_id_"]').value;
const chapterSpan = document.getElementById(`chapter_${imageId}`);
const chapter = chapterSpan ? chapterSpan.innerText : '';
const statusSelect = fieldset.querySelector('select[name^="status_"]');
const status = statusSelect ? statusSelect.value : '';
let include = false;
switch (selectedRangeMode) {
case 'all':
include = true;
break;
case 'unclassified':
include = !chapter || chapter === 'N/A' || chapter === 'Unclassified';
break;
case 'wrong':
include = status === 'wrong';
break;
case 'unattempted':
include = status === 'unattempted';
break;
}
if (include) manualQuestionsList.push(i);
});
}
if (manualQuestionsList.length === 0) {
alert("No questions match the selected criteria.");
return;
}
currentManualIndex = 0;
suggestionCache.clear();
// Setup subject pill click handlers
document.querySelectorAll('.subject-pill').forEach(pill => {
pill.onclick = () => {
setActiveSubject(pill.dataset.subject);
// Clear current suggestions and show loading state immediately
const container = document.getElementById('ai_suggestion_container');
const chipsContainer = document.getElementById('ai_suggestion_chips');
container.classList.remove('d-none');
chipsContainer.innerHTML = '<span class="text-muted"><i class="bi bi-hourglass-split me-1"></i>Loading suggestions...</span>';
// Clear input field for fresh suggestions
document.getElementById('topic_input').value = '';
// Clear cache and fetch new suggestions with the new subject
const globalIndex = manualQuestionsList[currentManualIndex];
const fieldset = document.querySelector(`fieldset[data-question-index="${globalIndex}"]`);
const imageId = fieldset.querySelector('input[name^="image_id_"]').value;
suggestionCache.delete(imageId);
getAiSuggestion();
};
});
// Setup typing suggestions
document.getElementById('topic_input').addEventListener('input', updateTypingSuggestions);
const modal1 = bootstrap.Modal.getInstance(document.getElementById('manualClassificationModal'));
modal1.hide();
const modal2 = new bootstrap.Modal(document.getElementById('topicSelectionModal'));
modal2.show();
loadTopicQuestion();
// Prefetch suggestions for first 30 questions
prefetchSuggestions(0, 30);
}
// Setup range toggle handlers
function setupRangeToggles() {
document.querySelectorAll('.range-toggle').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.range-toggle').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
selectedRangeMode = btn.dataset.range;
// Show/hide custom range container
const customContainer = document.getElementById('custom-range-container');
if (selectedRangeMode === 'custom') {
customContainer.classList.remove('d-none');
// Add initial slider if none exist
if (customRanges.length === 0) {
addRangeSlider();
}
} else {
customContainer.classList.add('d-none');
}
});
});
}
async function prefetchSuggestions(startIndex, count) {
// Group questions by their pre-selected subject for batch processing
const subjectBatches = new Map(); // subject -> [{globalIndex, imageId}]
const autoDetectQueue = []; // Questions without pre-selected subject
for (let i = startIndex; i < startIndex + count && i < manualQuestionsList.length; i++) {
const globalIndex = manualQuestionsList[i];
const fieldset = document.querySelector(`fieldset[data-question-index="${globalIndex}"]`);
const imageId = fieldset.querySelector('input[name^="image_id_"]').value;
if (suggestionCache.has(imageId)) continue;
const preSelectedSubject = questionSubjectMap.get(globalIndex);
if (preSelectedSubject) {
// Group by subject for batch processing
if (!subjectBatches.has(preSelectedSubject)) {
subjectBatches.set(preSelectedSubject, []);
}
subjectBatches.get(preSelectedSubject).push({ globalIndex, imageId });
} else {
// Auto-detect: process individually
autoDetectQueue.push({ globalIndex, imageId });
}
}
// Process batched requests (up to 8 per subject per batch)
for (const [subject, questions] of subjectBatches) {
for (let i = 0; i < questions.length; i += 8) {
const batch = questions.slice(i, i + 8);
const imageIds = batch.map(q => q.imageId);
// Create a single promise for the batch
const batchPromise = fetch('/get_topic_suggestions_batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_ids: imageIds, subject: subject })
})
.then(res => res.json())
.then(data => {
if (data.success && data.results) {
return data.results; // Map of imageId -> result
}
throw new Error(data.error || 'Batch request failed');
})
.catch(err => {
// Return fallback for all images in batch
const fallback = {};
imageIds.forEach(id => {
fallback[id] = { status: 'error', suggestions: ['Unclassified'], subject: subject, error: err.message };
});
return fallback;
});
// Store promise for each image that resolves to its specific result
batch.forEach(q => {
suggestionCache.set(q.imageId, batchPromise.then(results => {
const result = results[q.imageId];
if (result) {
return {
status: 'done',
suggestions: result.suggestions || ['Unclassified'],
subject: result.subject || subject,
otherSubjects: result.other_possible_subjects || []
};
}
return { status: 'done', suggestions: ['Unclassified'], subject: subject, otherSubjects: [] };
}));
});
}
}
// Process auto-detect questions individually
for (const q of autoDetectQueue) {
const promise = fetch('/get_topic_suggestions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_id: q.imageId })
})
.then(res => res.json())
.then(data => {
if (data.success) {
return {
status: 'done',
suggestions: data.suggestions || [data.chapter_title],
subject: data.subject,
otherSubjects: data.other_possible_subjects || []
};
}
throw new Error(data.error);
})
.catch(err => ({ status: 'error', error: err.message }));
suggestionCache.set(q.imageId, promise);
}
}
function loadTopicQuestion() {
const globalIndex = manualQuestionsList[currentManualIndex];
const fieldset = document.querySelector(`fieldset[data-question-index="${globalIndex}"]`);
const imageId = fieldset.querySelector('input[name^="image_id_"]').value;
const qNum = fieldset.querySelector('input[name^="question_number_"]').value;
const img = fieldset.querySelector('img');
const imgUrl = img ? img.src : '';
const imgAlt = img ? img.alt : '';
// Check if subject was pre-selected in slider, otherwise auto-detect
const preSelectedSubject = questionSubjectMap.get(globalIndex);
if (preSelectedSubject) {
setActiveSubject(preSelectedSubject);
} else {
const detectedSubjects = detectSubject(imgAlt);
setActiveSubject(detectedSubjects[0] || 'Biology');
}
// UI Updates
document.getElementById('topic_q_num').innerText = `#${qNum || (currentManualIndex + 1)}`;
const modalImg = document.getElementById('topic_q_img');
if (imgUrl) {
modalImg.src = imgUrl;
modalImg.style.display = 'inline-block';
} else {
modalImg.style.display = 'none';
}
// Update progress bar
const progressPercent = ((currentManualIndex + 1) / manualQuestionsList.length) * 100;
document.getElementById('topic_progress_bar').style.width = `${progressPercent}%`;
document.getElementById('topic_progress').innerText = `${currentManualIndex + 1}/${manualQuestionsList.length}`;
const chapterSpan = document.getElementById(`chapter_${imageId}`);
const currentChapter = chapterSpan ? chapterSpan.innerText : '';
document.getElementById('topic_input').value = (currentChapter && currentChapter !== 'N/A' && currentChapter !== 'Unclassified') ? currentChapter : '';
// Clear previous suggestions
document.getElementById('ai_suggestion_container').classList.add('d-none');
document.getElementById('ai_suggestion_chips').innerHTML = '';
document.getElementById('ai_error_msg').classList.add('d-none');
// Reset button
const btn = document.getElementById('btn_get_suggestion');
btn.disabled = false;
btn.innerHTML = `<span class="spinner-border spinner-border-sm d-none" role="status"></span> <i class="bi bi-stars"></i>`;
// Auto-load suggestions if topic is empty
if (!document.getElementById('topic_input').value) {
getAiSuggestion();
}
}
async function nextTopicQuestion() {
// Save current topic (fire and forget - non-blocking)
saveCurrentTopic();
if (currentManualIndex < manualQuestionsList.length - 1) {
currentManualIndex++;
loadTopicQuestion();
} else {
const modal2 = bootstrap.Modal.getInstance(document.getElementById('topicSelectionModal'));
modal2.hide();
showStatus("Manual classification completed for selected range.", "success");
}
}
function prevTopicQuestion() {
if (currentManualIndex > 0) {
currentManualIndex--;
loadTopicQuestion();
}
}
function saveCurrentTopic() {
const globalIndex = manualQuestionsList[currentManualIndex];
const fieldset = document.querySelector(`fieldset[data-question-index="${globalIndex}"]`);
const imageId = fieldset.querySelector('input[name^="image_id_"]').value;
const topic = document.getElementById('topic_input').value;
// Update UI immediately
const subjectSpan = document.getElementById(`subject_${imageId}`);
if(subjectSpan) subjectSpan.innerText = manualSubject;
const chapterSpan = document.getElementById(`chapter_${imageId}`);
if(chapterSpan) chapterSpan.innerText = topic || 'Unclassified';
// Save to backend in background (fire and forget)
fetch('/classified/update_single', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
image_id: imageId,
subject: manualSubject,
chapter: topic
})
}).catch(e => console.error("Failed to save topic", e));
}
async function getAiSuggestion() {
const globalIndex = manualQuestionsList[currentManualIndex];
const fieldset = document.querySelector(`fieldset[data-question-index="${globalIndex}"]`);
const imageId = fieldset.querySelector('input[name^="image_id_"]').value;
const btn = document.getElementById('btn_get_suggestion');
const container = document.getElementById('ai_suggestion_container');
const chipsContainer = document.getElementById('ai_suggestion_chips');
const errorDiv = document.getElementById('ai_error_msg');
btn.disabled = true;
btn.querySelector('.spinner-border').classList.remove('d-none');
errorDiv.classList.add('d-none');
try {
let result;
if (suggestionCache.has(imageId)) {
result = await suggestionCache.get(imageId);
} else {
// Check if subject was pre-selected in slider
const preSelectedSubject = questionSubjectMap.get(globalIndex);
const requestBody = preSelectedSubject
? { image_id: imageId, subject: preSelectedSubject }
: { image_id: imageId }; // No subject - AI will detect
const promise = fetch('/get_topic_suggestions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
})
.then(res => res.json())
.then(data => {
if (data.success) return {
status: 'done',
suggestions: data.suggestions || [data.chapter_title],
subject: data.subject, // AI-detected subject
otherSubjects: data.other_possible_subjects || []
};
else throw new Error(data.error);
})
.catch(err => ({ status: 'error', error: err.message }));
suggestionCache.set(imageId, promise);
result = await promise;
}
if (result.status === 'done') {
// Update subject from AI response
if (result.subject) {
setActiveSubject(result.subject);
}
container.classList.remove('d-none');
chipsContainer.innerHTML = '';
// Show other possible subjects if detected
if (result.otherSubjects && result.otherSubjects.length > 0) {
const otherLabel = document.createElement('span');
otherLabel.className = 'badge bg-secondary me-2';
otherLabel.innerText = `Also: ${result.otherSubjects.join(', ')}`;
otherLabel.title = 'This question may also belong to these subjects';
chipsContainer.appendChild(otherLabel);
}
result.suggestions.forEach(suggestion => {
const chip = document.createElement('button');
chip.className = 'btn btn-outline-info btn-sm rounded-1';
chip.innerText = suggestion;
chip.onclick = () => {
document.getElementById('topic_input').value = suggestion;
};
chipsContainer.appendChild(chip);
});
// Auto-fill input if empty and we have a primary suggestion
const currentVal = document.getElementById('topic_input').value;
if (!currentVal && result.suggestions.length > 0) {
document.getElementById('topic_input').value = result.suggestions[0];
}
} else {
throw new Error(result.error);
}
} catch (e) {
errorDiv.innerText = `Error: ${e.message}`;
errorDiv.classList.remove('d-none');
} finally {
btn.disabled = false;
btn.querySelector('.spinner-border').classList.add('d-none');
}
}
document.addEventListener('DOMContentLoaded', () => {
initializeTomSelect(); // Initialize Tom Select here
loadSettings();
setupEventListeners();
setupRangeToggles();
});
</script>
{% endblock %}