diff --git a/ADMIN_UI_REDESIGN_PLAN.md b/ADMIN_UI_REDESIGN_PLAN.md new file mode 100644 index 0000000..c377942 --- /dev/null +++ b/ADMIN_UI_REDESIGN_PLAN.md @@ -0,0 +1,700 @@ +# Admin UI Redesign Plan + +> **Document Type:** UI/UX Improvement Plan +> **Current System:** IRT Bank Soal Admin +> **Date:** 2026-06-15 +> **Status:** Draft for Review + +--- + +## Executive Summary + +The current admin interface is built from a **developer/system perspective** rather than a **human/admin perspective**. This plan outlines a complete redesign to make the admin dashboard intuitive, workflow-oriented, and human-readable. + +### Current Problems + +| Problem | Impact | +|---------|--------| +| Navigation uses technical terms | Admins don't understand menu labels | +| Multiple unrelated features in one view | Confusing, overwhelming | +| Data displayed in database terminology | Hard to interpret scores | +| No clear workflow guidance | Admin doesn't know what to do first | +| No contextual help | Unclear what each feature does | +| Mixed concern pages | AI + Questions + Calibration all on one page | + +--- + +## Current State Analysis + +### Current Navigation (System POV) + +``` +├── Dashboard (raw counts) +├── Websites (technical list) +├── Tryout Import (system term) +├── Data Hierarchy (developer term) +├── Basis Items (technical term) +├── Calibration Status (technical term) +├── Item Statistics (technical term) +├── Session Overview (technical term) +├── AI Playground (slang) +└── Password Info (unrelated) +``` + +### Current Issues + +1. **Naming Problems:** + - "Basis Items" → Should be "Question Templates" or "Original Questions" + - "Data Hierarchy" → Should be "Data Overview" or "Website Structure" + - "Tryout Import" → Should be "Import Questions" + - "Calibration Status" → Should be "Question Quality" or "Difficulty Analysis" + - "AI Playground" → Should be "Generate AI Questions" + - "Session Overview" → Should be "Student Attempts" + +2. **Dashboard Issues:** + - Shows raw database counts (Tryouts, Items, Sessions) + - No meaningful KPIs or actionable insights + - No visual indicators of system health + +3. **Page Organization:** + - Too many technical terms on each page + - Tables show raw data without explanation + - No breadcrumbs or context + +--- + +## Proposed Redesign + +### New Navigation Structure (Human POV) + +``` +🎯 Dashboard (Home) +├── System Health Summary +├── Quick Actions +└── Recent Activity + +📋 Questions Bank +├── All Questions (list + search) +├── Question Templates (basis items) +├── Import Questions (from Excel/JSON) +└── Question Quality (calibration status) + +🤖 AI Generation +├── Generate New Questions +├── Review Generated Questions +└── Generation History + +📊 Exams (Tryouts) +├── All Exams (list) +├── Exam Settings (scoring mode) +├── Student Attempts +└── Normalization Settings + +📈 Reports +├── Student Performance Report +├── Item Analysis Report +├── Exam Comparison Report +└── Scheduled Reports + +⚙️ Settings +├── Websites Management +├── Account Settings +└── System Info +``` + +### Navigation Mapping Table + +| Current Menu | New Menu | Reason | +|-------------|----------|--------| +| Dashboard | 🎯 Dashboard | Home base | +| Websites | ⚙️ Settings > Websites | Configuration | +| Tryout Import | 📋 Questions > Import Questions | Workflow step | +| Data Hierarchy | ⚙️ Settings | Admin settings | +| Basis Items | 📋 Questions > Question Templates | Content management | +| Calibration Status | 📋 Questions > Question Quality | Quality assurance | +| Item Statistics | 📈 Reports > Item Analysis | Reporting | +| Session Overview | 📊 Exams > Student Attempts | Workflow | +| AI Playground | 🤖 AI Generation | Dedicated feature | +| Password Info | ⚙️ Settings > Account | Configuration | + +--- + +## Detailed Page Redesigns + +### 1. Dashboard (Home) — `GET /admin/dashboard` + +**Current State:** +```python +# Shows raw counts +body = f""" +

Signed in as {admin}.

+
+
Tryouts{tryouts}
+
Items{items}
+
Sessions{sessions}
+
Completed Sessions{completed}
+
+

Open AI Playground

+""" +``` + +**Proposed State:** +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Good Morning, Admin! 👋 │ +│ Last login: Today at 9:00 AM │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 📊 System Overview │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ 5 Exams │ │ 450 │ │ 1,234 │ │ 89% │ │ +│ │ Active │ │ Questions│ │ Students │ │ Avg Score│ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ +│ │ +│ ⚠️ Attention Needed │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ ⚡ 23 questions need calibration (do this first!) │ │ +│ │ 📝 5 AI-generated questions pending review │ │ +│ │ 📥 2 exam exports ready for download │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ 🚀 Quick Actions │ +│ [Import Questions] [Generate AI] [View Reports] [Add Exam] │ +│ │ +│ 📈 Recent Activity │ +│ • 12 students completed "UTBK 2024" in last hour │ +│ • 3 new questions generated via AI │ +│ • Calibration completed for "SIMAK UI" (95% ready) │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Key Changes:** +- Human-readable greeting with time +- Meaningful metrics (not raw counts) +- Actionable alerts with urgency indicators +- Quick action buttons with clear labels +- Recent activity feed + +--- + +### 2. Questions Bank — Questions List (`/admin/questions`) + +**Current State:** Table with raw database fields + +**Proposed State:** +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 📋 Question Bank │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ [Search: "matematika" ] [Filter ▼] [🔍] │ +│ │ +│ Showing 450 questions across 5 exams │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ ☐ │ Q1 │ Berapakah hasil dari 2 + 2? │ │ +│ │ │ │ ▸ Easy (p=0.85) | Used 234x | SIMAK UI │ │ +│ ├────┼─────┼──────────────────────────────────────────────┤ │ +│ │ ☐ │ Q5 │ Hitung integral dari x² dx... │ │ +│ │ │ │ ▸ Medium (p=0.45) | Used 89x | UTBK 2024 │ │ +│ ├────┼─────┼──────────────────────────────────────────────┤ │ +│ │ ☐ │ Q12 │ Jelaskan teori evolusi... │ │ +│ │ │ │ ▸ Hard (p=0.22) | Used 45x | ONM 2024 │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ [Delete Selected] [Export Selected] [Edit Selected] │ +│ │ +│ 📄 Page 1 of 23 [<] [1] [2] [3] ... [>] │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Key Changes:** +- Question preview in list (not just ID) +- Human-readable difficulty (Easy/Medium/Hard) +- Usage count (how many times used) +- Which exam it belongs to +- Visual indicators for difficulty colors + +--- + +### 3. Question Templates — (`/admin/templates`) + +**Current State:** "Basis Items" - confusing technical term + +**Proposed State:** +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 📝 Question Templates │ +│ (Original questions used to generate AI variants) │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Templates are your "master questions" that AI uses to │ +│ create different versions with varying difficulty levels. │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 📝 Template #45: "Berapakah hasil dari 2 + 2?" │ │ +│ │ AI Generated Variants: 12 (3 easy, 6 medium, 3 hard) │ │ +│ │ [View All Variants] [Generate More] [Edit] │ │ +│ ├─────────────────────────────────────────────────────────┤ │ +│ │ 📝 Template #89: "Hitung integral dari x² dx..." │ │ +│ │ AI Generated Variants: 8 (2 easy, 4 medium, 2 hard) │ │ +│ │ [View All Variants] [Generate More] [Edit] │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ [+ Create New Template] │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Key Changes:** +- Clear explanation of what templates are +- Visual representation of variants +- Easy action buttons +- "Create New Template" prominent + +--- + +### 4. AI Generation — (`/admin/ai-generation`) + +**Current State:** "AI Playground" - informal, confusing tabs + +**Proposed State:** +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 🤖 AI Question Generator │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Generate new question variants using AI. │ +│ Select a template question and specify difficulty level. │ +│ │ +│ ┌──────────────────────┐ ┌──────────────────────┐ │ +│ │ 📝 Select Template │ │ 🎯 Target Difficulty │ │ +│ │ [Dropdown: Questions]│ │ ○ Easy (p > 0.70) │ │ +│ └──────────────────────┘ │ ● Medium (p ≈ 0.50) │ │ +│ │ ○ Hard (p < 0.30) │ │ +│ ┌──────────────────────┐ └──────────────────────┘ │ +│ │ 📝 How many variants?│ │ +│ │ [1] [3] [5] [10] │ ┌──────────────────────┐ │ +│ └──────────────────────┘ │ 💬 Additional Notes │ │ +│ │ [Optional context...] │ │ +│ └──────────────────────┘ │ +│ │ +│ [🚀 Generate Questions] │ +│ │ +├─────────────────────────────────────────────────────────────────┤ +│ 📋 Generated Questions (Pending Review) │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 🔄 Generating... 2 of 5 questions completed │ │ +│ │ [████████░░] 60% │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ✅ Generated & Ready for Review: │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ ✓ Variant #123: "Berapakah hasil dari 3 + 4?" (Easy) │ │ +│ │ [Preview] [Approve] [Regenerate] [Reject] │ │ +│ ├─────────────────────────────────────────────────────────┤ │ +│ │ ✓ Variant #124: "Hitung hasil dari 5 + 6..." (Easy) │ │ +│ │ [Preview] [Approve] [Regenerate] [Reject] │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Key Changes:** +- Clear, labeled sections +- Radio buttons for difficulty (not dropdown) +- Progress indicator during generation +- Clear action buttons (Approve/Reject/Regenerate) +- Explanation of what each option means + +--- + +### 5. Question Quality (Calibration) — (`/admin/question-quality`) + +**Current State:** "Calibration Status" - technical IRT terminology + +**Proposed State:** +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 📊 Question Quality Dashboard │ +│ (Shows how well each question is "calibrated" for testing) │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 📖 What is Question Quality? │ +│ Questions become "calibrated" after many students answer them. │ +│ Well-calibrated questions give accurate student scores. │ +│ │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Overall Quality: ████████░░ 78% │ +│ (78 out of 100 questions are ready for adaptive testing) │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 📋 By Exam │ │ +│ │ │ │ +│ │ UTBK 2024 ████████████ 95% ✓ Ready │ │ +│ │ SIMAK UI █████████░░░ 72% ⚠️ Partial │ │ +│ │ ONM 2024 ██████░░░░░░ 45% ❌ Needs more data│ │ +│ │ PASIAD Selection ████████████ 100% ✓ Excellent │ │ +│ │ │ │ +│ │ [Run Calibration for All Exams] │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ Questions Needing Attention: │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ ⚠️ Q45 - "Hitung integral..." only answered 12 times │ │ +│ │ Need at least 100 answers to calibrate properly. │ │ +│ │ Current estimate: p=0.42 (might change) │ │ +│ ├─────────────────────────────────────────────────────────┤ │ +│ │ ❌ Q78 - "Teori relativitas..." has conflicting answers │ │ +│ │ Check if correct answer is correct in database. │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Key Changes:** +- Clear explanation of what calibration means +- Progress bars for visual understanding +- Status indicators (✓ Ready, ⚠️ Partial, ❌ Needs data) +- Specific recommendations for action +- User-friendly difficulty explanation + +--- + +### 6. Student Attempts — (`/admin/student-attempts`) + +**Current State:** "Session Overview" - raw database table + +**Proposed State:** +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 📊 Student Attempts │ +│ (See how students performed on each exam) │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Filter: [Select Exam ▼] [Status ▼] [Date Range ▼] [Search] │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 📋 UTBK 2024 Results │ │ +│ │ │ │ +│ │ Participants: 1,234 students │ │ +│ │ Average Score (NM): 672 / 1000 │ │ +│ │ Average Score (NN): 505 / 1000 │ │ +│ │ Completion Rate: 98% (1,209 completed) │ │ +│ │ │ │ +│ │ Score Distribution: │ │ +│ │ ▁▂▃▇█▇▃▂▁ (bell curve centered around 500) │ │ +│ │ 200 300 400 500 600 700 800 900 1000 │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ Recent Attempts: │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 👤 John Doe (john@example.com) │ │ +│ │ Exam: UTBK 2024 | Completed: Today, 2:30 PM │ │ +│ │ Score: NM=720 (85th percentile) | NN=645 │ │ +│ │ Correct: 28/30 | Time: 45 minutes │ │ +│ ├─────────────────────────────────────────────────────────┤ │ +│ │ 👤 Jane Smith (jane@example.com) │ │ +│ │ Exam: SIMAK UI | Completed: Today, 1:15 PM │ │ +│ │ Score: NM=580 (45th percentile) | NN=485 │ │ +│ │ Correct: 22/30 | Time: 52 minutes │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ [Export All Results] [View Detailed Report] [Schedule Report] │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Key Changes:** +- Grouped by exam with summary stats +- Human-readable student info +- Percentile ranking +- Score distribution visualization +- Clear action buttons + +--- + +### 7. Reports — (`/admin/reports`) + +**Proposed State:** +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 📈 Reports │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Generate detailed analysis reports for exams and students. │ +│ │ +│ ┌─────────────────────────┐ ┌─────────────────────────┐ │ +│ │ 📊 Student Performance │ │ 📋 Item Analysis │ │ +│ │ │ │ │ │ +│ │ See individual student │ │ Analyze question │ │ +│ │ scores, rankings, and │ │ difficulty, validity, │ │ +│ │ detailed breakdowns. │ │ and discrimination. │ │ +│ │ │ │ │ │ +│ │ [Generate Report] │ │ [Generate Report] │ │ +│ └─────────────────────────┘ └─────────────────────────┘ │ +│ │ +│ ┌─────────────────────────┐ ┌─────────────────────────┐ │ +│ │ 📈 Exam Comparison │ │ 📅 Scheduled Reports │ │ +│ │ │ │ │ │ +│ │ Compare scores across │ │ Set up automatic │ │ +│ │ different exams or │ │ weekly/monthly reports │ │ +│ │ time periods. │ │ delivery. │ │ +│ │ │ │ │ │ +│ │ [Generate Report] │ │ [Manage Schedules] │ │ +│ └─────────────────────────┘ └─────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Key Changes:** +- Card-based layout with icons +- Clear description of each report type +- Visual cards instead of dropdowns +- Scheduled reports as first-class feature + +--- + +## Implementation Phases + +### Phase 1: Navigation Redesign (Foundation) + +**Files to modify:** +- `app/admin_web.py` - Update `ADMIN_NAV_ITEMS` +- Create new route handlers + +**Steps:** +1. Rename navigation items with human labels +2. Create new route structure +3. Implement breadcrumb system +4. Add help tooltips + +**New Navigation Structure:** +```python +ADMIN_NAV_ITEMS = ( + ("Dashboard", "/admin/dashboard", ("/admin/dashboard",)), + ("Questions", "/admin/questions", ("/admin/questions", "/admin/templates")), + ("AI Generator", "/admin/ai-generation", ("/admin/ai-generation",)), + ("Exams", "/admin/exams", ("/admin/exams", "/admin/student-attempts")), + ("Reports", "/admin/reports", ("/admin/reports",)), + ("Settings", "/admin/settings", ("/admin/settings",)), +) +``` + +--- + +### Phase 2: Dashboard Overhaul + +**New Dashboard Components:** +1. Greeting with user name and time +2. System health cards (with meaningful metrics) +3. Action alerts section +4. Quick action buttons +5. Recent activity feed + +**Files to modify:** +- `dashboard_view()` function +- `_render_admin_page()` for dashboard-specific layout + +--- + +### Phase 3: Questions Section + +**New Pages:** +1. `/admin/questions` - List all questions with search/filter +2. `/admin/questions/{id}` - Question detail view +3. `/admin/templates` - Question templates (formerly basis items) +4. `/admin/questions/import` - Import wizard + +**Key UI Components:** +- Question preview cards +- Difficulty badges (Easy/Medium/Hard) +- Color-coded indicators +- Inline search + +--- + +### Phase 4: AI Generation Section + +**New Pages:** +1. `/admin/ai-generation` - Main generation interface +2. `/admin/ai-generation/review` - Review pending variants +3. `/admin/ai-generation/history` - Generation history + +**Key UI Components:** +- Template selector with preview +- Difficulty radio buttons +- Generation progress bar +- Batch approve/reject actions + +--- + +### Phase 5: Exams Section + +**New Pages:** +1. `/admin/exams` - List all exams +2. `/admin/exams/{id}/settings` - Exam configuration +3. `/admin/student-attempts` - Student attempts list +4. `/admin/normalization` - Normalization settings + +**Key UI Components:** +- Exam cards with status indicators +- Student attempt cards +- Score distribution visualization + +--- + +### Phase 6: Reports Section + +**New Pages:** +1. `/admin/reports` - Report dashboard +2. `/admin/reports/student-performance` - Student report +3. `/admin/reports/item-analysis` - Item report +4. `/admin/reports/exam-comparison` - Comparison report +5. `/admin/reports/scheduled` - Scheduled reports + +**Key UI Components:** +- Report type cards +- Export format options +- Schedule configuration + +--- + +## Technical Implementation Notes + +### CSS Class Naming Convention + +```css +/* Old: System POV */ +.stat { } +.grid { } +.table-wrap { } + +/* New: Human POV */ +.dashboard-hero { } +.metric-card { } +.question-list { } +.difficulty-badge { } +.difficulty-easy { background: #dcfce7; } +.difficulty-medium { background: #fef3c7; } +.difficulty-hard { background: #fee2e2; } +``` + +### Helper Functions to Create + +```python +# In admin_web.py + +def _render_question_card(item: Item) -> str: + """Render a human-readable question card.""" + difficulty = _human_difficulty(item.ctt_p) + difficulty_color = _difficulty_color(item.ctt_p) + return f""" +
+
{difficulty}
+
{escape(item.stem[:100])}...
+
+ Used {item.calibration_sample_size}x | + {item.tryout_id} +
+
+ """ + +def _human_difficulty(p_value: float | None) -> str: + """Convert p-value to human-readable difficulty.""" + if p_value is None: + return "Unknown" + if p_value > 0.70: + return "Easy" + elif p_value >= 0.30: + return "Medium" + else: + return "Hard" + +def _difficulty_color(p_value: float | None) -> str: + """Get color class for difficulty badge.""" + if p_value is None: + return "difficulty-unknown" + if p_value > 0.70: + return "difficulty-easy" + elif p_value >= 0.30: + return "difficulty-medium" + else: + return "difficulty-hard" +``` + +### Responsive Design + +```css +/* Mobile-friendly layout */ +@media (max-width: 768px) { + .admin-layout { + flex-direction: column; + } + + .sidebar-nav { + display: flex; + overflow-x: auto; + padding: 8px; + } + + .metric-cards { + grid-template-columns: repeat(2, 1fr); + } +} +``` + +--- + +## Success Metrics + +| Metric | Target | +|--------|--------| +| Time to complete common task | Reduce by 50% | +| Admin confusion score | < 2/5 | +| Support tickets about UI | Reduce by 80% | +| Feature discovery rate | > 90% can find features | + +--- + +## Appendix: Terminology Mapping + +| System Term | Human Term | +|------------|------------| +| Tryout | Exam / Test | +| Item | Question | +| Basis Item | Question Template / Original Question | +| Session | Student Attempt | +| Calibration | Question Quality / Difficulty Analysis | +| IRT | Adaptive Scoring | +| CTT | Standard Scoring | +| Bobot | Weight / Point Value | +| NM | Raw Score | +| NN | Normalized Score | +| p-value | Difficulty Score | +| Theta | Student Ability Score | + +--- + +## Files to Modify + +| File | Changes | +|------|---------| +| `app/admin_web.py` | Complete UI rewrite | +| `app/admin.py` | May need minor updates | +| `requirements.txt` | Add any new frontend deps (if needed) | + +--- + +## Next Steps + +1. [ ] Review and approve this plan +2. [ ] Prioritize phases (suggest starting with Phase 1 & 2) +3. [ ] Create mockups/wireframes for key pages +4. [ ] Implement Phase 1: Navigation & Dashboard +5. [ ] User testing with admin users +6. [ ] Iterate based on feedback diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..651bc4d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements and install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Run migrations and start the app +CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"] diff --git a/PROJECT_UNDERSTANDING.md b/PROJECT_UNDERSTANDING.md new file mode 100644 index 0000000..e96ecc5 --- /dev/null +++ b/PROJECT_UNDERSTANDING.md @@ -0,0 +1,612 @@ +# Project Understanding: IRT-Powered Adaptive Question Bank System + +> **Project Name:** IRT Bank Soal +> **Version:** 1.0.0 +> **Last Updated:** 2026-06-15 +> **Repository:** https://git.backoffice.biz.id/dwindown/yellow-bank-soal + +--- + +## Table of Contents + +1. [Executive Summary](#executive-summary) +2. [Project Purpose](#project-purpose) +3. [Tech Stack](#tech-stack) +4. [Project Structure](#project-structure) +5. [Core Concepts](#core-concepts) +6. [Data Models](#data-models) +7. [API Endpoints](#api-endpoints) +8. [Key Services](#key-services) +9. [Scoring Formulas](#scoring-formulas) +10. [Configuration](#configuration) +11. [Workflows](#workflows) +12. [Deployment](#deployment) + +--- + +## Executive Summary + +This is a **FastAPI-based backend system** for managing adaptive assessment/tryout exams with sophisticated scoring capabilities. The system supports both **Classical Test Theory (CTT)** and **Item Response Theory (IRT)** scoring methods, with multi-website support for WordPress integration. + +### Key Features + +| Feature | Description | +|---------|-------------| +| **CTT Scoring** | Classical Test Theory with exact Excel formula compatibility | +| **IRT Support** | Item Response Theory (1PL Rasch model) for adaptive testing | +| **Multi-Site** | Single backend serving multiple WordPress sites | +| **AI Generation** | Automatic question variant generation via OpenRouter | +| **Excel Import/Export** | Bulk import/export questions from Excel files | +| **Adaptive Testing** | Computer Adaptive Testing (CAT) with theta estimation | +| **Normalization** | Static, dynamic, or hybrid score normalization | + +--- + +## Project Purpose + +The system replaces traditional fixed-difficulty exams with an **adaptive question bank** that: + +1. **Measures student ability accurately** using IRT theta estimation +2. **Provides comparable scores** across different exam sessions via normalization +3. **Generates new questions** using AI when needed +4. **Integrates with WordPress** LMS platforms for student access +5. **Reduces exam fraud** by delivering different question variants to each student + +--- + +## Tech Stack + +### Core Technologies + +``` +Framework: FastAPI >= 0.104.1 +Server: Uvicorn >= 0.24.0 +Database: PostgreSQL + SQLAlchemy 2.0 (async) +ORM: SQLAlchemy >= 2.0.23 +Driver: asyncpg >= 0.29.0 +Migrations: Alembic >= 1.13.0 +Validation: Pydantic >= 2.5.0 +``` + +### Data Processing + +``` +Excel: openpyxl >= 3.1.2, pandas >= 2.1.4 +Math/Science: numpy >= 1.26.2, scipy >= 1.11.4 +``` + +### External Integrations + +``` +AI: OpenAI >= 1.6.1 (OpenRouter API) +Task Queue: Celery >= 5.3.6, Redis >= 5.0.1 +Admin Panel: FastAPI-Admin >= 1.0.0 +``` + +--- + +## Project Structure + +``` +yellow-bank-soal/ +├── app/ +│ ├── __init__.py +│ ├── main.py # FastAPI app entry point +│ ├── admin.py # FastAPI Admin configuration +│ ├── admin_web.py # Admin web interface +│ ├── database.py # Database configuration & session +│ │ +│ ├── api/ +│ │ └── v1/ +│ │ ├── __init__.py +│ │ └── session.py # Adaptive session endpoints +│ │ +│ ├── core/ +│ │ ├── __init__.py +│ │ ├── auth.py # Authentication & authorization +│ │ ├── config.py # Settings from environment +│ │ └── rate_limit.py # Rate limiting +│ │ +│ ├── models/ +│ │ ├── __init__.py +│ │ ├── ai_generation_run.py +│ │ ├── item.py # Question items +│ │ ├── report_schedule.py +│ │ ├── session.py # Student tryout sessions +│ │ ├── tryout.py # Tryout configurations +│ │ ├── tryout_import_snapshot.py +│ │ ├── tryout_snapshot_question.py +│ │ ├── tryout_stats.py # Normalization statistics +│ │ ├── user.py +│ │ ├── user_answer.py # Student responses +│ │ └── website.py +│ │ +│ ├── routers/ +│ │ ├── __init__.py +│ │ ├── admin.py # Admin-only endpoints +│ │ ├── ai.py # AI generation endpoints +│ │ ├── import_export.py # Excel import/export +│ │ ├── reports.py # Report generation +│ │ ├── sessions.py # Session management +│ │ ├── tryouts.py # Tryout configuration +│ │ └── wordpress.py # WordPress integration +│ │ +│ ├── schemas/ # Pydantic request/response models +│ │ ├── __init__.py +│ │ ├── ai.py +│ │ ├── report.py +│ │ ├── session.py +│ │ ├── tryout.py +│ │ └── wordpress.py +│ │ +│ └── services/ +│ ├── __init__.py +│ ├── ai_generation.py # OpenRouter integration +│ ├── cat_selection.py # Computer Adaptive Testing +│ ├── config_management.py +│ ├── ctt_scoring.py # CTT scoring engine +│ ├── excel_import.py # Excel parsing +│ ├── irt_calibration.py # IRT calibration +│ ├── normalization.py +│ ├── reporting.py +│ ├── tryout_json_import.py +│ └── wordpress_auth.py +│ +├── alembic/ # Database migrations +│ ├── env.py +│ ├── script.py.mako +│ └── versions/ +│ +├── tests/ # Unit & integration tests +│ ├── test_auth_scope.py +│ ├── test_auth_tokens.py +│ ├── test_model_mappings.py +│ ├── test_normalization.py +│ ├── test_operational_hardening.py +│ ├── test_route_wiring.py +│ ├── test_security_regressions.py +│ └── test_tryout_json_import.py +│ +├── requirements.txt +├── alembic.ini +├── irt_1pl_mle.py # Standalone IRT MLE script +├── PRD.md # Product Requirements Document +├── project-brief.md # Technical specification +└── handoff.md # Project handoff context +``` + +--- + +## Core Concepts + +### 1. Tryout (Exam) + +A **Tryout** represents a complete exam/test with configurable behavior: + +```python +scoring_mode: "ctt" | "irt" | "hybrid" +selection_mode: "fixed" | "adaptive" | "hybrid" +normalization_mode: "static" | "dynamic" | "hybrid" +``` + +### 2. Item (Question) + +An **Item** represents a single question with: + +- **Content**: stem (question text), options (A/B/C/D), correct_answer +- **CTT Parameters**: p-value (difficulty), bobot (weight) +- **IRT Parameters**: b (difficulty), se (standard error) +- **Metadata**: slot position, difficulty level, AI generation info + +### 3. Session (Student Attempt) + +A **Session** tracks a student's attempt: + +- Links student (`wp_user_id`) to a Tryout +- Records all answers via `UserAnswer` records +- Stores computed scores: NM, NN, theta + +### 4. Website (Multi-Tenant) + +The system supports **multiple WordPress websites** from a single backend: + +- Each website has isolated data +- Authenticated via `X-Website-ID` header +- WordPress JWT tokens for authentication + +--- + +## Data Models + +### Entity Relationship Diagram + +```mermaid +erDiagram + Website ||--o{ Tryout : "hosts" + Website ||--o{ User : "contains" + Website ||--o{ Session : "serves" + Website ||--o{ Item : "contains" + + Tryout ||--o{ Item : "contains" + Tryout ||--o{ Session : "has" + Tryout ||--o{ TryoutStats : "tracks" + + Session ||--o{ UserAnswer : "contains" + Session ||--o{ User : "belongs to" + + Item ||--o{ UserAnswer : "answered by" + Item ||--o{ Item : "has variants" + + AIGenerationRun ||--o{ Item : "generates" +``` + +### Model Summary + +| Model | Purpose | Key Fields | +|-------|---------|------------| +| `Website` | Multi-tenant isolation | domain, wordpress_url | +| `User` | WordPress user mapping | wp_user_id, website_id | +| `Tryout` | Exam configuration | scoring_mode, selection_mode, normalization_mode | +| `Item` | Question | stem, options, ctt_p, ctt_bobot, irt_b, irt_se | +| `Session` | Student attempt | session_id, NM, NN, theta | +| `UserAnswer` | Single response | response, is_correct, bobot_earned | +| `TryoutStats` | Normalization data | participant_count, rataan, sb | +| `AIGenerationRun` | AI generation batch | model, status, items_generated | + +--- + +## API Endpoints + +### Public API (via `/api/v1`) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/tryout/{tryout_id}/config` | Get tryout configuration | +| `PUT` | `/tryout/{tryout_id}/normalization` | Update normalization settings | +| `GET` | `/tryout/` | List tryouts for website | +| `GET` | `/tryout/{tryout_id}/calibration-status` | Get IRT calibration status | +| `POST` | `/tryout/{tryout_id}/calibrate` | Trigger IRT calibration | +| `POST` | `/session/` | Create new session | +| `GET` | `/session/{session_id}` | Get session details | +| `POST` | `/session/{session_id}/complete` | Submit answers, calculate scores | + +### Admin API (requires admin role) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `POST` | `/ai/generate` | Generate AI questions | +| `POST` | `/import/excel` | Import questions from Excel | +| `GET` | `/export/excel/{tryout_id}` | Export questions to Excel | +| `GET` | `/reports/*` | Generate various reports | + +### Adaptive Session API (via `/api/v1/session`) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `POST` | `/adaptive/start` | Start adaptive session | +| `POST` | `/adaptive/respond` | Submit answer, get next item | +| `POST` | `/adaptive/complete` | Complete adaptive session | + +--- + +## Key Services + +### 1. CTT Scoring Engine (`ctt_scoring.py`) + +Implements Classical Test Theory scoring with exact Excel formulas. + +**Key Functions:** +- `calculate_ctt_p()` - Difficulty: p = Σ Benar / Total Peserta +- `calculate_ctt_bobot()` - Weight: Bobot = 1 - p +- `calculate_ctt_nm()` - Raw Score: NM = (Total_Bobot / Total_Bobot_Max) × 1000 +- `calculate_ctt_nn()` - Normalized: NN = 500 + 100 × ((NM - Rataan) / SB) +- `categorize_difficulty()` - Categorize by p-value +- `update_tryout_stats()` - Incrementally update normalization stats + +### 2. IRT Calibration (`irt_calibration.py`) + +Implements Item Response Theory (1PL Rasch model) for adaptive testing. + +**Key Functions:** +- `estimate_theta_mle()` - MLE theta estimation for students +- `estimate_b()` - IRT difficulty calibration for items +- `calibrate_item()` - Calibrate single item from response data +- `calibrate_all()` - Batch calibrate all items in tryout +- `calculate_fisher_information()` - Fisher information for item selection + +**Parameters:** +- θ (theta): Student ability [-3, +3] +- b: Item difficulty [-3, +3] +- Probability: P(θ) = 1 / (1 + exp(-(θ - b))) + +### 3. AI Generation (`ai_generation.py`) + +Generates question variants using OpenRouter API. + +**Key Functions:** +- `generate_question()` - Generate single question via OpenRouter +- `generate_questions_batch()` - Generate multiple questions +- `save_ai_question()` - Save generated question to database +- `check_cache_reuse()` - Check for reusable similar questions + +**Models Supported:** +- Qwen 2.5 32B (balanced) +- Mistral Small (low cost) +- Llama 3.3 70B (premium) + +### 4. Excel Import/Export (`excel_import.py`) + +Bulk import/export questions from Excel files. + +**Key Functions:** +- `parse_excel_import()` - Parse Excel file to items +- `bulk_insert_items()` - Insert parsed items to database +- `export_questions_to_excel()` - Export tryout to Excel + +### 5. CAT Selection (`cat_selection.py`) + +Computer Adaptive Testing item selection algorithm. + +**Key Functions:** +- `select_next_item()` - Select next item based on theta estimate +- `calculate_theta_update()` - Update theta after response +- `check_termination()` - Check if test should end + +--- + +## Scoring Formulas + +### CTT (Classical Test Theory) + +Based on exact client Excel formulas: + +```python +# STEP 1: Tingkat Kesukaran (p-value) +p = Σ Benar / Total Peserta + +# STEP 2: Bobot (Weight) +Bobot = 1 - p + +# STEP 3: Total Benar per Siswa +Total_Benar = count of correct answers + +# STEP 4: Total Bobot Earned per Siswa +Total_Bobot_Siswa = Σ Bobot for each correct answer + +# STEP 5: Nilai Mentah (Raw Score) +NM = (Total_Bobot_Siswa / Total_Bobot_Max) × 1000 + +# STEP 6: Nilai Nasional (Normalized Score) +NN = 500 + 100 × ((NM - Rataan) / SB) +``` + +### IRT (Item Response Theory) + +1PL Rasch Model: + +```python +# Probability of correct response +P(θ, b) = 1 / (1 + exp(-(θ - b))) + +# Log-likelihood for MLE +LL = Σ [u_i × log(P) + (1-u_i) × log(1-P)] + +# Theta estimation via MLE +θ_mle = argmax_θ LL(θ) +``` + +### Difficulty Categories (CTT Standard) + +| p-value | Category | Description | +|---------|----------|-------------| +| p < 0.30 | Sulit | Difficult | +| 0.30 ≤ p ≤ 0.70 | Sedang | Medium | +| p > 0.70 | Mudah | Easy | + +--- + +## Configuration + +### Environment Variables + +```bash +# Database +DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/irt_bank_soal + +# FastAPI +SECRET_KEY=your-secret-key-here +ENVIRONMENT=development # development, staging, production +ENABLE_ADMIN=true +ADMIN_USERNAME=admin +ADMIN_PASSWORD=your-password + +# OpenRouter (AI) +OPENROUTER_API_KEY=sk-or-v1-xxx +OPENROUTER_MODEL_QWEN=qwen/qwen2.5-32b-instruct +OPENROUTER_MODEL_CHEAP=mistralai/mistral-small-2603 +OPENROUTER_MODEL_LLAMA=meta-llama/llama-3.3-70b-instruct + +# Redis/Celery +REDIS_URL=redis://localhost:6379/0 +CELERY_BROKER_URL=redis://localhost:6379/0 + +# CORS +ALLOWED_ORIGINS=http://localhost:3000,https://yourdomain.com +``` + +### Tryout Configuration Options + +```python +# Scoring Mode +scoring_mode = "ctt" # Classical Test Theory +scoring_mode = "irt" # Item Response Theory +scoring_mode = "hybrid" # Both (IRT for calibration, CTT for scoring) + +# Selection Mode +selection_mode = "fixed" # Fixed order questions +selection_mode = "adaptive" # Computer Adaptive Testing +selection_mode = "hybrid" # Start fixed, switch to adaptive + +# Normalization Mode +normalization_mode = "static" # Use hardcoded rataan/sb +normalization_mode = "dynamic" # Calculate from participant data +normalization_mode = "hybrid" # Dynamic when sufficient data +``` + +--- + +## Workflows + +### 1. Student Taking a Tryout + +```mermaid +sequenceDiagram + participant S as Student + participant API as FastAPI + participant WP as WordPress + + S->>API: POST /session/ (start session) + API-->>S: session_id + + loop For each question + S->>API: GET /session/{id}/next-item + API-->>S: Question data + + S->>API: POST /session/{id}/answer + API-->>S: Next question or completion + end + + S->>API: POST /session/{id}/complete + API-->>S: NM, NN scores +``` + +### 2. Admin Importing Questions + +```mermaid +flowchart TD + A[Upload Excel File] --> B[Parse Excel] + B --> C{Validate Structure} + C -->|Invalid| D[Return Error] + C -->|Valid| E[Calculate CTT p & bobot] + E --> F[Bulk Insert Items] + F --> G[Commit to Database] + G --> H[Return Import Summary] +``` + +### 3. AI Question Generation + +```mermaid +flowchart TD + A[Request Generation] --> B{Check Cache} + B -->|Found similar| C[Return Cached] + B -->|Not found| D[Call OpenRouter API] + D --> E{Parse Response} + E -->|Parse Error| F[Return Error] + E -->|Success| G[Save to Database] + G --> H[Return Generated Item] +``` + +### 4. IRT Calibration + +```mermaid +flowchart TD + A[Collect Responses] --> B{Enough Data?} + B -->|No| C[Wait for more] + B -->|Yes| D[For each Item] + D --> E[Get Response Matrix] + E --> F[Estimate b via MLE] + F --> G[Calculate Standard Error] + G --> H[Update Item] + H --> D + D --> I[Mark Items Calibrated] +``` + +--- + +## Deployment + +### Requirements + +- Python 3.10+ +- PostgreSQL 14+ +- Redis 6+ (for Celery) +- Nginx (reverse proxy) +- aaPanel with Python Manager (recommended) + +### Running the Application + +```bash +# Install dependencies +pip install -r requirements.txt + +# Run migrations +alembic upgrade head + +# Start server +uvicorn app.main:app --host 0.0.0.0 --port 8000 + +# Or with reload (development) +uvicorn app.main:app --reload +``` + +### Running Tests + +```bash +pytest tests/ -v +``` + +### API Documentation + +- Swagger UI: `http://localhost:8000/docs` +- ReDoc: `http://localhost:8000/redoc` +- OpenAPI JSON: `http://localhost:8000/openapi.json` + +--- + +## Security Considerations + +### Authentication + +- WordPress JWT tokens for user authentication +- `X-Website-ID` header for multi-tenant isolation +- Admin routes protected by admin role check + +### Production Hardening + +1. **SECRET_KEY** must be set to a strong, unique value +2. **ADMIN_PASSWORD** must not be the default +3. **CORS** origins should be explicitly configured +4. **Database** connections should use SSL in production +5. **Rate limiting** enabled for AI generation endpoints + +--- + +## Glossary + +| Term | Definition | +|------|------------| +| **Tryout** | An exam/test assessment | +| **Item** | A single question in a tryout | +| **Session** | A student's attempt at a tryout | +| **CTT** | Classical Test Theory - traditional scoring | +| **IRT** | Item Response Theory - modern adaptive scoring | +| **NM** | Nilai Mentah - raw score [0-1000] | +| **NN** | Nilai Nasional - normalized score [0-1000] | +| **θ (theta)** | IRT ability estimate [-3 to +3] | +| **b** | IRT item difficulty [-3 to +3] | +| **p-value** | CTT proportion correct [0 to 1] | +| **Bobot** | CTT weight (1 - p) | +| **Rataan** | Mean (Indonesian) | +| **SB** | Simpangan Baku - Standard Deviation | +| **CAT** | Computer Adaptive Testing | +| **MLE** | Maximum Likelihood Estimation | + +--- + +## References + +- [PRD.md](./PRD.md) - Complete Product Requirements Document +- [project-brief.md](./project-brief.md) - Original technical specification +- [FastAPI Documentation](https://fastapi.tiangolo.com/) +- [SQLAlchemy 2.0](https://docs.sqlalchemy.org/en/20/) +- [Item Response Theory](https://en.wikipedia.org/wiki/Item_response_theory) \ No newline at end of file diff --git a/alembic.ini b/alembic.ini index e206cc8..0791816 100644 --- a/alembic.ini +++ b/alembic.ini @@ -84,7 +84,7 @@ path_separator = os # database URL. This is consumed by the user-maintained env.py script only. # other means of configuring database URLs may be customized within the env.py # file. -sqlalchemy.url = postgresql+asyncpg://postgres:postgres@localhost:5432/irt_bank_soal +sqlalchemy.url = driver://user:pass@localhost/dbname [post_write_hooks] diff --git a/app/admin_web.py b/app/admin_web.py index b916784..ebd97db 100644 --- a/app/admin_web.py +++ b/app/admin_web.py @@ -5,23 +5,29 @@ This replaces the previous fastapi-admin runtime path, which depended on Tortoise-oriented internals that do not match this project. """ +import json +import re import secrets import uuid from dataclasses import dataclass from datetime import datetime, timezone from html import escape, unescape -import json -import re from typing import Any import aioredis -from fastapi import APIRouter, Depends, File, Form, Request, UploadFile -from sqlalchemy import func, select +from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile +from sqlalchemy import Integer, func, or_, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from starlette.responses import HTMLResponse, RedirectResponse -from starlette.status import HTTP_303_SEE_OTHER, HTTP_401_UNAUTHORIZED, HTTP_429_TOO_MANY_REQUESTS +from starlette.status import ( + HTTP_303_SEE_OTHER, + HTTP_401_UNAUTHORIZED, + HTTP_429_TOO_MANY_REQUESTS, +) +from app.admin_web_icons import EMOJI_TO_ICON, NAV_ICONS_SVG from app.core.config import get_settings from app.database import get_db from app.models import ( @@ -74,7 +80,9 @@ async def configure_admin_web() -> None: return if not settings.ADMIN_USERNAME or not settings.ADMIN_PASSWORD: - raise RuntimeError("ENABLE_ADMIN=true requires ADMIN_USERNAME and ADMIN_PASSWORD to be set.") + raise RuntimeError( + "ENABLE_ADMIN=true requires ADMIN_USERNAME and ADMIN_PASSWORD to be set." + ) _admin_redis = aioredis.from_url( settings.REDIS_URL, @@ -118,20 +126,89 @@ def _dashboard_redirect() -> RedirectResponse: return RedirectResponse(url="/admin/dashboard", status_code=HTTP_303_SEE_OTHER) +# ============================================================ +# ADMIN NAVIGATION - Human-friendly labels +# ============================================================ +# Structure: (Label, URL, Child URL prefixes) +# Organized by workflow: Dashboard > Questions > Exams > Reports > Settings + ADMIN_NAV_ITEMS = ( + # Main navigation groups ("Dashboard", "/admin/dashboard", ("/admin/dashboard",)), - ("Websites", "/admin/websites", ("/admin/websites",)), - ("Tryout Import", "/admin/tryout-import", ("/admin/tryout-import", "/admin/snapshot-questions")), - ("Data Hierarchy", "/admin/hierarchy", ("/admin/hierarchy",)), - ("Basis Items", "/admin/basis-items", ("/admin/basis-items",)), - ("Calibration Status", "/admin/calibration-status", ("/admin/calibration-status",)), - ("Item Statistics", "/admin/item-statistics", ("/admin/item-statistics",)), - ("Session Overview", "/admin/session-overview", ("/admin/session-overview",)), - ("AI Playground", "/admin/ai-playground", ("/admin/ai-playground",)), - ("Password Info", "/admin/password", ("/admin/password",)), + # Questions section + ( + "Questions", + "/admin/questions", + ( + "/admin/questions", + "/admin/templates", + "/admin/question-quality", + ), + ), + ( + "Import Questions", + "/admin/tryout-import", + ( + "/admin/tryout-import", + "/admin/snapshot-questions", + ), + ), + # Exams section + ( + "Exams", + "/admin/exams", + ( + "/admin/exams", + "/admin/student-attempts", + "/admin/normalization", + ), + ), + # Reports section + ( + "Reports", + "/admin/reports", + ( + "/admin/reports", + "/admin/item-statistics", + "/admin/calibration-status", + "/admin/session-overview", + ), + ), + # Settings section + ( + "Settings", + "/admin/settings", + ( + "/admin/settings", + "/admin/websites", + "/admin/hierarchy", + "/admin/password", + ), + ), + # Logout (special - no active state) ("Logout", "/admin/logout", ("/admin/logout",)), ) +# URL mapping for backwards compatibility (old URLs -> new URLs) +LEGACY_URL_MAP = { + "/admin/basis-items": "/admin/ai-generation", + "/admin/calibration-status": "/admin/question-quality", + "/admin/item-statistics": "/admin/reports", + "/admin/session-overview": "/admin/exams", +} + +# Navigation section icons (using SVG for consistent professional look) +NAV_ICONS = NAV_ICONS_SVG + + +def _replace_emojis_with_icons(html: str) -> str: + """Replace emoji characters with SVG icons in HTML content.""" + for emoji, icon_svg in EMOJI_TO_ICON.items(): + if emoji in html: + wrapped_svg = f'{icon_svg}' + html = html.replace(emoji, wrapped_svg) + return html + def _is_admin_nav_active( current_path: str, @@ -147,13 +224,29 @@ def _is_admin_nav_active( def _admin_nav_links(request: Request) -> str: + """Render human-friendly navigation links with icons.""" current_path = request.url.path + + # Check for legacy URLs and redirect if needed + for legacy_url, new_url in LEGACY_URL_MAP.items(): + if current_path.startswith(legacy_url): + current_path = new_url + break + links = [] for label, path, child_prefixes in ADMIN_NAV_ITEMS: + # Special handling for Logout + if label == "Logout": + links.append(f'{escape(label)}') + continue + active = _is_admin_nav_active(current_path, path, child_prefixes) + icon = NAV_ICONS.get(label, "") + label_html = f"{icon} {escape(label)}" if icon else escape(label) class_attr = ' class="active"' if active else "" aria = ' aria-current="page"' if active else "" - links.append(f'{escape(label)}') + links.append(f'{label_html}') + return "\n ".join(links) @@ -164,7 +257,9 @@ def _render_auth_page( body: str, status_code: int = 200, ) -> HTMLResponse: - remember_me_checked = "checked" if request.cookies.get("remember_me") == "on" else "" + remember_me_checked = ( + "checked" if request.cookies.get("remember_me") == "on" else "" + ) html = f""" @@ -185,8 +280,14 @@ def _render_auth_page( .muted {{ color: #64748b; font-size: 13px; margin-top: 14px; }} a {{ color: #0f172a; }} + +

{escape(title)}

{escape(subtitle)}

@@ -214,7 +315,9 @@ def _render_auth_page( return response -def _render_admin_page(request: Request, title: str, page_title: str, body: str) -> HTMLResponse: +def _render_admin_page( + request: Request, title: str, page_title: str, body: str +) -> HTMLResponse: sidebar_links = _admin_nav_links(request) html = f""" @@ -223,15 +326,100 @@ def _render_admin_page(request: Request, title: str, page_title: str, body: str) {escape(title)} +
+ + +
+
+
+ +

Welcome to IRT Admin!

+

Choose a workflow below to get step-by-step guidance.

+
+
+
+

{EMOJI_TO_ICON["🎯"]} Start Here

+
+
+

First Time Setup

+

New to the system? Start here to understand the workflow.

+
+
+ 1 +
+ Import your first questions + Go to Import Questions +
+
+
+ 2 +
+ Review imported questions + Go to Question Bank +
+
+
+ 3 +
+ Generate AI variants for variety + Go to AI Generator +
+
+
+ 4 +
+ Create an exam when questions are ready + Go to Exams +
+
+
+
+
+
+ +
+

{EMOJI_TO_ICON["📥"]} Import Workflows

+
+
+

Import Questions from Excel

+

Upload a .xlsx file with questions and answers.

+
+
+ 1 +
+ Prepare your Excel file + Format: Column A=Question, Column B-J=Options, Last column=Answer key +
+
+
+ 2 +
+ Go to Import Questions + Open Import Page +
+
+
+ 3 +
+ Preview & Submit + Review the preview, then click Submit to import. +
+
+
+
+
+
+ +
+

{EMOJI_TO_ICON["🤖"]} AI Generation

+
+
+

Generate AI Question Variants

+

Create variations of existing questions using AI.

+
+
+ 1 +
+ Select a template question + Browse Question Bank +
+
+
+ 2 +
+ Choose AI Generator + Open AI Generator +
+
+
+ 3 +
+ Review & Approve + Check the Review tab for AI-generated variants. +
+
+
+
+
+

Improve Question Quality

+

Use AI to enhance unclear or weak questions.

+
+
+ 1 +
+ Check Question Quality + View Quality Dashboard +
+
+
+ 2 +
+ Find low-quality questions + Look for red indicators in the quality report. +
+
+
+ 3 +
+ Generate improved variants + Use AI Generator +
+
+
+
+
+
+ +
+

{EMOJI_TO_ICON["📋"]} Exam Workflows

+
+
+

Create an Exam

+

Set up a test session for students.

+
+
+ 1 +
+ Ensure questions are calibrated + Check Calibration +
+
+
+ 2 +
+ Go to Exams + Manage Exams +
+
+
+ 3 +
+ Configure scoring mode + Choose IRT scoring for adaptive testing. +
+
+
+
+
+

Monitor Student Performance

+

Track how students are doing on exams.

+
+
+ 1 +
+ View exam reports + Go to Reports +
+
+
+ 2 +
+ Check calibration progress + View Quality +
+
+
+ 3 +
+ Understand IRT scoring + Questions become more accurate as more students answer. +
+
+
+
+
+
+
+ +
+
+ + +

{escape(page_title)}

- {body} + {_replace_emojis_with_icons(body)}
@@ -354,7 +821,7 @@ def _table(headers: list[str], rows: list[list[Any]]) -> str: for row in rows: cols = "".join(f"{escape(str(value))}" for value in row) body_rows.append(f"{cols}") - body = "".join(body_rows) or f"No data" + body = "".join(body_rows) or f'No data' return f"{head}{body}
" @@ -486,13 +953,17 @@ def _tryout_import_form_body( f"{escape(snapshot.title)}" f"{snapshot.question_count}" f"{escape(str(snapshot.created_at))}" - f"Browse" + f'Browse' "" ) snapshots_table = ( "" - + ("".join(snapshot_rows) if snapshot_rows else "") + + ( + "".join(snapshot_rows) + if snapshot_rows + else '' + ) + "
Snapshot IDWebsiteTryout IDTitleQuestionsImported AtActions
No data
No data
" ) @@ -528,17 +999,38 @@ def _tryout_import_form_body( preview_html = f"""

Preview Summary

-

File: {escape(upload_filename or "uploaded JSON")}

+

File: { + escape(upload_filename or "uploaded JSON") + }

-
Tryouts{preview.get("tryout_count", 0)}
-
New Questions{totals.get("new_questions", 0)}
-
Updated Questions{totals.get("updated_questions", 0)}
-
Removed Questions{totals.get("removed_questions", 0)}
+
Tryouts{ + preview.get("tryout_count", 0) + }
+
New Questions{ + totals.get("new_questions", 0) + }
+
Updated Questions{ + totals.get("updated_questions", 0) + }
+
Removed Questions{ + totals.get("removed_questions", 0) + }
- {_table( - ["Tryout ID", "Title", "Total", "New", "Updated", "Unchanged", "Removed", "Warnings"], - tryout_rows, - )} + { + _table( + [ + "Tryout ID", + "Title", + "Total", + "New", + "Updated", + "Unchanged", + "Removed", + "Warnings", + ], + tryout_rows, + ) + }
{import_form}
""" @@ -549,7 +1041,7 @@ def _tryout_import_form_body( {error_html}
- + @@ -571,7 +1063,9 @@ def _snapshot_slot_map(snapshot: TryoutImportSnapshot) -> dict[str, int]: return slot_map -def _snapshot_options_to_item_options(raw_options: list[dict[str, Any]] | list[Any]) -> dict[str, str]: +def _snapshot_options_to_item_options( + raw_options: list[dict[str, Any]] | list[Any], +) -> dict[str, str]: item_options: dict[str, str] = {} for option in raw_options or []: if not isinstance(option, dict): @@ -600,8 +1094,8 @@ def _snapshot_questions_body( if promoted_item: select_html = "" action_html = ( - f'Item #{promoted_item.id} already exists. ' - f'Open in AI Playground' + f"Item #{promoted_item.id} already exists. " + f'Open in Variant Generator' ) else: select_html = f'' @@ -619,19 +1113,19 @@ def _snapshot_questions_body( "" ) questions_table = ( - "" - f"" - "
" - "" + '' + f'' + '
' + '' "
" - "" - + ("".join(rows) if rows else "") + '
el.checked = this.checked)\">SlotSource Question IDCorrectOptionsActiveStemAction
No data
' + + ("".join(rows) if rows else '') + "
SlotSource Question IDCorrectOptionsActiveStemAction
No data
" "" ) return f"""

Snapshot ID: {snapshot.id} | Website: {snapshot.website_id} | Tryout: {escape(snapshot.source_tryout_id)}

-

Promote selected snapshot questions into the live items table as sedang basis items for AI generation.

+

Promote selected snapshot questions into the live items table as original basis items (Medium difficulty) for AI generation.

{success_html} {error_html} {questions_table} @@ -639,17 +1133,11 @@ def _snapshot_questions_body( """ -async def _basis_items_for_playground(db: AsyncSession, limit: int = 20) -> list[Item]: - result = await db.execute( - select(Item) - .where(Item.level == "sedang") - .order_by(Item.created_at.desc(), Item.id.desc()) - .limit(limit) - ) - return list(result.scalars().all()) -async def _recent_generation_runs(db: AsyncSession, limit: int = 20) -> list[AIGenerationRun]: +async def _recent_generation_runs( + db: AsyncSession, limit: int = 20 +) -> list[AIGenerationRun]: result = await db.execute( select(AIGenerationRun).order_by(AIGenerationRun.id.desc()).limit(limit) ) @@ -698,7 +1186,9 @@ async def _load_hierarchy_context(db: AsyncSession) -> dict[str, list[Any]]: basis_result = await db.execute( select(Item) .where(Item.generated_by != "ai", Item.level == "sedang") - .order_by(Item.website_id.asc(), Item.tryout_id.asc(), Item.slot.asc(), Item.id.asc()) + .order_by( + Item.website_id.asc(), Item.tryout_id.asc(), Item.slot.asc(), Item.id.asc() + ) ) variant_result = await db.execute( select(Item) @@ -742,7 +1232,7 @@ def _hierarchy_flow_strip() -> str: ("1", "Website", "Owner/source site"), ("2", "Snapshot", "Imported tryout export"), ("3", "Source Question", "Read-only imported question"), - ("4", "Basis Item", "Promoted sedang parent"), + ("4", "Basis Item", "Promoted original parent"), ("5", "Run", "AI generation request"), ("6", "Variant", "Generated child question"), ) @@ -763,26 +1253,23 @@ def _hierarchy_attention_html( basis_missing_source: list[Item], ) -> str: rows = [] - for snapshot in snapshots_without_basis: + if snapshots_without_basis: rows.append( - f'
  • Snapshot {escape(snapshot.title)} ' - f'has no promoted basis items yet. ' - f'Open snapshot questions
  • ' + f'
  • Snapshot {len(snapshots_without_basis)} snapshots have no promoted basis items yet. ' + f'(e.g., {escape(snapshots_without_basis[0].title)})
  • ' ) - for item in basis_without_variants: + if basis_without_variants: rows.append( - f'
  • Basis Item #{item.id} has no generated variants yet. ' - f'Open workspace
  • ' + f'
  • Basis Item {len(basis_without_variants)} promoted basis items have no generated variants yet. ' + f'Go to Basis Items to select an item for generation
  • ' ) - for item in variants_without_basis: + if variants_without_basis: rows.append( - f'
  • Variant #{item.id} is not linked to an existing basis item. ' - f'View variant
  • ' + f'
  • Variant {len(variants_without_basis)} variants are orphaned (not linked to an existing basis item).
  • ' ) - for item in basis_missing_source: + if basis_missing_source: rows.append( - f'
  • Basis Item #{item.id} is missing a source snapshot question reference. ' - f'Open workspace
  • ' + f'
  • Basis Item {len(basis_missing_source)} basis items are missing a source snapshot question reference.
  • ' ) if not rows: @@ -791,9 +1278,9 @@ def _hierarchy_attention_html( """ return f""" -
    -

    Needs Attention

    -
      {"".join(rows)}
    +
    +

    Needs Attention

    +
      {"".join(rows)}
    """ @@ -805,40 +1292,31 @@ def _basis_hierarchy_item_html( runs: list[AIGenerationRun], ) -> str: latest_run = runs[0] if runs else None - latest_variant_links = [] - for variant in variants[:3]: - latest_variant_links.append( - f'' - f'Variant #{variant.id}' - ) + source_label = "-" if source_question is not None: - source_label = ( - f"{escape(source_question.source_question_id)} | " - f"{escape(_truncate(_html_to_text(source_question.question_title or source_question.question_html), 120))}" - ) + source_label = f"{escape(source_question.source_question_id)}" + run_html = "-" if latest_run is not None: - run_html = ( - f'Run #{latest_run.id} | {escape(latest_run.target_level)} | ' - f'{latest_run.requested_count} requested | ' - f'Review run' - ) - variant_links_html = " ".join(latest_variant_links) if latest_variant_links else 'No variant detail links yet.' + run_html = f"Batch #{latest_run.id} ({escape(latest_run.target_level)})" + + stem_preview = escape(_truncate(_html_to_text(basis_item.stem), 120)) + variant_counts = ( + _variant_status_counts_html(variants) + if variants + else '0 variants' + ) + + target_tab = "review" if variants else "generate" return f""" -
    -

    Basis Item #{basis_item.id} | Slot {basis_item.slot} | Tryout {escape(basis_item.tryout_id)}

    -

    Source question: {source_label}

    -

    Stem: {escape(_truncate(_html_to_text(basis_item.stem), 180))}

    -

    Variants: {_variant_status_counts_html(variants)}

    -

    Latest run: {run_html}

    -
    - Basis workspace - Review variants - {variant_links_html} -
    -
    + + {basis_item.slot} + {stem_preview} + {variant_counts} + Workspace + """ @@ -866,11 +1344,15 @@ def _hierarchy_view_body(context: dict[str, list[Any]]) -> str: for question in questions: _append_grouped(questions_by_website, question.website_id, question) if question.latest_snapshot_id is not None: - _append_grouped(questions_by_snapshot, question.latest_snapshot_id, question) + _append_grouped( + questions_by_snapshot, question.latest_snapshot_id, question + ) for item in basis_items: _append_grouped(basis_by_website, item.website_id, item) if item.source_snapshot_question_id is not None: - _append_grouped(basis_by_source_question, item.source_snapshot_question_id, item) + _append_grouped( + basis_by_source_question, item.source_snapshot_question_id, item + ) for variant in variants: _append_grouped(variants_by_website, variant.website_id, variant) if variant.basis_item_id is not None: @@ -880,7 +1362,9 @@ def _hierarchy_view_body(context: dict[str, list[Any]]) -> str: snapshots_without_basis = [] for snapshot in snapshots: - snapshot_question_ids = {question.id for question in questions_by_snapshot.get(snapshot.id, [])} + snapshot_question_ids = { + question.id for question in questions_by_snapshot.get(snapshot.id, []) + } linked_basis = [ item for question_id in snapshot_question_ids @@ -889,13 +1373,17 @@ def _hierarchy_view_body(context: dict[str, list[Any]]) -> str: if not linked_basis: snapshots_without_basis.append(snapshot) - basis_without_variants = [item for item in basis_items if not variants_by_basis.get(item.id)] + basis_without_variants = [ + item for item in basis_items if not variants_by_basis.get(item.id) + ] variants_without_basis = [ item for item in variants if item.basis_item_id is None or item.basis_item_id not in basis_by_id ] - basis_missing_source = [item for item in basis_items if item.source_snapshot_question_id is None] + basis_missing_source = [ + item for item in basis_items if item.source_snapshot_question_id is None + ] website_sections = [] for website in websites: @@ -904,9 +1392,7 @@ def _hierarchy_view_body(context: dict[str, list[Any]]) -> str: website_basis = basis_by_website.get(website.id, []) website_variants = variants_by_website.get(website.id, []) website_runs = [ - run - for item in website_basis - for run in runs_by_basis.get(item.id, []) + run for item in website_basis for run in runs_by_basis.get(item.id, []) ] snapshot_groups = [] for snapshot in website_snapshots: @@ -920,19 +1406,36 @@ def _hierarchy_view_body(context: dict[str, list[Any]]) -> str: ], key=lambda item: (item.slot, item.id), ) - basis_html = ( - "".join( - _basis_hierarchy_item_html( - item, - questions_by_id.get(item.source_snapshot_question_id), - variants_by_basis.get(item.id, []), - runs_by_basis.get(item.id, []), + if snapshot_basis: + basis_html = ( + """ + + + + + + + + + + + """ + + "".join( + _basis_hierarchy_item_html( + item, + questions_by_id.get(item.source_snapshot_question_id), + variants_by_basis.get(item.id, []), + runs_by_basis.get(item.id, []), + ) + for item in snapshot_basis ) - for item in snapshot_basis + + """ + +
    SlotStem PreviewVariantsAction
    + """ ) - if snapshot_basis - else '

    No promoted basis items for this snapshot yet.

    ' - ) + else: + basis_html = '

    No promoted basis items for this snapshot yet.

    ' snapshot_groups.append( f"""
    @@ -1014,7 +1517,9 @@ async def _family_usage_stats( ) -> tuple[dict[int, dict[str, float]], dict[str, float]]: family_item_ids = [basis_item.id] + [item.id for item in variants] usage_metrics = await _usage_metrics_for_items(db, family_item_ids) - family_impressions = int(sum(metric["impressions"] for metric in usage_metrics.values())) + family_impressions = int( + sum(metric["impressions"] for metric in usage_metrics.values()) + ) family_unique_users = int( await db.scalar( select(func.count(func.distinct(UserAnswer.wp_user_id))).where( @@ -1023,7 +1528,9 @@ async def _family_usage_stats( ) or 0 ) - family_frequency = (family_impressions / family_unique_users) if family_unique_users else 0.0 + family_frequency = ( + (family_impressions / family_unique_users) if family_unique_users else 0.0 + ) return usage_metrics, { "impressions": float(family_impressions), "unique_users": float(family_unique_users), @@ -1042,16 +1549,20 @@ def _basis_items_list_body(items: list[Item]) -> str: f"{item.website_id}" f"{escape(_truncate(item.stem, 120))}" f"{item.source_snapshot_question_id or '-'}" - f"Open Workspace" + f'Open Workspace' "" ) table = ( "" - + ("".join(rows) if rows else "") + + ( + "".join(rows) + if rows + else '' + ) + "
    Item IDTryoutSlotWebsiteStemSource Snapshot QIDActions
    No basis items found.
    No basis items found.
    " ) return f""" -

    Basis items are canonical parent questions (sedang, non-AI). Open a workspace to generate and review AI child variants.

    +

    Basis items are original parent questions (Medium difficulty, non-AI). Open a workspace to generate and review AI child variants.

    {table} """ @@ -1097,30 +1608,35 @@ def _basis_item_workspace_body( variant_rows = [] for item in variants: - usage = usage_by_item.get(item.id, {"impressions": 0.0, "unique_users": 0.0, "frequency": 0.0}) + usage = usage_by_item.get( + item.id, {"impressions": 0.0, "unique_users": 0.0, "frequency": 0.0} + ) options = item.options if isinstance(item.options, dict) else {} - options_rows = "".join( - f"{escape(str(key))}" - f"{escape(str(value))}" - for key, value in options.items() - ) or "No options" + options_rows = ( + "".join( + f'{escape(str(key))}' + f'{escape(str(value))}' + for key, value in options.items() + ) + or 'No options' + ) review_html = ( - "
    " - "Review full content" - f"
    " - f"

    Full Stem
    {escape(_html_to_text(item.stem))}

    " - "" - "" + '
    ' + 'Review full content' + f'
    ' + f'

    Full Stem
    {escape(_html_to_text(item.stem))}

    ' + '
    OptionText
    ' + '' f"{options_rows}" "
    OptionText
    " - f"

    Correct Answer: {escape(item.correct_answer or '-')}

    " - f"

    Explanation: {escape(_html_to_text(item.explanation) or '-')}

    " + f'

    Correct Answer: {escape(item.correct_answer or "-")}

    ' + f'

    Explanation: {escape(_html_to_text(item.explanation) or "-")}

    ' "
    " "
    " ) variant_rows.append( "" - f"" + f'' f"{item.id}" f"{item.generation_run_id or '-'}" f"{escape(item.level)}" @@ -1134,19 +1650,23 @@ def _basis_item_workspace_body( "" ) variants_table = ( - f"
    " - "
    " - "' + '' + '' + '' + '' + '' "" - "" + '' "
    " - "" - + ("".join(variant_rows) if variant_rows else "") + '
    el.checked = this.checked)\">Item IDRun IDLevelStatusModelImpressionsUnique UsersFrequencyStemCreated At
    No generated variants yet for this parent.
    ' + + ( + "".join(variant_rows) + if variant_rows + else '' + ) + "
    Item IDRun IDLevelStatusModelImpressionsUnique UsersFrequencyStemCreated At
    No generated variants yet for this parent.
    " ) @@ -1160,7 +1680,7 @@ def _basis_item_workspace_body( Tryout: {escape(basis_item.tryout_id)} | Slot: {basis_item.slot} | Website: {basis_item.website_id} | - Source Snapshot QID: {basis_item.source_snapshot_question_id or '-'} + Source Snapshot QID: {basis_item.source_snapshot_question_id or "-"}

    Family Usage: impressions={int(family_stats.get("impressions", 0.0))}, @@ -1176,8 +1696,8 @@ def _basis_item_workspace_body(

    @@ -1214,8 +1734,8 @@ def _basis_item_workspace_body(
    @@ -1283,7 +1803,7 @@ async def _find_or_create_demo_basis_item(db: AsyncSession) -> Item: tryout = Tryout( website_id=website.id, tryout_id="demo-tryout", - name="Demo AI Playground Tryout", + name="Demo Variant Generator Tryout", description="Seed data for the AI playground.", scoring_mode="ctt", selection_mode="fixed", @@ -1323,14 +1843,20 @@ async def _load_websites(db: AsyncSession) -> list[Website]: return list(result.scalars().all()) -async def _recent_snapshots(db: AsyncSession, limit: int = 20) -> list[TryoutImportSnapshot]: +async def _recent_snapshots( + db: AsyncSession, limit: int = 20 +) -> list[TryoutImportSnapshot]: result = await db.execute( - select(TryoutImportSnapshot).order_by(TryoutImportSnapshot.id.desc()).limit(limit) + select(TryoutImportSnapshot) + .order_by(TryoutImportSnapshot.id.desc()) + .limit(limit) ) return list(result.scalars().all()) -async def _ensure_operational_tryout(snapshot: TryoutImportSnapshot, db: AsyncSession) -> Tryout: +async def _ensure_operational_tryout( + snapshot: TryoutImportSnapshot, db: AsyncSession +) -> Tryout: result = await db.execute( select(Tryout).where( Tryout.website_id == snapshot.website_id, @@ -1378,7 +1904,12 @@ async def _load_snapshot_question_context( ) promoted_items_by_slot = {item.slot: item for item in item_result.scalars().all()} slot_map = _snapshot_slot_map(snapshot) - questions.sort(key=lambda row: (slot_map.get(row.source_question_id, 10**9), row.source_question_id)) + questions.sort( + key=lambda row: ( + slot_map.get(row.source_question_id, 10**9), + row.source_question_id, + ) + ) return questions, promoted_items_by_slot, slot_map @@ -1586,7 +2117,9 @@ async def login_submit( secure=secure_cookie, samesite="lax", ) - await _admin_redis.set(f"{SESSION_PREFIX}{token}", settings.ADMIN_USERNAME, ex=expire) + await _admin_redis.set( + f"{SESSION_PREFIX}{token}", settings.ADMIN_USERNAME, ex=expire + ) return response @@ -1655,27 +2188,1343 @@ async def dashboard_view(request: Request, db: AsyncSession = Depends(get_db)): if not admin: return _login_redirect() - tryouts = await db.scalar(select(func.count()).select_from(Tryout)) or 0 - items = await db.scalar(select(func.count()).select_from(Item)) or 0 - sessions = await db.scalar(select(func.count()).select_from(Session)) or 0 - completed_sessions = ( - await db.scalar(select(func.count()).select_from(Session).where(Session.is_completed.is_(True))) + # Get basic counts + tryouts_count = await db.scalar(select(func.count()).select_from(Tryout)) or 0 + items_count = await db.scalar(select(func.count()).select_from(Item)) or 0 + sessions_count = await db.scalar(select(func.count()).select_from(Session)) or 0 + completed_count = ( + await db.scalar( + select(func.count()) + .select_from(Session) + .where(Session.is_completed.is_(True)) + ) or 0 ) + # Get websites count + websites_count = await db.scalar(select(func.count()).select_from(Website)) or 0 + + # Calculate completion rate + completion_rate = ( + (completed_count / sessions_count * 100) if sessions_count > 0 else 0 + ) + + # Get AI stats + try: + ai_stats = await get_ai_stats(db) + pending_review = ai_stats.get("pending_review", 0) + total_generated = ai_stats.get("total_generated", 0) + except Exception: + pending_review = 0 + total_generated = 0 + + # Get calibration stats + try: + uncalibrated_result = await db.execute( + select(func.count().label("count")) + .select_from(Item) + .where(Item.calibrated.is_(False)) + ) + uncalibrated_count = uncalibrated_result.scalar() or 0 + except Exception: + uncalibrated_count = 0 + + # Get recent sessions for activity feed + recent_sessions = await db.execute( + select(Session) + .where(Session.is_completed.is_(True)) + .order_by(Session.end_time.desc()) + .limit(5) + ) + recent_sessions_list = list(recent_sessions.scalars().all()) + + # Get recent AI runs + recent_runs = await db.execute( + select(AIGenerationRun).order_by(AIGenerationRun.id.desc()).limit(3) + ) + recent_runs_list = list(recent_runs.scalars().all()) + + # Build activity feed + activity_items = [] + + # Add recent session activity + for session in recent_sessions_list: + if session.end_time: + time_str = _format_relative_time(session.end_time) + activity_items.append( + f"
  • 👤 {escape(session.wp_user_id)} completed " + f'{escape(session.tryout_id)} ' + f"({time_str})" + ) + + # Add recent AI activity + for run in recent_runs_list: + if run.created_at: + time_str = _format_relative_time(run.created_at) + completed = len(run.generated_items) if run.generated_items else 0 + activity_items.append( + f"
  • 🤖 AI generated {completed}/{run.requested_count} " + f'view results ' + f"({time_str})" + ) + + activity_html = "" + if activity_items: + activity_html = f'
      {" ".join(activity_items[:5])}
    ' + else: + activity_html = '

    No recent activity

    ' + + # Build alerts + alerts = [] + + if uncalibrated_count > 0: + alerts.append( + f'
    ' + f"⚠️ {uncalibrated_count} questions need calibration " + f"(need more student answers to calculate difficulty)" + f"
    " + ) + + if pending_review > 0: + alerts.append( + f'
    ' + f"📝 {pending_review} AI-generated questions pending your review " + f'Review now' + f"
    " + ) + + if total_generated == 0: + alerts.append( + '
    ' + "💡 Tip: Start by importing questions or creating question templates " + "to enable AI generation" + "
    " + ) + + alerts_html = "".join(alerts) if alerts else "" + + # Build greeting based on time of day + current_hour = datetime.now().hour + if current_hour < 12: + greeting = "Good Morning" + elif current_hour < 17: + greeting = "Good Afternoon" + else: + greeting = "Good Evening" + body = f""" -

    Signed in as {escape(admin.username)}.

    -
    -
    Tryouts{tryouts}
    -
    Items{items}
    -
    Sessions{sessions}
    -
    Completed Sessions{completed_sessions}
    +
    +

    {greeting}, {escape(admin.username)}! 👋

    +

    Here's what's happening with your exam system today.

    -

    Open AI Playground

    + + {alerts_html} + +

    📊 System Overview

    +
    +
    +
    📋
    +
    +
    {tryouts_count}
    +
    Exams
    +
    +
    +
    +
    📝
    +
    +
    {items_count}
    +
    Questions
    +
    +
    +
    +
    👥
    +
    +
    {completed_count}
    +
    Completed Tests
    +
    {completion_rate:.0f}% completion rate
    +
    +
    +
    +
    🌐
    +
    +
    {websites_count}
    +
    Websites
    +
    +
    +
    + +

    🚀 Quick Actions

    + + +

    📈 Recent Activity

    + {activity_html} """ + return _render_admin_page(request, "IRT Bank Soal Admin", "Dashboard", body) +def _format_relative_time(dt: datetime) -> str: + """Format datetime as relative time string.""" + if dt is None: + return "Unknown" + + now = datetime.now(timezone.utc) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + + diff = now - dt + seconds = diff.total_seconds() + + if seconds < 60: + return "just now" + elif seconds < 3600: + minutes = int(seconds / 60) + return f"{minutes} minute{'s' if minutes > 1 else ''} ago" + elif seconds < 86400: + hours = int(seconds / 3600) + return f"{hours} hour{'s' if hours > 1 else ''} ago" + else: + days = int(seconds / 86400) + return f"{days} day{'s' if days > 1 else ''} ago" + + +# ============================================================ +# NEW HUMAN-FRIENDLY ROUTES +# ============================================================ + + +@router.get("/questions", include_in_schema=False) +async def questions_view( + request: Request, + db: AsyncSession = Depends(get_db), + q: str = "", + difficulty: str = "", + status: str = "", + website_id: int | None = None, + tryout_id: str = "", + page: int = 1, +): + """Questions bank - list all questions with working filters and pagination.""" + admin = await _current_admin(request) + if not admin: + return _login_redirect() + + # Build query with filters + query = select(Item) + count_query = select(func.count()).select_from(Item) + + # Search filter (search in stem) + if q: + search_filter = or_( + Item.stem.ilike(f"%{q}%"), + Item.tryout_id.ilike(f"%{q}%"), + ) + query = query.where(search_filter) + count_query = count_query.where(search_filter) + + # Website filter + if website_id: + query = query.where(Item.website_id == website_id) + count_query = count_query.where(Item.website_id == website_id) + + # Tryout filter + if tryout_id: + query = query.where(Item.tryout_id == tryout_id) + count_query = count_query.where(Item.tryout_id == tryout_id) + + # Get total count before pagination + total_result = await db.execute(count_query) + total_items = total_result.scalar() or 0 + + # Calculate pagination + per_page = 25 + total_pages = max(1, (total_items + per_page - 1) // per_page) + page = max(1, min(page, total_pages)) + offset = (page - 1) * per_page + + # Get paginated items + result = await db.execute( + query.order_by(Item.website_id.asc(), Item.tryout_id.asc(), Item.slot.asc()) + .offset(offset) + .limit(per_page) + ) + items = list(result.scalars().all()) + + # Get websites for filter dropdown + websites_result = await db.execute(select(Website).order_by(Website.site_name)) + websites = list(websites_result.scalars().all()) + + # Build question rows + question_rows = [] + for item in items: + # Calculate human-readable difficulty + p_value = item.ctt_p + if p_value is None: + difficulty_label = "Unknown" + difficulty_class = "difficulty-unknown" + elif p_value > 0.70: + difficulty_label = "Easy" + difficulty_class = "difficulty-easy" + elif p_value >= 0.30: + difficulty_label = "Medium" + difficulty_class = "difficulty-medium" + else: + difficulty_label = "Hard" + difficulty_class = "difficulty-hard" + + # Truncate stem for preview + stem_preview = escape(_truncate(_html_to_text(item.stem or ""), 100)) + + question_rows.append(f""" + + + #{item.id} + + {stem_preview} +
    + {difficulty_label} + | + Used {item.calibration_sample_size or 0}x + | + Slot {item.slot} +
    + + {escape(item.level or "-")} + + + {"✅ Calibrated" if item.calibrated else "⏳ Needs Data"} + + + + View + + + """) + + # Build pagination HTML + pagination_html = "" + if total_pages > 1: + page_links = [] + for p in range(max(1, page - 2), min(total_pages + 1, page + 3)): + active_class = "active" if p == page else "" + page_links.append( + f'{p}' + ) + + pagination_html = f""" + + """ + + # Filter selects + difficulty_selected = { + "easy": 'value="easy" selected', + "medium": 'value="medium" selected', + "hard": 'value="hard" selected', + }.get(difficulty.lower(), "") + + status_selected = { + "calibrated": 'value="calibrated" selected', + "uncalibrated": 'value="uncalibrated" selected', + }.get(status.lower(), "") + + # Build website options + website_options = [''] + for site in websites: + selected = "selected" if website_id == site.id else "" + website_options.append( + f'' + ) + + table_html = ( + '
    ' + '' + "" + "" + '' + '' + "" + '' + '' + '' + "" + "" + "" + + ( + "".join(question_rows) + if question_rows + else f'' + ) + + "
    IDQuestionLevelStatusActions
    No questions found. Import questions to get started.
    " + ) + + body = f""" +

    Manage your question bank. Click any question to see details and options.

    + + + + + + + + Clear + + +
    + {total_items} questions total + +
    + + {table_html} + {pagination_html} + + + """ + + return _render_admin_page(request, "Questions", "📝 Question Bank", body) + + +@router.get("/questions/{item_id}", include_in_schema=False) +async def question_detail_view( + item_id: int, + request: Request, + db: AsyncSession = Depends(get_db), +): + """Question detail view - shows full question with all options and statistics.""" + admin = await _current_admin(request) + if not admin: + return _login_redirect() + + # Get the item + item = await db.get(Item, item_id) + if not item: + body = """ +
    + ⚠️ +
    + Question not found +

    The question you're looking for doesn't exist or has been deleted.

    +
    +
    + ← Back to Questions + """ + return _render_admin_page( + request, "Question Not Found", "Question Not Found", body + ) + + # Get tryout info + tryout_result = await db.execute( + select(Tryout).where( + Tryout.tryout_id == item.tryout_id, + Tryout.website_id == item.website_id, + ) + ) + tryout = tryout_result.scalar_one_or_none() + + # Get website info + website_result = await db.execute( + select(Website).where(Website.id == item.website_id) + ) + website = website_result.scalar_one_or_none() + + # Calculate difficulty + p_value = item.ctt_p + if p_value is None: + difficulty_label = "Unknown" + difficulty_class = "difficulty-unknown" + difficulty_explanation = "Not enough data yet to determine difficulty." + elif p_value > 0.70: + difficulty_label = "Easy" + difficulty_class = "difficulty-easy" + difficulty_explanation = ( + f"{p_value:.1%} of students answered correctly. This is an easy question." + ) + elif p_value >= 0.30: + difficulty_label = "Medium" + difficulty_class = "difficulty-medium" + difficulty_explanation = f"{p_value:.1%} of students answered correctly. This is a medium difficulty question." + else: + difficulty_label = "Hard" + difficulty_class = "difficulty-hard" + difficulty_explanation = f"{p_value:.1%} of students answered correctly. This is a difficult question." + + # Parse options from JSON + options = item.options or {} + + # Build options HTML + options_html = "" + correct_key = item.correct_answer or "" + for key in sorted(options.keys()): + is_correct = key.upper() == correct_key.upper() + row_class = "correct-option" if is_correct else "" + check_mark = " ✅" if is_correct else "" + options_html += f'{key}{check_mark}{str(options[key])}' + + # Build stats cards + stats_html = f""" +
    +
    + Difficulty + {difficulty_label} + {p_value if p_value else "N/A"} +
    +
    + Calibration Status + + {"✅ Calibrated" if item.calibrated else "⏳ Needs Data"} + +
    +
    + Sample Size + {item.calibration_sample_size or 0} + responses +
    +
    + IRT Difficulty (b) + {f"{item.irt_b:.2f}" if item.irt_b else "N/A"} +
    + +
    + """ + + # Context info + context_html = f""" +
    +

    📍 Context

    +
    +
    + Website + {escape(website.site_name if website else f"ID: {item.website_id}")} +
    +
    + Exam + {escape(tryout.name if tryout else item.tryout_id)} +
    +
    + Slot + {item.slot} +
    +
    + Level + {escape(item.level or "Not specified")} +
    +
    + Item ID + #{item.id} +
    +
    + Created + {escape(str(item.created_at)[:10] if item.created_at else "Unknown")} +
    +
    +
    + """ + + # Difficulty explanation + difficulty_info = f""" +
    + 💡 +
    + About Difficulty +

    {difficulty_explanation}

    +
    +
    + """ + + body = f""" + ← Back to Questions + +
    +

    Question #{item.id}

    + +
    + + {difficulty_info} + +

    📝 Question

    +
    {item.stem or "No question text"}
    + +

    🔘 Answer Options

    +
    + + + + {options_html if options_html else ''} + +
    KeyAnswer Text
    No options available
    +
    + +

    📊 Statistics

    + {stats_html} + + {context_html} + +
    +

    ℹ️ What is Calibration?

    +

    A question becomes "calibrated" after many students (100+) have answered it. Once calibrated, the system can accurately measure student ability and provide adaptive testing.

    +

    The IRT parameters (difficulty, discrimination, guessing) are calculated from student response patterns.

    +
    + + + """ + + return _render_admin_page( + request, f"Question #{item_id}", "📝 Question Details", body + ) + + +@router.get("/question-quality", include_in_schema=False) +async def question_quality_view(request: Request, db: AsyncSession = Depends(get_db)): + """Question Quality - shows calibration status with human-friendly explanations.""" + admin = await _current_admin(request) + if not admin: + return _login_redirect() + + # Get calibration stats by tryout + result = await db.execute( + select( + Tryout.tryout_id, + Tryout.name, + func.count(Item.id).label("total_items"), + func.sum(func.cast(Item.calibrated, Integer)).label("calibrated_items"), + ) + .join( + Item, + (Tryout.tryout_id == Item.tryout_id) + & (Tryout.website_id == Item.website_id), + ) + .group_by(Tryout.tryout_id, Tryout.name) + .order_by(Tryout.name) + ) + tryout_stats = list(result.all()) + + # Calculate totals + total_items = sum(s.total_items or 0 for s in tryout_stats) + total_calibrated = sum(s.calibrated_items or 0 for s in tryout_stats) + overall_percentage = ( + (total_calibrated / total_items * 100) if total_items > 0 else 0 + ) + + # Build tryout rows + tryout_rows = [] + for stat in tryout_stats: + total = stat.total_items or 0 + calibrated = stat.calibrated_items or 0 + percentage = (calibrated / total * 100) if total > 0 else 0 + + if percentage >= 90: + status = '✅ Ready' + elif percentage >= 50: + status = '⚠️ Partial' + else: + status = '❌ Needs Data' + + # Calculate bar width + bar_width = min(100, percentage) + + tryout_rows.append(f""" + + {escape(stat.name or stat.tryout_id)} + {total} + {calibrated} + +
    +
    + {percentage:.0f}% +
    + + {status} + + """) + + body = f""" +
    +

    📖 What is Question Quality?

    +

    Questions become "calibrated" after many students answer them. Well-calibrated questions give accurate student scores.

    +

    How it works: When 100+ students answer a question, we can calculate its true difficulty (p-value) and use it for adaptive testing.

    +
    + +
    +
    +

    Overall Quality

    + {overall_percentage:.0f}% +
    +
    +
    +
    +

    {total_calibrated} of {total_items} questions calibrated

    +
    + +

    📋 By Exam

    + + + + + + + + + + + + {"".join(tryout_rows) if tryout_rows else ''} + +
    Exam NameTotal QuestionsCalibratedProgressStatus
    No exams with questions yet.
    + + + + + """ + + return _render_admin_page(request, "Question Quality", "📊 Question Quality", body) + + +@router.get("/exams", include_in_schema=False) +async def exams_view(request: Request, db: AsyncSession = Depends(get_db)): + """Exams overview - list all exams with human-friendly display and visual cards.""" + admin = await _current_admin(request) + if not admin: + return _login_redirect() + + # Get all tryouts with stats + result = await db.execute( + select(Tryout) + .options(selectinload(Tryout.stats)) + .order_by(Tryout.created_at.desc()) + ) + tryouts = list(result.scalars().all()) + + # Get summary stats + total_tryouts = len(tryouts) + total_participants = sum( + s.stats.participant_count if s.stats else 0 for s in tryouts + ) + total_items_result = await db.execute(select(func.count(Item.id))) + total_items = total_items_result.scalar() or 0 + + # Build exam cards + exam_cards = [] + for tryout in tryouts: + stats = tryout.stats + participant_count = stats.participant_count if stats else 0 + avg_nm = stats.rataan if stats else None + std_nm = stats.std if stats else None + min_nm = stats.minimum if stats else None + max_nm = stats.maximum if stats else None + + # Get item count + items_result = await db.execute( + select(func.count(Item.id)).where( + Item.tryout_id == tryout.tryout_id, Item.website_id == tryout.website_id + ) + ) + item_count = items_result.scalar() or 0 + + # Get calibrated items count + calibrated_result = await db.execute( + select(func.count(Item.id)).where( + Item.tryout_id == tryout.tryout_id, + Item.website_id == tryout.website_id, + Item.calibrated == True, + ) + ) + calibrated_count = calibrated_result.scalar() or 0 + + # Scoring mode badge with colors + mode_colors = { + "ctt": ("CTT", "background: #dbeafe; color: #1e40af;", "📐"), + "irt": ("IRT", "background: #fce7f3; color: #9d174d;", "📈"), + "hybrid": ("Hybrid", "background: #fef3c7; color: #92400e;", "🔄"), + } + mode_info = mode_colors.get( + tryout.scoring_mode, (tryout.scoring_mode.upper(), "", "📋") + ) + mode_badge = f'{mode_info[2]} {mode_info[0]}' + + # Calibration progress + calibration_pct = (calibrated_count / item_count * 100) if item_count > 0 else 0 + calibration_color = ( + "#10b981" + if calibration_pct >= 90 + else "#f59e0b" + if calibration_pct >= 50 + else "#ef4444" + ) + + exam_cards.append(f""" +
    +
    +

    {escape(tryout.name or tryout.tryout_id)}

    + {mode_badge} +
    +
    ID: {escape(tryout.tryout_id)}
    + +
    +
    + 📝 +
    + {item_count} + Questions +
    +
    +
    + 👥 +
    + {participant_count} + Students +
    +
    +
    + 📊 +
    + {"N/A" if avg_nm is None else f"{avg_nm:.0f}"} + Avg Score +
    +
    +
    + +
    +
    + Calibration + {calibrated_count}/{item_count} ({calibration_pct:.0f}%) +
    +
    +
    +
    +
    + +
    +
    + Score Range: + {"N/A" if min_nm is None else f"{min_nm:.0f}"} - {"N/A" if max_nm is None else f"{max_nm:.0f}"} +
    +
    + Std Dev: + {"N/A" if std_nm is None else f"{std_nm:.1f}"} +
    +
    + + +
    + """) + + # Summary cards + summary_html = f""" +
    +
    + 📋 +
    + {total_tryouts} + Total Exams +
    +
    +
    + 👥 +
    + {total_participants} + Total Students +
    +
    +
    + 📝 +
    + {total_items} + Total Questions +
    +
    +
    + """ + + body = f""" +

    View and manage your exams. Each exam shows student statistics and calibration progress.

    + + {summary_html} + +

    All Exams

    + +
    + {"".join(exam_cards) if exam_cards else '
    No exams yet. Import questions to create your first exam.
    '} +
    + + + """ + + return _render_admin_page(request, "Exams", "📋 Exams", body) + + +@router.get("/reports", include_in_schema=False) +async def reports_view(request: Request, db: AsyncSession = Depends(get_db)): + """Reports dashboard - human-friendly report access with quick stats.""" + admin = await _current_admin(request) + if not admin: + return _login_redirect() + + # Get quick stats for the overview + items_result = await db.execute(select(func.count(Item.id))) + total_items = items_result.scalar() or 0 + + calibrated_result = await db.execute( + select(func.count(Item.id)).where(Item.calibrated == True) + ) + calibrated_items = calibrated_result.scalar() or 0 + + sessions_result = await db.execute(select(func.count(Session.id))) + total_sessions = sessions_result.scalar() or 0 + + calibration_pct = (calibrated_items / total_items * 100) if total_items > 0 else 0 + + body = f""" +

    Access detailed analysis reports for your exams, questions, and students.

    + +
    +
    + 📝 +
    + {total_items} + Total Questions +
    +
    +
    + +
    + {calibrated_items} + Calibrated ({calibration_pct:.0f}%) +
    +
    +
    + 📋 +
    + {total_sessions} + Student Sessions +
    +
    +
    + +

    Analysis Reports

    + + + +

    Quick Actions

    + + + + + """ + + return _render_admin_page(request, "Reports", "📈 Reports", body) + + +@router.get("/settings", include_in_schema=False) +async def settings_view(request: Request, db: AsyncSession = Depends(get_db)): + """Settings dashboard - access to configuration pages.""" + admin = await _current_admin(request) + if not admin: + return _login_redirect() + + body = f""" +
    +
    ⚙️
    +
    +

    System Settings

    +

    Manage your exam platform configuration, websites, and account settings.

    +
    +
    + +

    Configuration

    + + +

    Account

    + + +

    System Information

    +
    +
    +
    +
    🚀
    +
    + Version + 1.0.0 +
    +
    +
    +
    +
    + Framework + FastAPI +
    +
    +
    +
    💾
    +
    + Database + PostgreSQL +
    +
    +
    +
    🔄
    +
    + Session Timeout + {settings.ADMIN_SESSION_EXPIRE_SECONDS}s +
    +
    +
    +
    + + + """ + + return _render_admin_page(request, "Settings", "⚙️ Settings", body) + + + + +# ============================================================ +# LEGACY ROUTES (backward compatibility) +# ============================================================ + + @router.get("/hierarchy", include_in_schema=False) async def hierarchy_view(request: Request, db: AsyncSession = Depends(get_db)): admin = await _current_admin(request) @@ -2035,7 +3884,9 @@ async def snapshot_questions_view( ) return _render_admin_page(request, "Tryout Import", "Tryout Import", body) - questions, promoted_items_by_slot, _ = await _load_snapshot_question_context(snapshot, db) + questions, promoted_items_by_slot, _ = await _load_snapshot_question_context( + snapshot, db + ) body = _snapshot_questions_body(snapshot, questions, promoted_items_by_slot) return _render_admin_page(request, "Snapshot Questions", "Snapshot Questions", body) @@ -2063,14 +3914,18 @@ async def snapshot_question_promote_bulk( return _render_admin_page(request, "Tryout Import", "Tryout Import", body) if not snapshot_question_ids: - questions, promoted_items_by_slot, _ = await _load_snapshot_question_context(snapshot, db) + questions, promoted_items_by_slot, _ = await _load_snapshot_question_context( + snapshot, db + ) body = _snapshot_questions_body( snapshot, questions, promoted_items_by_slot, error="Select at least one snapshot question to promote.", ) - return _render_admin_page(request, "Snapshot Questions", "Snapshot Questions", body) + return _render_admin_page( + request, "Snapshot Questions", "Snapshot Questions", body + ) question_result = await db.execute( select(TryoutSnapshotQuestion).where( @@ -2097,21 +3952,27 @@ async def snapshot_question_promote_bulk( await db.commit() - questions, promoted_items_by_slot, _ = await _load_snapshot_question_context(snapshot, db) + questions, promoted_items_by_slot, _ = await _load_snapshot_question_context( + snapshot, db + ) success_parts = [] if created_items: success_parts.append(f"created {len(created_items)} item(s)") if existing_items: success_parts.append(f"reused {len(existing_items)} existing item(s)") if missing_option_count: - success_parts.append(f"skipped {missing_option_count} question(s) with missing option text") + success_parts.append( + f"skipped {missing_option_count} question(s) with missing option text" + ) if mismatch_count: success_parts.append(f"skipped {mismatch_count} mismatched question(s)") success_message = "Bulk promote finished: " + ", ".join(success_parts) + "." if created_items: success_message += f" Latest basis item ID: {created_items[-1].id}." - body = _snapshot_questions_body(snapshot, questions, promoted_items_by_slot, success=success_message) + body = _snapshot_questions_body( + snapshot, questions, promoted_items_by_slot, success=success_message + ) return _render_admin_page(request, "Snapshot Questions", "Snapshot Questions", body) @@ -2121,7 +3982,9 @@ async def calibration_status_view(request: Request, db: AsyncSession = Depends(g if not admin: return _login_redirect() - result = await db.execute(select(Tryout.tryout_id, Tryout.name, Tryout.website_id).order_by(Tryout.id)) + result = await db.execute( + select(Tryout.tryout_id, Tryout.name, Tryout.website_id).order_by(Tryout.id) + ) tryouts = result.all() rows = [] @@ -2133,13 +3996,20 @@ async def calibration_status_view(request: Request, db: AsyncSession = Depends(g name, status["total_items"], status["calibrated_items"], - f'{status["calibration_percentage"]:.2f}%', + f"{status['calibration_percentage']:.2f}%", "Yes" if status["ready_for_irt"] else "No", ] ) body = _table( - ["Tryout ID", "Name", "Total Items", "Calibrated", "Calibration %", "Ready for IRT"], + [ + "Tryout ID", + "Name", + "Total Items", + "Calibrated", + "Calibration %", + "Ready for IRT", + ], rows, ) return _render_admin_page(request, "Calibration Status", "Calibration Status", body) @@ -2156,12 +4026,16 @@ async def item_statistics_view(request: Request, db: AsyncSession = Depends(get_ rows = [] for level in levels: - item_result = await db.execute(select(Item).where(Item.level == level).order_by(Item.slot).limit(10)) + item_result = await db.execute( + select(Item).where(Item.level == level).order_by(Item.slot).limit(10) + ) items = item_result.scalars().all() total_responses = sum(item.calibration_sample_size or 0 for item in items) calibrated_count = sum(1 for item in items if item.calibrated) calibration_percentage = (calibrated_count / len(items) * 100) if items else 0 - avg_correctness = sum(item.ctt_p or 0 for item in items) / len(items) if items else 0 + avg_correctness = ( + sum(item.ctt_p or 0 for item in items) / len(items) if items else 0 + ) rows.append( [ level, @@ -2174,7 +4048,14 @@ async def item_statistics_view(request: Request, db: AsyncSession = Depends(get_ ) body = _table( - ["Level", "Total Items", "Calibrated", "Calibration %", "Responses", "Avg Correctness"], + [ + "Level", + "Total Items", + "Calibrated", + "Calibration %", + "Responses", + "Avg Correctness", + ], rows, ) return _render_admin_page(request, "Item Statistics", "Item Statistics", body) @@ -2186,7 +4067,9 @@ async def session_overview_view(request: Request, db: AsyncSession = Depends(get if not admin: return _login_redirect() - result = await db.execute(select(Session).order_by(Session.created_at.desc()).limit(50)) + result = await db.execute( + select(Session).order_by(Session.created_at.desc()).limit(50) + ) sessions = result.scalars().all() rows = [ @@ -2204,7 +4087,17 @@ async def session_overview_view(request: Request, db: AsyncSession = Depends(get for session in sessions ] body = _table( - ["Session ID", "WP User", "Tryout", "Completed", "Mode", "Benar", "NM", "NN", "Theta"], + [ + "Session ID", + "WP User", + "Tryout", + "Completed", + "Mode", + "Benar", + "NM", + "NN", + "Theta", + ], rows, ) return _render_admin_page(request, "Session Overview", "Session Overview", body) @@ -2249,7 +4142,11 @@ async def basis_item_workspace_view( } basis_item = await db.get(Item, basis_item_id) - if basis_item is None or basis_item.generated_by == "ai" or basis_item.level != "sedang": + if ( + basis_item is None + or basis_item.generated_by == "ai" + or basis_item.level != "sedang" + ): result = await db.execute( select(Item) .where(Item.level == "sedang", Item.generated_by != "ai") @@ -2267,7 +4164,8 @@ async def basis_item_workspace_view( ) runs = list(run_result.scalars().all()) variant_result = await db.execute( - select(Item).where(Item.generated_by == "ai", Item.basis_item_id == basis_item.id) + select(Item) + .where(Item.generated_by == "ai", Item.basis_item_id == basis_item.id) .order_by(Item.created_at.desc(), Item.id.desc()) .limit(300) ) @@ -2299,7 +4197,8 @@ async def basis_item_workspace_view( family_stats, filters, ) - return _render_admin_page(request, + return _render_admin_page( + request, f"Basis Item #{basis_item.id}", f"Basis Item Workspace #{basis_item.id}", body, @@ -2324,8 +4223,14 @@ async def basis_item_generate_submit( filters = {"status": "", "level": "", "run_id": "", "min_frequency": ""} basis_item = await db.get(Item, basis_item_id) - if basis_item is None or basis_item.generated_by == "ai" or basis_item.level != "sedang": - return RedirectResponse(url="/admin/basis-items", status_code=HTTP_303_SEE_OTHER) + if ( + basis_item is None + or basis_item.generated_by == "ai" + or basis_item.level != "sedang" + ): + return RedirectResponse( + url="/admin/basis-items", status_code=HTTP_303_SEE_OTHER + ) # Llama-only policy for production quality consistency. ai_model = settings.OPENROUTER_MODEL_LLAMA @@ -2347,7 +4252,9 @@ async def basis_item_generate_submit( ) runs = list(run_result.scalars().all()) variants = list(variant_result.scalars().all()) - usage_metrics, family_stats = await _family_usage_stats(db, basis_item, variants) + usage_metrics, family_stats = await _family_usage_stats( + db, basis_item, variants + ) body = _basis_item_workspace_body( basis_item, runs, @@ -2363,16 +4270,21 @@ async def basis_item_generate_submit( include_note_for_admin=note_for_admin, include_note_in_prompt=note_in_prompt, ) - return _render_admin_page(request, + return _render_admin_page( + request, f"Basis Item #{basis_item.id}", f"Basis Item Workspace #{basis_item.id}", body, ) if target_level not in {"mudah", "sulit"}: - return RedirectResponse(url=f"/admin/basis-items/{basis_item.id}", status_code=HTTP_303_SEE_OTHER) + return RedirectResponse( + url=f"/admin/basis-items/{basis_item.id}", status_code=HTTP_303_SEE_OTHER + ) if generation_count < 1 or generation_count > 50: - return RedirectResponse(url=f"/admin/basis-items/{basis_item.id}", status_code=HTTP_303_SEE_OTHER) + return RedirectResponse( + url=f"/admin/basis-items/{basis_item.id}", status_code=HTTP_303_SEE_OTHER + ) run_id = await create_generation_run( basis_item_id=basis_item.id, @@ -2456,7 +4368,8 @@ async def basis_item_generate_submit( include_note_for_admin=note_for_admin, include_note_in_prompt=note_in_prompt, ) - return _render_admin_page(request, + return _render_admin_page( + request, f"Basis Item #{basis_item.id}", f"Basis Item Workspace #{basis_item.id}", body, @@ -2478,7 +4391,9 @@ async def basis_item_review_bulk( basis_item = await db.get(Item, basis_item_id) if basis_item is None: - return RedirectResponse(url="/admin/basis-items", status_code=HTTP_303_SEE_OTHER) + return RedirectResponse( + url="/admin/basis-items", status_code=HTTP_303_SEE_OTHER + ) valid_actions = {"approved", "rejected", "archived", "stale", "active"} if action in valid_actions and item_ids: @@ -2521,7 +4436,8 @@ async def basis_item_review_bulk( filters, success=f"Applied status '{action}' to selected variants.", ) - return _render_admin_page(request, + return _render_admin_page( + request, f"Basis Item #{basis_item.id}", f"Basis Item Workspace #{basis_item.id}", body, @@ -2531,8 +4447,7 @@ async def basis_item_review_bulk( AI_PLAYGROUND_TABS = ( ("generate", "Generate"), ("review", "Review Queue"), - ("runs", "Runs"), - ("basis", "Basis Items"), + ("runs", "Batches"), ) AI_VARIANT_STATUSES = ("draft", "approved", "active", "rejected", "archived", "stale") AI_VARIANT_LEVELS = ("mudah", "sulit") @@ -2542,21 +4457,23 @@ def _selected_option(value: str, selected_value: str) -> str: return "selected" if value == selected_value else "" -def _ai_tab_nav(active_tab: str) -> str: +def _ai_tab_nav(item_id: int, active_tab: str) -> str: links = [] for tab, label in AI_PLAYGROUND_TABS: active_class = "active" if tab == active_tab else "" aria = ' aria-current="page"' if tab == active_tab else "" links.append( - f'{escape(label)}' + f'{escape(label)}' ) - return f'' + return f'' def _status_pill(status: str | None) -> str: value = status or "unknown" css_value = re.sub(r"[^a-z0-9_-]+", "-", value.lower()) - return f'{escape(value)}' + return ( + f'{escape(value)}' + ) def _ai_status_strip( @@ -2577,7 +4494,7 @@ def _ai_status_strip(
    OpenRouter{"Yes" if key_configured else "No"}
    AI Items{stats.get("total_ai_items", 0)}
    -
    Latest Run{escape(latest_run)}
    +
    Latest Batch{escape(latest_run)}
    Saved{escape(latest_saved)}
    """ @@ -2589,7 +4506,7 @@ def _ai_generation_summary(generation_summary: dict[str, Any] | None) -> str: saved_item_ids = generation_summary.get("saved_item_ids") or [] return f"""
    -
    Run ID{generation_summary.get("run_id", "-")}
    +
    Batch ID{generation_summary.get("run_id", "-")}
    Requested{generation_summary.get("requested_count", 0)}
    Generated{generation_summary.get("generated_count", 0)}
    Saved{len(saved_item_ids)}
    @@ -2598,9 +4515,8 @@ def _ai_generation_summary(generation_summary: dict[str, Any] | None) -> str: def _ai_generate_tab( - basis_items: list[Item], + item: Item, generation_summary: dict[str, Any] | None, - basis_item_id: str, target_level: str, ai_model: str, generation_count: str, @@ -2608,100 +4524,60 @@ def _ai_generate_tab( include_note_for_admin: bool, include_note_in_prompt: bool, ) -> str: - seed_callout = "" - if not basis_items: - seed_callout = """ -
    No sedang basis items found yet.
    -
    - -
    - """ - selected_basis_id = str(basis_item_id or "") - basis_options = [''] - for item in basis_items: - item_id = str(item.id) - selected = _selected_option(item_id, selected_basis_id) - stem_preview = _truncate(_html_to_text(item.stem), 82) - basis_options.append( - f'' - ) + full_stem = escape(_html_to_text(item.stem)) + basis_selection_html = f""" +
    +

    Basis Item Context

    +

    Tryout: {escape(str(item.tryout_id))} | Slot: {item.slot} | ID: #{item.id}

    +

    "{full_stem}"

    + +
    + """ return f"""
    - {seed_callout} {_ai_generation_summary(generation_summary)} -
    -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    - - Find Basis Item + + + {basis_selection_html} + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    """ -def _ai_basis_tab(basis_items: list[Item]) -> str: - rows = [] - for item in basis_items: - stem_preview = _truncate(_html_to_text(item.stem), 140) - rows.append( - "" - f"{item.id}" - f"{escape(str(item.tryout_id))}" - f"{item.slot}" - f"{item.website_id}" - f"{escape(stem_preview)}" - f"Use" - "" - ) - - table = ( - "
    " - + ("".join(rows) if rows else "") - + "
    Item IDTryoutSlotWebsiteStemAction
    No sedang basis items found.
    " - ) - return f""" -
    -
    -
    - -
    -
    - {table} -
    - """ def _ai_runs_tab( + item: Item, generation_runs: list[AIGenerationRun], generation_summary: dict[str, Any] | None, ) -> str: @@ -2716,12 +4592,16 @@ def _ai_runs_tab( f"{escape(_truncate(run.model, 54))}" f"{escape(run.created_by)}" f"{escape(str(run.created_at))}" - f"Review" + f'Review' "" ) table = ( - "
    " - + ("".join(rows) if rows else "") + '
    Run IDBasis ItemTargetRequestedModelCreated ByCreated AtAction
    No generation runs yet.
    ' + + ( + "".join(rows) + if rows + else '' + ) + "
    Batch IDBasis ItemTargetRequestedModelCreated ByCreated AtAction
    No generation batches yet.
    " ) return f""" @@ -2733,6 +4613,7 @@ def _ai_runs_tab( def _ai_review_tab( + item: Item, generated_variants: list[Item], status_filter: str, level_filter: str, @@ -2754,7 +4635,7 @@ def _ai_review_tab( stem_preview = _truncate(_html_to_text(item.stem), 120) variant_rows.append( "" - f"" + f'' f"{item.id}" f"{item.generation_run_id or '-'}" f"{item.basis_item_id or '-'}" @@ -2763,7 +4644,7 @@ def _ai_review_tab( f"{escape(_truncate(item.ai_model or '-', 42))}" f"{escape(stem_preview)}" f"{escape(str(item.created_at))}" - f"View" + f'View' "" ) variant_table_rows = ( @@ -2774,7 +4655,7 @@ def _ai_review_tab( return f"""
    -
    +