first commit
This commit is contained in:
6
.claude/settings.local.json
Normal file
6
.claude/settings.local.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"enabledMcpjsonServers": [
|
||||||
|
"brave-search"
|
||||||
|
],
|
||||||
|
"enableAllProjectMcpServers": true
|
||||||
|
}
|
||||||
31
.env.example
Normal file
31
.env.example
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Database
|
||||||
|
DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/irt_bank_soal
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=irt_bank_soal
|
||||||
|
DB_USER=postgres
|
||||||
|
DB_PASSWORD=your_password_here
|
||||||
|
|
||||||
|
# FastAPI
|
||||||
|
SECRET_KEY=your-secret-key-here-change-in-production
|
||||||
|
API_V1_STR=/api/v1
|
||||||
|
PROJECT_NAME=IRT Bank Soal
|
||||||
|
ENVIRONMENT=development
|
||||||
|
|
||||||
|
# OpenRouter (AI Generation)
|
||||||
|
OPENROUTER_API_KEY=your-openrouter-api-key-here
|
||||||
|
OPENROUTER_MODEL_QWEN=qwen/qwen-2.5-coder-32b-instruct
|
||||||
|
OPENROUTER_MODEL_LLAMA=meta-llama/llama-3.3-70b-instruct
|
||||||
|
OPENROUTER_TIMEOUT=30
|
||||||
|
|
||||||
|
# WordPress Integration
|
||||||
|
WORDPRESS_API_URL=https://your-wordpress-site.com/wp-json
|
||||||
|
WORDPRESS_AUTH_TOKEN=your-wordpress-jwt-token
|
||||||
|
|
||||||
|
# Redis (Celery)
|
||||||
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
CELERY_BROKER_URL=redis://localhost:6379/0
|
||||||
|
CELERY_RESULT_BACKEND=redis://localhost:6379/0
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
ALLOWED_ORIGINS=https://site1.com,https://site2.com,https://site3.com
|
||||||
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
*.pyc
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.DS_Store
|
||||||
952
AAPANEL_DEPLOYMENT.md
Normal file
952
AAPANEL_DEPLOYMENT.md
Normal file
@@ -0,0 +1,952 @@
|
|||||||
|
# IRT Bank Soal - AaPanel Deployment Guide
|
||||||
|
|
||||||
|
**Document Version:** 1.1
|
||||||
|
**Date:** March 21, 2026
|
||||||
|
**Project:** IRT-Powered Adaptive Question Bank System v1.2.0
|
||||||
|
**Updated:** Clarified PostgreSQL setup using Databases > PgSQL menu
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Prerequisites](#1-prerequisites)
|
||||||
|
2. [AaPanel Installation](#2-aapanel-installation)
|
||||||
|
3. [Install Required Software via AaPanel](#3-install-required-software-via-aapanel)
|
||||||
|
4. [PostgreSQL Setup](#4-postgresql-setup)
|
||||||
|
5. [Python Manager Setup](#5-python-manager-setup)
|
||||||
|
6. [Project Deployment](#6-project-deployment)
|
||||||
|
7. [Environment Configuration](#7-environment-configuration)
|
||||||
|
8. [Database Migration](#8-database-migration)
|
||||||
|
9. [Running the Application](#9-running-the-application)
|
||||||
|
10. [Nginx Reverse Proxy Configuration](#10-nginx-reverse-proxy-configuration)
|
||||||
|
11. [SSL Configuration](#11-ssl-configuration)
|
||||||
|
12. [Post-Deployment Verification](#12-post-deployment-verification)
|
||||||
|
13. [Troubleshooting](#13-troubleshooting)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Prerequisites
|
||||||
|
|
||||||
|
### Server Requirements
|
||||||
|
|
||||||
|
| Requirement | Minimum | Recommended |
|
||||||
|
|-------------|---------|-------------|
|
||||||
|
| OS | Ubuntu 20.04 / CentOS 7+ | Ubuntu 22.04 LTS |
|
||||||
|
| RAM | 2 GB | 4 GB+ |
|
||||||
|
| Storage | 20 GB | 50 GB+ |
|
||||||
|
| CPU | 1 vCPU | 2+ vCPU |
|
||||||
|
|
||||||
|
### Domain Requirements
|
||||||
|
|
||||||
|
- A domain name pointed to your server IP
|
||||||
|
- Subdomain recommended (e.g., `api.yourdomain.com`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. AaPanel Installation
|
||||||
|
|
||||||
|
### Step 2.1: Install AaPanel
|
||||||
|
|
||||||
|
**For Ubuntu/Debian:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Login to your server via SSH
|
||||||
|
ssh root@your-server-ip
|
||||||
|
|
||||||
|
# Install AaPanel
|
||||||
|
wget -O install.sh http://www.aapanel.com/script/install-ubuntu_6.0_en.sh && bash install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**For CentOS:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install AaPanel
|
||||||
|
yum install -y wget && wget -O install.sh http://www.aapanel.com/script/install_6.0_en.sh && sh install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2.2: Access AaPanel
|
||||||
|
|
||||||
|
1. After installation completes, note the panel URL and credentials
|
||||||
|
2. Access AaPanel via browser: `http://your-server-ip:8888`
|
||||||
|
3. Login with provided credentials
|
||||||
|
4. **Important:** Change default port and password after first login
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Install Required Software via AaPanel
|
||||||
|
|
||||||
|
### Step 3.1: Install Nginx
|
||||||
|
|
||||||
|
1. In AaPanel, go to **App Store**
|
||||||
|
2. Find **Nginx** and click **Install**
|
||||||
|
3. Select version (recommended: 1.24+)
|
||||||
|
4. Click **Submit** and wait for installation
|
||||||
|
|
||||||
|
### Step 3.2: Install Python Manager
|
||||||
|
|
||||||
|
1. Go to **App Store**
|
||||||
|
2. Search for **Python Manager** (or **PM2 Manager**)
|
||||||
|
3. Click **Install**
|
||||||
|
|
||||||
|
### Step 3.3: Install Redis (Optional, for Celery)
|
||||||
|
|
||||||
|
1. Go to **App Store**
|
||||||
|
2. Find **Redis** and click **Install**
|
||||||
|
3. Click **Submit**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. PostgreSQL Setup
|
||||||
|
|
||||||
|
> **IMPORTANT:** Use **Databases > PgSQL** menu from AaPanel sidebar.
|
||||||
|
>
|
||||||
|
> This menu supports both:
|
||||||
|
> - **Local server** - PostgreSQL installed on your AaPanel server
|
||||||
|
> - **Remote server** - External PostgreSQL (Supabase, Neon, AWS RDS, etc.)
|
||||||
|
|
||||||
|
### Step 4.1: Choose Your Database Type
|
||||||
|
|
||||||
|
You have two options:
|
||||||
|
|
||||||
|
| Option | Description | Best For |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| **Remote Database** | External PostgreSQL service (Supabase, Neon, etc.) | Easy setup, managed, free tier available |
|
||||||
|
| **Local Database** | PostgreSQL on your AaPanel server | Full control, no external dependency |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Option A: Remote PostgreSQL Database (RECOMMENDED)
|
||||||
|
|
||||||
|
Use an external PostgreSQL service:
|
||||||
|
- **Supabase** - https://supabase.com (free tier: 500MB)
|
||||||
|
- **Neon** - https://neon.tech (free tier: 3GB)
|
||||||
|
- **AWS RDS** - https://aws.amazon.com/rds/postgresql/
|
||||||
|
- **DigitalOcean** - https://www.digitalocean.com/products/managed-databases-postgresql
|
||||||
|
- **Railway** - https://railway.app
|
||||||
|
|
||||||
|
#### Step 4.A.1: Create Database on Provider
|
||||||
|
|
||||||
|
1. Sign up on your chosen provider
|
||||||
|
2. Create a new PostgreSQL project/database
|
||||||
|
3. Note down the connection details from dashboard:
|
||||||
|
- **Host** (e.g., `db.xxxxx.supabase.co` or `ep-xxx.us-east-2.aws.neon.tech`)
|
||||||
|
- **Port** (usually `5432`, Supabase uses `6543` for pooler)
|
||||||
|
- **Database name** (e.g., `postgres` or `neondb`)
|
||||||
|
- **Username** (e.g., `postgres.xxxxx`)
|
||||||
|
- **Password**
|
||||||
|
|
||||||
|
#### Step 4.A.2: Add Remote Server to AaPanel PgSQL
|
||||||
|
|
||||||
|
1. In AaPanel, go to **Databases** > **PgSQL**
|
||||||
|
2. Click **Remote DB** button
|
||||||
|
3. Fill in the form:
|
||||||
|
- **Server Name:** `my-remote-db` (any name you like)
|
||||||
|
- **Server Address:** `db.xxxxx.supabase.co` (your host)
|
||||||
|
- **Port:** `5432` or `6543` (check your provider)
|
||||||
|
- **Root User:** `postgres` or your username
|
||||||
|
- **Root Password:** your password
|
||||||
|
4. Click **Submit**
|
||||||
|
|
||||||
|
#### Step 4.A.3: Sync Databases from Remote Server
|
||||||
|
|
||||||
|
1. After adding remote server, click **Get DB from server**
|
||||||
|
2. Select your remote server from dropdown
|
||||||
|
3. Click **Submit**
|
||||||
|
4. Your remote databases will appear in the list
|
||||||
|
|
||||||
|
#### Step 4.A.4: Note Your Connection String
|
||||||
|
|
||||||
|
Your connection string format:
|
||||||
|
```
|
||||||
|
postgresql+asyncpg://username:password@host:port/database_name
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example (Supabase):**
|
||||||
|
```
|
||||||
|
postgresql+asyncpg://postgres.xxxxx:YourPassword@aws-0-ap-southeast-1.pooler.supabase.com:6543/postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example (Neon):**
|
||||||
|
```
|
||||||
|
postgresql+asyncpg://neondb_owner:YourPassword@ep-xxxx.us-east-2.aws.neon.tech/neondb?sslmode=require
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Option B: Local PostgreSQL Database
|
||||||
|
|
||||||
|
Install PostgreSQL directly on your AaPanel server.
|
||||||
|
|
||||||
|
#### Step 4.B.1: Install PostgreSQL via Terminal
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SSH into your server
|
||||||
|
ssh root@your-server-ip
|
||||||
|
|
||||||
|
# Ubuntu/Debian
|
||||||
|
apt update
|
||||||
|
apt install -y postgresql postgresql-contrib
|
||||||
|
|
||||||
|
# Start and enable PostgreSQL
|
||||||
|
systemctl start postgresql
|
||||||
|
systemctl enable postgresql
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
systemctl status postgresql
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 4.B.2: Create Database and User via Terminal
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Switch to postgres user
|
||||||
|
su - postgres
|
||||||
|
|
||||||
|
# Enter PostgreSQL CLI
|
||||||
|
psql
|
||||||
|
|
||||||
|
# Run SQL commands:
|
||||||
|
CREATE DATABASE irt_bank_soal;
|
||||||
|
|
||||||
|
CREATE USER irt_user WITH ENCRYPTED PASSWORD 'your_secure_password_here';
|
||||||
|
|
||||||
|
GRANT ALL PRIVILEGES ON DATABASE irt_bank_soal TO irt_user;
|
||||||
|
|
||||||
|
# Connect to database and grant schema
|
||||||
|
\c irt_bank_soal
|
||||||
|
GRANT ALL ON SCHEMA public TO irt_user;
|
||||||
|
|
||||||
|
# Exit
|
||||||
|
\q
|
||||||
|
exit
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 4.B.3: Add Local Server to AaPanel PgSQL
|
||||||
|
|
||||||
|
1. In AaPanel, go to **Databases** > **PgSQL**
|
||||||
|
2. Click **Root Password** to view/change postgres password
|
||||||
|
3. If your local PostgreSQL is not showing, click **Get DB from server**
|
||||||
|
4. Select **Local server**
|
||||||
|
5. Click **Submit**
|
||||||
|
|
||||||
|
#### Step 4.B.4: Create Additional Database via AaPanel (Optional)
|
||||||
|
|
||||||
|
1. In **Databases** > **PgSQL**
|
||||||
|
2. Click **Add DB**
|
||||||
|
3. Fill in:
|
||||||
|
- **Database name:** `irt_bank_soal`
|
||||||
|
- **Username:** `irt_user` (or same as DB name)
|
||||||
|
- **Password:** (click generate or enter custom)
|
||||||
|
- **Add to:** `Local server`
|
||||||
|
4. Click **Submit**
|
||||||
|
|
||||||
|
#### Step 4.B.5: Note Your Connection String
|
||||||
|
|
||||||
|
```
|
||||||
|
postgresql+asyncpg://irt_user:your_password@127.0.0.1:5432/irt_bank_soal
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.1 Test Database Connection
|
||||||
|
|
||||||
|
Before proceeding, verify your database connection works.
|
||||||
|
|
||||||
|
### For Remote Database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install psql client if needed
|
||||||
|
apt install -y postgresql-client
|
||||||
|
|
||||||
|
# Test connection (replace with your details)
|
||||||
|
psql "postgresql://username:password@host:port/database_name" -c "SELECT version();"
|
||||||
|
```
|
||||||
|
|
||||||
|
### For Local Database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test connection
|
||||||
|
psql -U irt_user -d irt_bank_soal -h 127.0.0.1 -c "SELECT version();"
|
||||||
|
|
||||||
|
# If prompted for password, enter it
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.2 Connection String Quick Reference
|
||||||
|
|
||||||
|
| Database Type | Connection String Format |
|
||||||
|
|---------------|-------------------------|
|
||||||
|
| **Remote (Supabase)** | `postgresql+asyncpg://postgres.xxxx:password@aws-0-region.pooler.supabase.com:6543/postgres` |
|
||||||
|
| **Remote (Neon)** | `postgresql+asyncpg://user:password@ep-xxxx.region.aws.neon.tech/neondb?sslmode=require` |
|
||||||
|
| **Local** | `postgresql+asyncpg://irt_user:password@127.0.0.1:5432/irt_bank_soal` |
|
||||||
|
|
||||||
|
> **Note:** We use `postgresql+asyncpg://` because our app uses async SQLAlchemy with `asyncpg` driver.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Python Manager Setup
|
||||||
|
|
||||||
|
### Step 5.1: Open Python Manager
|
||||||
|
|
||||||
|
1. In AaPanel, go to **App Store**
|
||||||
|
2. Find **Python Manager** and click **Settings**
|
||||||
|
|
||||||
|
### Step 5.2: Install Python Version
|
||||||
|
|
||||||
|
1. Click **Version Management**
|
||||||
|
2. Select **Python 3.11** (or latest stable)
|
||||||
|
3. Click **Install**
|
||||||
|
4. Wait for installation to complete
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Project Deployment
|
||||||
|
|
||||||
|
### Step 6.1: Create Project Directory
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create project directory
|
||||||
|
mkdir -p /www/wwwroot/irt-bank-soal
|
||||||
|
|
||||||
|
# Navigate to directory
|
||||||
|
cd /www/wwwroot/irt-bank-soal
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6.2: Upload Project Files
|
||||||
|
|
||||||
|
**Option A: Upload via File Manager**
|
||||||
|
|
||||||
|
1. In AaPanel, go to **Files**
|
||||||
|
2. Navigate to `/www/wwwroot/irt-bank-soal`
|
||||||
|
3. Upload your project ZIP file
|
||||||
|
4. Extract the archive
|
||||||
|
|
||||||
|
**Option B: Clone from Git (if applicable)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /www/wwwroot/irt-bank-soal
|
||||||
|
|
||||||
|
# If using Git
|
||||||
|
git clone https://github.com/your-repo/irt-bank-soal.git .
|
||||||
|
|
||||||
|
# Or copy from local
|
||||||
|
# scp -r /Users/dwindown/Applications/tryout-system/* root@your-server-ip:/www/wwwroot/irt-bank-soal/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6.3: Verify Project Structure
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Expected structure:
|
||||||
|
ls -la /www/wwwroot/irt-bank-soal/
|
||||||
|
# app/
|
||||||
|
# app/models/
|
||||||
|
# app/routers/
|
||||||
|
# app/services/
|
||||||
|
# app/core/
|
||||||
|
# tests/
|
||||||
|
# requirements.txt
|
||||||
|
# .env.example
|
||||||
|
# alembic/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Environment Configuration
|
||||||
|
|
||||||
|
### Step 7.1: Create Virtual Environment via Python Manager
|
||||||
|
|
||||||
|
1. In AaPanel **Python Manager**, click **Add Project**
|
||||||
|
2. Configure:
|
||||||
|
- **Project Name:** `irt-bank-soal`
|
||||||
|
- **Project Path:** `/www/wwwroot/irt-bank-soal`
|
||||||
|
- **Python Version:** `Python 3.11`
|
||||||
|
- **Framework:** `FastAPI`
|
||||||
|
- **Startup Method:** `uvicorn`
|
||||||
|
3. Click **Submit**
|
||||||
|
|
||||||
|
### Step 7.2: Create Environment File
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy example file
|
||||||
|
cp /www/wwwroot/irt-bank-soal/.env.example /www/wwwroot/irt-bank-soal/.env
|
||||||
|
|
||||||
|
# Edit .env file
|
||||||
|
nano /www/wwwroot/irt-bank-soal/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 7.3: Configure .env File
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Database Configuration
|
||||||
|
# For Remote Database (Supabase example):
|
||||||
|
# DATABASE_URL=postgresql+asyncpg://postgres.xxxx:password@aws-0-ap-southeast-1.pooler.supabase.com:6543/postgres
|
||||||
|
# For Remote Database (Neon example):
|
||||||
|
# DATABASE_URL=postgresql+asyncpg://neondb_owner:password@ep-xxxx.us-east-2.aws.neon.tech/neondb?sslmode=require
|
||||||
|
# For Local Database:
|
||||||
|
DATABASE_URL=postgresql+asyncpg://irt_user:your_secure_password_here@127.0.0.1:5432/irt_bank_soal
|
||||||
|
|
||||||
|
# Security
|
||||||
|
SECRET_KEY=your-production-secret-key-min-32-characters-random-string
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
ENVIRONMENT=production
|
||||||
|
DEBUG=false
|
||||||
|
|
||||||
|
# API Configuration
|
||||||
|
API_V1_STR=/api/v1
|
||||||
|
PROJECT_NAME=IRT Bank Soal
|
||||||
|
PROJECT_VERSION=1.2.0
|
||||||
|
|
||||||
|
# CORS - Add your WordPress domains
|
||||||
|
ALLOWED_ORIGINS=https://yourdomain.com,https://www.yourdomain.com
|
||||||
|
|
||||||
|
# OpenRouter API (for AI Generation)
|
||||||
|
OPENROUTER_API_KEY=your-openrouter-api-key-here
|
||||||
|
OPENROUTER_API_URL=https://openrouter.ai/api/v1
|
||||||
|
OPENROUTER_MODEL_QWEN=qwen/qwen-2.5-coder-32b-instruct
|
||||||
|
OPENROUTER_MODEL_LLAMA=meta-llama/llama-3.3-70b-instruct
|
||||||
|
OPENROUTER_TIMEOUT=60
|
||||||
|
|
||||||
|
# WordPress Integration
|
||||||
|
WORDPRESS_API_URL=https://yourdomain.com/wp-json
|
||||||
|
WORDPRESS_AUTH_TOKEN=your-wordpress-jwt-token
|
||||||
|
|
||||||
|
# Redis (for Celery task queue)
|
||||||
|
REDIS_URL=redis://127.0.0.1:6379/0
|
||||||
|
|
||||||
|
# Admin Panel
|
||||||
|
ADMIN_USER=admin
|
||||||
|
ADMIN_PASSWORD=your-secure-admin-password
|
||||||
|
|
||||||
|
# Normalization Defaults
|
||||||
|
DEFAULT_RATAAN=500
|
||||||
|
DEFAULT_SB=100
|
||||||
|
MIN_SAMPLE_FOR_DYNAMIC=100
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 7.4: Generate Secret Key
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate a secure secret key
|
||||||
|
python3 -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
|
|
||||||
|
# Copy the output and paste into SECRET_KEY in .env
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Database Migration
|
||||||
|
|
||||||
|
### Step 8.1: Activate Virtual Environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Via Python Manager, the venv is usually at:
|
||||||
|
source /www/wwwroot/irt-bank-soal/venv/bin/activate
|
||||||
|
|
||||||
|
# Or check Python Manager for exact venv path
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 8.2: Install Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ensure you're in project directory
|
||||||
|
cd /www/wwwroot/irt-bank-soal
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Verify installation
|
||||||
|
pip list | grep -E "fastapi|sqlalchemy|numpy|scipy|httpx|openpyxl"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 8.3: Initialize Alembic (First Time Setup)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Initialize Alembic if not already done
|
||||||
|
alembic init alembic
|
||||||
|
|
||||||
|
# Generate initial migration
|
||||||
|
alembic revision --autogenerate -m "Initial migration"
|
||||||
|
|
||||||
|
# Apply migration
|
||||||
|
alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 8.4: Verify Database Tables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check tables were created
|
||||||
|
psql -U irt_user -d irt_bank_soal -h 127.0.0.1 -c "\dt"
|
||||||
|
|
||||||
|
# Expected output: websites, users, tryouts, items, sessions, user_answers, tryout_stats
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Running the Application
|
||||||
|
|
||||||
|
### Step 9.1: Configure Python Project in AaPanel
|
||||||
|
|
||||||
|
1. In **Python Manager**, find your project `irt-bank-soal`
|
||||||
|
2. Click **Settings**
|
||||||
|
3. Configure startup:
|
||||||
|
- **Startup File:** `app/main.py`
|
||||||
|
- **Startup Method:** `uvicorn`
|
||||||
|
- **Port:** `8000`
|
||||||
|
- **Modules:** `uvicorn[standard]`
|
||||||
|
|
||||||
|
### Step 9.2: Set Startup Command
|
||||||
|
|
||||||
|
In Python Manager settings, set the startup command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Startup command
|
||||||
|
uvicorn app.main:app --host 127.0.0.1 --port 8000 --workers 4
|
||||||
|
|
||||||
|
# Or for development:
|
||||||
|
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 9.3: Start the Application
|
||||||
|
|
||||||
|
1. In Python Manager, click **Start** on your project
|
||||||
|
2. Check logs for any errors
|
||||||
|
3. Verify the application is running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test health endpoint
|
||||||
|
curl http://127.0.0.1:8000/
|
||||||
|
|
||||||
|
# Expected response:
|
||||||
|
# {"status": "healthy", "project_name": "IRT Bank Soal", "version": "1.2.0"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 9.4: Configure Auto-Start on Boot
|
||||||
|
|
||||||
|
1. In Python Manager, enable **Auto-start on boot**
|
||||||
|
2. Or manually via terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using systemd (create service file)
|
||||||
|
nano /etc/systemd/system/irt-bank-soal.service
|
||||||
|
```
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=IRT Bank Soal FastAPI Application
|
||||||
|
After=network.target
|
||||||
|
# Uncomment below if using LOCAL PostgreSQL:
|
||||||
|
# After=network.target postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=www
|
||||||
|
Group=www
|
||||||
|
WorkingDirectory=/www/wwwroot/irt-bank-soal
|
||||||
|
Environment="PATH=/www/wwwroot/irt-bank-soal/venv/bin"
|
||||||
|
ExecStart=/www/wwwroot/irt-bank-soal/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000 --workers 4
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Enable and start service
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable irt-bank-soal
|
||||||
|
systemctl start irt-bank-soal
|
||||||
|
systemctl status irt-bank-soal
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Nginx Reverse Proxy Configuration
|
||||||
|
|
||||||
|
### Step 10.1: Create Website in AaPanel
|
||||||
|
|
||||||
|
1. In AaPanel, go to **Website**
|
||||||
|
2. Click **Add Site**
|
||||||
|
3. Configure:
|
||||||
|
- **Domain:** `api.yourdomain.com` (or your subdomain)
|
||||||
|
- **PHP Version:** Pure Static (not needed)
|
||||||
|
- **Database:** None (already created)
|
||||||
|
4. Click **Submit**
|
||||||
|
|
||||||
|
### Step 10.2: Configure Reverse Proxy
|
||||||
|
|
||||||
|
1. Click **Settings** on the newly created website
|
||||||
|
2. Go to **Reverse Proxy**
|
||||||
|
3. Click **Add Reverse Proxy**
|
||||||
|
4. Configure:
|
||||||
|
- **Proxy Name:** `irt-api`
|
||||||
|
- **Target URL:** `http://127.0.0.1:8000`
|
||||||
|
5. Click **Submit**
|
||||||
|
|
||||||
|
### Step 10.3: Manual Nginx Configuration (Alternative)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Edit Nginx config
|
||||||
|
nano /www/server/panel/vhost/nginx/api.yourdomain.com.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name api.yourdomain.com;
|
||||||
|
|
||||||
|
# Access and error logs
|
||||||
|
access_log /www/wwwlogs/api.yourdomain.com.log;
|
||||||
|
error_log /www/wwwlogs/api.yourdomain.com.error.log;
|
||||||
|
|
||||||
|
# Client body size (for Excel uploads)
|
||||||
|
client_max_body_size 50M;
|
||||||
|
|
||||||
|
# Proxy to FastAPI
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Static files (if any)
|
||||||
|
location /static/ {
|
||||||
|
alias /www/wwwroot/irt-bank-soal/static/;
|
||||||
|
expires 30d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 10.4: Test and Reload Nginx
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test Nginx configuration
|
||||||
|
nginx -t
|
||||||
|
|
||||||
|
# Reload Nginx
|
||||||
|
nginx -s reload
|
||||||
|
|
||||||
|
# Or via AaPanel: Website > Settings > Config > Save
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. SSL Configuration
|
||||||
|
|
||||||
|
### Step 11.1: Install SSL Certificate
|
||||||
|
|
||||||
|
1. In AaPanel, go to **Website**
|
||||||
|
2. Click **Settings** on your site
|
||||||
|
3. Go to **SSL**
|
||||||
|
4. Choose method:
|
||||||
|
- **Let's Encrypt:** Free, auto-renewal
|
||||||
|
- **Own Certificate:** Upload your own
|
||||||
|
- **Buy:** Purchase through AaPanel
|
||||||
|
|
||||||
|
### Step 11.2: Configure Let's Encrypt
|
||||||
|
|
||||||
|
1. Click **Let's Encrypt**
|
||||||
|
2. Enter your email
|
||||||
|
3. Select domain `api.yourdomain.com`
|
||||||
|
4. Click **Apply**
|
||||||
|
5. Enable **Force HTTPS**
|
||||||
|
|
||||||
|
### Step 11.3: Update .env for HTTPS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Edit .env
|
||||||
|
nano /www/wwwroot/irt-bank-soal/.env
|
||||||
|
|
||||||
|
# Update CORS to use HTTPS
|
||||||
|
ALLOWED_ORIGINS=https://yourdomain.com,https://www.yourdomain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Post-Deployment Verification
|
||||||
|
|
||||||
|
### Step 12.1: Test API Endpoints
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test health endpoint
|
||||||
|
curl https://api.yourdomain.com/
|
||||||
|
|
||||||
|
# Test detailed health
|
||||||
|
curl https://api.yourdomain.com/health
|
||||||
|
|
||||||
|
# Test API documentation
|
||||||
|
# Open in browser: https://api.yourdomain.com/docs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 12.2: Test Database Connection
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Via API
|
||||||
|
curl https://api.yourdomain.com/health
|
||||||
|
|
||||||
|
# Expected response includes database status:
|
||||||
|
# {"status": "healthy", "database": "connected", "api_version": "v1"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 12.3: Test Admin Panel
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Access admin panel
|
||||||
|
# Open in browser: https://api.yourdomain.com/admin
|
||||||
|
# Login with credentials from .env
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 12.4: Load Test Data (Optional)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SSH into server
|
||||||
|
ssh root@your-server-ip
|
||||||
|
|
||||||
|
# Navigate to project
|
||||||
|
cd /www/wwwroot/irt-bank-soal
|
||||||
|
|
||||||
|
# Activate venv
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
# Run test data script
|
||||||
|
python3 -c "
|
||||||
|
import asyncio
|
||||||
|
from app.database import init_db
|
||||||
|
asyncio.run(init_db())
|
||||||
|
print('Database initialized successfully')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Troubleshooting
|
||||||
|
|
||||||
|
### Issue: Python Manager Not Starting Application
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check logs
|
||||||
|
tail -f /www/wwwroot/irt-bank-soal/logs/error.log
|
||||||
|
|
||||||
|
# Check if port is in use
|
||||||
|
lsof -i :8000
|
||||||
|
|
||||||
|
# Manually test startup
|
||||||
|
cd /www/wwwroot/irt-bank-soal
|
||||||
|
source venv/bin/activate
|
||||||
|
uvicorn app.main:app --host 127.0.0.1 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue: Database Connection Failed
|
||||||
|
|
||||||
|
**For Remote Database:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test connection from server
|
||||||
|
apt install -y postgresql-client
|
||||||
|
psql "postgresql://username:password@remote-host:port/database" -c "SELECT 1;"
|
||||||
|
|
||||||
|
# Check if firewall allows outbound connection
|
||||||
|
# Most remote DBs use port 5432 or 6543
|
||||||
|
|
||||||
|
# Verify DATABASE_URL in .env
|
||||||
|
cat /www/wwwroot/irt-bank-soal/.env | grep DATABASE_URL
|
||||||
|
|
||||||
|
# Common issues:
|
||||||
|
# - Wrong port (Supabase pooler uses 6543, direct uses 5432)
|
||||||
|
# - Missing sslmode=require (Neon requires this)
|
||||||
|
# - IP not whitelisted (check provider dashboard)
|
||||||
|
```
|
||||||
|
|
||||||
|
**For Local Database:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check PostgreSQL status
|
||||||
|
systemctl status postgresql
|
||||||
|
|
||||||
|
# Test connection manually
|
||||||
|
psql -U irt_user -d irt_bank_soal -h 127.0.0.1 -W
|
||||||
|
|
||||||
|
# Check pg_hba.conf allows connections
|
||||||
|
cat /etc/postgresql/*/main/pg_hba.conf | grep -v "^#" | grep -v "^$"
|
||||||
|
|
||||||
|
# Verify DATABASE_URL in .env
|
||||||
|
cat /www/wwwroot/irt-bank-soal/.env | grep DATABASE_URL
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue: 502 Bad Gateway
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check if FastAPI is running
|
||||||
|
ps aux | grep uvicorn
|
||||||
|
|
||||||
|
# Check Nginx error logs
|
||||||
|
tail -f /www/wwwlogs/api.yourdomain.com.error.log
|
||||||
|
|
||||||
|
# Verify proxy configuration
|
||||||
|
cat /www/server/panel/vhost/nginx/api.yourdomain.com.conf | grep proxy_pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue: CORS Errors
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check ALLOWED_ORIGINS in .env
|
||||||
|
cat /www/wwwroot/irt-bank-soal/.env | grep ALLOWED_ORIGINS
|
||||||
|
|
||||||
|
# Ensure WordPress domain is included
|
||||||
|
# Example: ALLOWED_ORIGINS=https://site1.com,https://site2.com
|
||||||
|
|
||||||
|
# Restart application after changes
|
||||||
|
# Via Python Manager: Stop > Start
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue: SSL Certificate Not Working
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check certificate
|
||||||
|
openssl s_client -connect api.yourdomain.com:443
|
||||||
|
|
||||||
|
# Force HTTPS in Nginx config
|
||||||
|
# Add to server block:
|
||||||
|
# return 301 https://$host$request_uri;
|
||||||
|
|
||||||
|
# Reload Nginx
|
||||||
|
nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue: Large File Upload Failed
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Increase Nginx client body size
|
||||||
|
nano /www/server/panel/vhost/nginx/api.yourdomain.com.conf
|
||||||
|
|
||||||
|
# Add/modify:
|
||||||
|
# client_max_body_size 100M;
|
||||||
|
|
||||||
|
# Also check PHP settings if using PHP
|
||||||
|
# In AaPanel: PHP > Settings > Upload Max Filesize
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Application Management
|
||||||
|
systemctl start irt-bank-soal
|
||||||
|
systemctl stop irt-bank-soal
|
||||||
|
systemctl restart irt-bank-soal
|
||||||
|
systemctl status irt-bank-soal
|
||||||
|
|
||||||
|
# Local Database Management (if using local PostgreSQL)
|
||||||
|
systemctl start postgresql
|
||||||
|
systemctl stop postgresql
|
||||||
|
systemctl restart postgresql
|
||||||
|
systemctl status postgresql
|
||||||
|
|
||||||
|
# Nginx Management
|
||||||
|
nginx -t # Test config
|
||||||
|
nginx -s reload # Reload config
|
||||||
|
systemctl restart nginx # Restart Nginx
|
||||||
|
|
||||||
|
# View Logs
|
||||||
|
tail -f /www/wwwlogs/api.yourdomain.com.log
|
||||||
|
tail -f /www/wwwlogs/api.yourdomain.com.error.log
|
||||||
|
|
||||||
|
# Application Logs (if configured)
|
||||||
|
tail -f /www/wwwroot/irt-bank-soal/logs/app.log
|
||||||
|
|
||||||
|
# Test Database Connection
|
||||||
|
# Local:
|
||||||
|
psql -U irt_user -d irt_bank_soal -h 127.0.0.1 -c "SELECT version();"
|
||||||
|
# Remote:
|
||||||
|
psql "postgresql://user:pass@host:port/db" -c "SELECT version();"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Checklist
|
||||||
|
|
||||||
|
- [ ] Changed AaPanel default port and password
|
||||||
|
- [ ] Database user has strong password
|
||||||
|
- [ ] SECRET_KEY is unique and 32+ characters
|
||||||
|
- [ ] SSL certificate installed and forced HTTPS
|
||||||
|
- [ ] CORS restricted to production domains only
|
||||||
|
- [ ] Firewall configured (only 80, 443, 22, 8888 open)
|
||||||
|
- [ ] Admin password is strong
|
||||||
|
- [ ] For local DB: PostgreSQL not exposed to internet
|
||||||
|
- [ ] For remote DB: IP whitelist configured (if supported)
|
||||||
|
- [ ] Regular backups configured
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backup Configuration
|
||||||
|
|
||||||
|
### Database Backup
|
||||||
|
|
||||||
|
**For Local Database:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create backup directory
|
||||||
|
mkdir -p /www/backup
|
||||||
|
|
||||||
|
# Manual backup
|
||||||
|
pg_dump -U irt_user -h 127.0.0.1 irt_bank_soal > /www/backup/irt_bank_soal_$(date +%Y%m%d).sql
|
||||||
|
|
||||||
|
# Automated backup (cron)
|
||||||
|
crontab -e
|
||||||
|
# Add: 0 2 * * * pg_dump -U irt_user -h 127.0.0.1 irt_bank_soal > /www/backup/irt_bank_soal_$(date +\%Y\%m\%d).sql
|
||||||
|
```
|
||||||
|
|
||||||
|
**For Remote Database:**
|
||||||
|
|
||||||
|
Most managed PostgreSQL providers have built-in backup features:
|
||||||
|
- **Supabase:** Dashboard > Database > Backups (daily automatic)
|
||||||
|
- **Neon:** Automatic point-in-time recovery
|
||||||
|
- **AWS RDS:** Automated backups with retention period
|
||||||
|
|
||||||
|
You can also backup manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Manual backup from remote (requires postgresql-client)
|
||||||
|
pg_dump "postgresql://username:password@host:port/database" > /www/backup/irt_bank_soal_$(date +%Y%m%d).sql
|
||||||
|
|
||||||
|
# Or with SSL for providers like Neon
|
||||||
|
pg_dump "postgresql://username:password@host:port/database?sslmode=require" > /www/backup/irt_bank_soal_$(date +%Y%m%d).sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### Project Backup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backup project files
|
||||||
|
tar -czvf /www/backup/irt_project_$(date +%Y%m%d).tar.gz /www/wwwroot/irt-bank-soal
|
||||||
|
|
||||||
|
# Exclude venv to save space
|
||||||
|
tar -czvf /www/backup/irt_project_$(date +%Y%m%d).tar.gz --exclude='venv' /www/wwwroot/irt-bank-soal
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Document End**
|
||||||
|
|
||||||
|
**Status:** Ready for Deployment
|
||||||
|
|
||||||
|
**Support:** Refer to TEST.md for testing procedures and PRD.md for requirements.
|
||||||
746
PRD.md
Normal file
746
PRD.md
Normal file
@@ -0,0 +1,746 @@
|
|||||||
|
# Product Requirements Document (PRD)
|
||||||
|
## IRT-Powered Adaptive Question Bank System
|
||||||
|
|
||||||
|
**Document Version:** 1.1
|
||||||
|
**Date:** March 21, 2026 (Updated)
|
||||||
|
**Product Name:** IRT Bank Soal (Adaptive Question Bank with AI Generation)
|
||||||
|
**Client:** Sejoli Tryout Multi-Website Platform
|
||||||
|
**Status:** Draft - Clarifications Incorporated
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
### v1.1 (March 21, 2026)
|
||||||
|
- Added **AI Generation**: 1 request = 1 question, no approval workflow
|
||||||
|
- Added **Admin Playground**: Admin can test AI generation without saving to DB
|
||||||
|
- Updated **Normalization Control**: Optional manual/automatic mode, system handles auto when sufficient data
|
||||||
|
- Updated **IRT → CTT Rollback**: Historical IRT scores preserved, CTT applied to new sessions only
|
||||||
|
- Removed **Admin Permissions/Role-based Access**: Not needed (each admin per site via WordPress)
|
||||||
|
- Updated **Custom Dashboards**: Use FastAPI Admin only (no custom dashboards)
|
||||||
|
- Added **AI Generation Toggle**: Global on/off switch for cost control
|
||||||
|
- Added **User-level Question Reuse**: Check if student already answered at difficulty level
|
||||||
|
- Updated **Student UX**: Admin sees internal metrics, students see only primary score
|
||||||
|
- Added **Data Retention**: Keep all data (no policy yet)
|
||||||
|
- Added **Reporting Section**: Student performance, Item analysis, Calibration status, Tryout comparison
|
||||||
|
- Updated **Admin Persona Note**: This project is backend tool for IRT/CTT calculation; WordPress handles static questions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Product Vision
|
||||||
|
|
||||||
|
### 1.1 Vision Statement
|
||||||
|
To provide an adaptive, intelligent question bank system that seamlessly integrates with Sejoli's existing Excel-based workflow while introducing modern Item Response Theory (IRT) capabilities and AI-powered question generation, enabling more accurate and efficient student assessment.
|
||||||
|
|
||||||
|
### 1.1.1 Primary Goals
|
||||||
|
- **100% Excel Compatibility**: Maintain exact formula compatibility with client's existing Excel workflow (CTT scoring with p, bobot, NM, NN)
|
||||||
|
- **Gradual Modernization**: Enable smooth transition from Classical Test Theory (CTT) to Item Response Theory (IRT)
|
||||||
|
- **Adaptive Assessment**: Provide Computerized Adaptive Testing (CAT) capabilities for more efficient and accurate measurement
|
||||||
|
- **AI-Enhanced Content**: Automatically generate question variants (Mudah/Sulit) from base Sedang questions
|
||||||
|
- **Multi-Site Support**: Single backend serving multiple WordPress-powered educational sites
|
||||||
|
- **Non-Destructive**: Zero disruption to existing operations - all enhancements are additive
|
||||||
|
|
||||||
|
### 1.1.2 Success Metrics
|
||||||
|
- **Technical**: CTT scores match client Excel 100%, IRT calibration >80% coverage
|
||||||
|
- **Educational**: 30% reduction in test length with IRT vs CTT, measurement precision (SE < 0.5 after 15 items)
|
||||||
|
- **Adoption**: >70% tryouts use hybrid mode within 3 months, >80% student satisfaction with adaptive mode
|
||||||
|
- **Efficiency**: 99.9% question reuse rate via AI-generated variants
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. User Personas
|
||||||
|
|
||||||
|
### 2.1 Administrators (School/Guru)
|
||||||
|
**Profile:** Non-technical education professionals managing tryouts
|
||||||
|
**Pain Points:**
|
||||||
|
- Excel-based scoring is manual and time-consuming
|
||||||
|
- Static questions require constant new content creation
|
||||||
|
- Difficulty normalization requires manual calculation
|
||||||
|
- Limited ability to compare student performance across groups
|
||||||
|
|
||||||
|
**Needs:**
|
||||||
|
- Simple, transparent scoring formulas (CTT mode)
|
||||||
|
- Easy Excel import/export workflow
|
||||||
|
- Clear visualizations of student performance
|
||||||
|
- Configurable normalization (static vs dynamic)
|
||||||
|
- Optional advanced features (IRT) without complexity
|
||||||
|
|
||||||
|
### 2.2 Students
|
||||||
|
**Profile:** Students taking tryouts for assessment
|
||||||
|
**Pain Points:**
|
||||||
|
- Fixed-length tests regardless of ability level
|
||||||
|
- Question difficulty may not match their skill
|
||||||
|
- Long testing sessions with low-value questions
|
||||||
|
|
||||||
|
**Needs:**
|
||||||
|
- Adaptive tests that match their ability level
|
||||||
|
- Shorter, more efficient assessment
|
||||||
|
- Clear feedback on strengths/weaknesses
|
||||||
|
- Consistent scoring across attempts
|
||||||
|
|
||||||
|
### 2.3 Content Creators
|
||||||
|
**Profile:** Staff creating and managing question banks
|
||||||
|
**Pain Points:**
|
||||||
|
- Creating 3 difficulty variants per question is time-consuming
|
||||||
|
- Limited question pool for repeated assessments
|
||||||
|
- Manual categorization of difficulty levels
|
||||||
|
|
||||||
|
**Needs:**
|
||||||
|
- AI-assisted question generation
|
||||||
|
- Easy difficulty level adjustment
|
||||||
|
- Reuse of base questions with variant generation
|
||||||
|
- Bulk question management tools
|
||||||
|
|
||||||
|
### 2.4 Technical Administrators
|
||||||
|
**Profile:** IT staff managing the platform
|
||||||
|
**Pain Points:**
|
||||||
|
- Multiple WordPress sites with separate databases
|
||||||
|
- Difficulty scaling question pools
|
||||||
|
- Maintenance of complex scoring systems
|
||||||
|
|
||||||
|
**Needs:**
|
||||||
|
- Centralized backend for multiple sites
|
||||||
|
- Scalable architecture (AA-panel VPS)
|
||||||
|
- REST API for WordPress integration
|
||||||
|
- Automated calibration and normalization
|
||||||
|
- **Note**: Each admin manages static questions within WordPress; this project provides the backend tool for IRT/CTT calculation and dynamic question selection
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Functional Requirements
|
||||||
|
|
||||||
|
### 3.1 CTT Scoring (Classical Test Theory)
|
||||||
|
**FR-1.1** System must calculate tingkat kesukaran (p) per question using exact client Excel formula:
|
||||||
|
```
|
||||||
|
p = Σ Benar / Total Peserta
|
||||||
|
```
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- p-value calculated per question for each tryout
|
||||||
|
- Values stored in database (items.ctt_p)
|
||||||
|
- Results match client Excel to 4 decimal places
|
||||||
|
|
||||||
|
**FR-1.2** System must calculate bobot (weight) per question:
|
||||||
|
```
|
||||||
|
Bobot = 1 - p
|
||||||
|
```
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Bobot calculated and stored (items.ctt_bobot)
|
||||||
|
- Easy questions (p > 0.70) have low bobot (< 0.30)
|
||||||
|
- Difficult questions (p < 0.30) have high bobot (> 0.70)
|
||||||
|
|
||||||
|
**FR-1.3** System must calculate Nilai Mentah (NM) per student:
|
||||||
|
```
|
||||||
|
NM = (Total_Bobot_Siswa / Total_Bobot_Max) × 1000
|
||||||
|
```
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- NM ranges 0-1000
|
||||||
|
- SUMPRODUCT equivalent implemented correctly
|
||||||
|
- Results stored per response (user_answers.ctt_nm)
|
||||||
|
|
||||||
|
**FR-1.4** System must calculate Nilai Nasional (NN) with normalization:
|
||||||
|
```
|
||||||
|
NN = 500 + 100 × ((NM - Rataan) / SB)
|
||||||
|
```
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- NN normalized to mean=500, SD=100
|
||||||
|
- Support static (hardcoded rataan/SB) and dynamic (real-time) modes
|
||||||
|
- NN clipped to 0-1000 range
|
||||||
|
|
||||||
|
**FR-1.5** System must categorize question difficulty per CTT standards:
|
||||||
|
- p < 0.30 → Sukar (Sulit)
|
||||||
|
- 0.30 ≤ p ≤ 0.70 → Sedang
|
||||||
|
- p > 0.70 → Mudah
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Category assigned (items.ctt_category)
|
||||||
|
- Used for level field (items.level)
|
||||||
|
|
||||||
|
### 3.2 IRT Scoring (Item Response Theory)
|
||||||
|
**FR-2.1** System must implement 1PL Rasch model:
|
||||||
|
```
|
||||||
|
P(θ) = 1 / (1 + e^-(θ - b))
|
||||||
|
```
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- θ (ability) estimated per student
|
||||||
|
- b (difficulty) calibrated per question
|
||||||
|
- Ranges: θ, b ∈ [-3, +3]
|
||||||
|
|
||||||
|
**FR-2.2** System must estimate θ using Maximum Likelihood Estimation (MLE)
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Initial guess θ = 0
|
||||||
|
- Optimization bounds [-3, +3]
|
||||||
|
- Standard error (SE) calculated using Fisher information
|
||||||
|
|
||||||
|
**FR-2.3** System must calibrate b parameters from response data
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Minimum 100-500 responses per item for calibration
|
||||||
|
- Calibration status tracked (items.calibrated)
|
||||||
|
- Auto-convert CTT p to initial b: `b ≈ -ln((1-p)/p)`
|
||||||
|
|
||||||
|
**FR-2.4** System must map θ to NN for CTT comparison
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- θ ∈ [-3, +3] mapped to NN ∈ [0, 1000]
|
||||||
|
- Formula: `NN = 500 + (θ / 3) × 500`
|
||||||
|
- Secondary score returned in API responses
|
||||||
|
|
||||||
|
### 3.3 Hybrid Mode
|
||||||
|
**FR-3.1** System must support dual scoring (CTT + IRT parallel)
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Both scores calculated per response
|
||||||
|
- Primary/secondary score returned
|
||||||
|
- Admin can choose which to display
|
||||||
|
|
||||||
|
**FR-3.2** System must support hybrid item selection
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- First N items: fixed order (CTT mode)
|
||||||
|
- Remaining items: adaptive (IRT mode)
|
||||||
|
- Configurable transition point (tryout_config.hybrid_transition_slot)
|
||||||
|
|
||||||
|
**FR-3.3** System must support hybrid normalization
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Static mode for small samples (< threshold)
|
||||||
|
- Dynamic mode for large samples (≥ threshold)
|
||||||
|
- Configurable threshold (tryout_config.min_sample_for_dynamic)
|
||||||
|
|
||||||
|
### 3.4 Dynamic Normalization
|
||||||
|
**FR-4.1** System must maintain running statistics per tryout
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Track: participant_count, total_nm_sum, total_nm_sq_sum
|
||||||
|
- Update on each completed session
|
||||||
|
- Stored in tryout_stats table
|
||||||
|
|
||||||
|
**FR-4.2** System must calculate real-time rataan and SB
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Rataan = mean(all NM)
|
||||||
|
- SB = sqrt(variance(all NM))
|
||||||
|
- Updated incrementally (no full recalc)
|
||||||
|
|
||||||
|
**FR-4.3** System must support optional normalization control (manual vs automatic)
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Admin can choose manual mode (static normalization with hardcoded values)
|
||||||
|
- Admin can choose automatic mode (dynamic normalization when sufficient data)
|
||||||
|
- When automatic selected and sufficient data reached: system handles normalization automatically
|
||||||
|
- Configurable threshold: min_sample_for_dynamic (default: 100)
|
||||||
|
- Admin can switch between manual/automatic at any time
|
||||||
|
- System displays current data readiness (participant count vs threshold)
|
||||||
|
|
||||||
|
### 3.5 AI Question Generation
|
||||||
|
**FR-5.1** System must generate question variants via OpenRouter API
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Generate Mudah variant from Sedang base
|
||||||
|
- Generate Sulit variant from Sedang base
|
||||||
|
- Generate same-level variant from Sedang base
|
||||||
|
- Use Qwen3 Coder 480B or Llama 3.3 70B
|
||||||
|
- **1 request = 1 question** (not batch generation)
|
||||||
|
|
||||||
|
**FR-5.2** System must use standardized prompt template
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Include context (tryout_id, slot, level)
|
||||||
|
- Include basis soal for reference (provides topic/context)
|
||||||
|
- Request 1 question with 4 options
|
||||||
|
- Include explanation
|
||||||
|
- Maintain same context, vary only difficulty level
|
||||||
|
|
||||||
|
**FR-5.3** System must implement question reuse/caching with user-level tracking
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Check DB for existing variant before generating
|
||||||
|
- Check if student user_id already answered question at specific difficulty level
|
||||||
|
- Reuse if found (same tryout_id, slot, level)
|
||||||
|
- Generate only if cache miss OR user hasn't answered at this difficulty
|
||||||
|
|
||||||
|
**FR-5.4** System must provide admin playground for AI testing
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Admin can request AI generation without saving to database
|
||||||
|
- Admin can re-request unlimited times until satisfied (no approval workflow)
|
||||||
|
- Preview mode shows generated question before saving
|
||||||
|
- Admin can edit content before saving
|
||||||
|
- Purpose: Build admin trust in AI quality before enabling for students
|
||||||
|
|
||||||
|
**FR-5.5** System must parse and store AI-generated questions
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Parse stem, options, correct answer, explanation
|
||||||
|
- Store in items table with generated_by='ai'
|
||||||
|
- Link to basis_item_id
|
||||||
|
- No approval workflow required for student tests
|
||||||
|
|
||||||
|
**FR-5.6** System must support AI generation toggle
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Global toggle to enable/disable AI generation (config.AI_generation_enabled)
|
||||||
|
- When disabled: reuse DB questions regardless of repetition
|
||||||
|
- When enabled: generate new variants if cache miss
|
||||||
|
- Admin can toggle on/off based on cost/budget
|
||||||
|
|
||||||
|
### 3.6 Item Selection
|
||||||
|
**FR-6.1** System must support fixed order selection (CTT mode)
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Items delivered in slot order (1, 2, 3, ...)
|
||||||
|
- No adaptive logic
|
||||||
|
- Used when selection_mode='fixed'
|
||||||
|
|
||||||
|
**FR-6.2** System must support adaptive selection (IRT mode)
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Select item where b ≈ current θ
|
||||||
|
- Prioritize calibrated items
|
||||||
|
- Use item information to maximize precision
|
||||||
|
|
||||||
|
**FR-6.3** System must support level-based selection (hybrid mode)
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Select from specified level (Mudah/Sedang/Sulit)
|
||||||
|
- Check if level variant exists in DB
|
||||||
|
- Generate via AI if not exists
|
||||||
|
|
||||||
|
### 3.7 Excel Import
|
||||||
|
**FR-7.1** System must import from client Excel format
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Parse answer key (Row 2, KUNCI)
|
||||||
|
- Extract calculated p-values (Row 4, data_only=True)
|
||||||
|
- Extract bobot values (Row 5)
|
||||||
|
- Import student responses (Row 6+)
|
||||||
|
|
||||||
|
**FR-7.2** System must create items from Excel import
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Create item per question slot
|
||||||
|
- Set ctt_p, ctt_bobot, ctt_category
|
||||||
|
- Auto-calculate irt_b from ctt_p
|
||||||
|
- Set calibrated=False
|
||||||
|
|
||||||
|
**FR-7.3** System must configure tryout from Excel import
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Create tryout_config with CTT settings
|
||||||
|
- Set normalization_mode='static' (default)
|
||||||
|
- Set static_rataan=500, static_sb=100
|
||||||
|
|
||||||
|
### 3.8 API Endpoints
|
||||||
|
**FR-8.1** System must provide Next Item endpoint
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- POST /api/v1/session/{session_id}/next_item
|
||||||
|
- Accept mode (ctt/irt/hybrid)
|
||||||
|
- Accept current_responses array
|
||||||
|
- Return item with selection_method metadata
|
||||||
|
|
||||||
|
**FR-8.2** System must provide Complete Session endpoint
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- POST /api/v1/session/{session_id}/complete
|
||||||
|
- Return primary_score (CTT or IRT)
|
||||||
|
- Return secondary_score (parallel calculation)
|
||||||
|
- Return comparison (NN difference, agreement)
|
||||||
|
|
||||||
|
**FR-8.3** System must provide Get Tryout Config endpoint
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- GET /api/v1/tryout/{tryout_id}/config
|
||||||
|
- Return scoring_mode, normalization_mode
|
||||||
|
- Return current_stats (participant_count, rataan, SB)
|
||||||
|
- Return calibration_status
|
||||||
|
|
||||||
|
**FR-8.4** System must provide Update Normalization endpoint
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- PUT /api/v1/tryout/{tryout_id}/normalization
|
||||||
|
- Accept normalization_mode update
|
||||||
|
- Accept static_rataan, static_sb overrides
|
||||||
|
- Return will_switch_to_dynamic_at threshold
|
||||||
|
|
||||||
|
### 3.9 Multi-Site Support
|
||||||
|
**FR-9.1** System must support multiple WordPress sites
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Each site has unique website_id
|
||||||
|
- Shared backend, isolated data per site
|
||||||
|
- API responses scoped to website_id
|
||||||
|
|
||||||
|
**FR-9.2** System must support per-site configuration
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Each (website_id, tryout_id) pair unique
|
||||||
|
- Independent tryout_config per tryout
|
||||||
|
- Independent tryout_stats per tryout
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Non-Functional Requirements
|
||||||
|
|
||||||
|
### 4.1 Performance
|
||||||
|
**NFR-4.1.1** Next Item API response time < 500ms
|
||||||
|
**NFR-4.1.2** Complete Session API response time < 2s
|
||||||
|
**NFR-4.1.3** AI question generation < 10s (OpenRouter timeout)
|
||||||
|
**NFR-4.1.4** Support 1000 concurrent students
|
||||||
|
|
||||||
|
### 4.2 Scalability
|
||||||
|
**NFR-4.2.1** Support 10,000+ items in database
|
||||||
|
**NFR-4.2.2** Support 100,000+ student responses
|
||||||
|
**NFR-4.2.3** Question reuse: 99.9% cache hit rate after initial generation
|
||||||
|
**NFR-4.2.4** Horizontal scaling via PostgreSQL read replicas
|
||||||
|
|
||||||
|
### 4.3 Reliability
|
||||||
|
**NFR-4.3.1** 99.9% uptime for tryout periods
|
||||||
|
**NFR-4.3.2** Automatic fallback to CTT if IRT fails
|
||||||
|
**NFR-4.3.3** Database transaction consistency
|
||||||
|
**NFR-4.3.4** Graceful degradation if AI API unavailable
|
||||||
|
|
||||||
|
### 4.4 Security
|
||||||
|
**NFR-4.4.1** API authentication via WordPress tokens
|
||||||
|
**NFR-4.4.2** Website_id isolation (no cross-site data access)
|
||||||
|
**NFR-4.4.3** Rate limiting per API key
|
||||||
|
**NFR-4.4.4** Audit trail for all scoring changes
|
||||||
|
|
||||||
|
### 4.5 Compatibility
|
||||||
|
**NFR-4.5.1** 100% formula match with client Excel
|
||||||
|
**NFR-4.5.2** Non-destructive: zero data loss during transitions
|
||||||
|
**NFR-4.5.3** Reversible: can disable IRT features anytime
|
||||||
|
**NFR-4.5.4** WordPress REST API integration
|
||||||
|
|
||||||
|
### 4.6 Maintainability
|
||||||
|
**NFR-4.6.1** FastAPI Admin auto-generated UI for CRUD
|
||||||
|
**NFR-4.6.2** Alembic migrations for schema changes
|
||||||
|
**NFR-4.6.3** Comprehensive API documentation (OpenAPI)
|
||||||
|
**NFR-4.6.4** Logging for debugging scoring calculations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Data Requirements
|
||||||
|
|
||||||
|
### 5.1 Core Entities
|
||||||
|
|
||||||
|
#### Items
|
||||||
|
- **id**: Primary key
|
||||||
|
- **website_id, tryout_id**: Composite key for multi-site
|
||||||
|
- **slot, level**: Position and difficulty
|
||||||
|
- **stem, options, correct, explanation**: Question content
|
||||||
|
- **ctt_p, ctt_bobot, ctt_category**: CTT parameters
|
||||||
|
- **irt_b, irt_a, irt_c**: IRT parameters
|
||||||
|
- **calibrated, calibration_sample_size**: Calibration status
|
||||||
|
- **generated_by, ai_model, basis_item_id**: AI generation metadata
|
||||||
|
|
||||||
|
#### User Answers
|
||||||
|
- **id**: Primary key
|
||||||
|
- **wp_user_id, website_id, tryout_id, slot, level**: Composite key
|
||||||
|
- **item_id, response**: Question and answer
|
||||||
|
- **ctt_bobot_earned, ctt_total_bobot_cumulative, ctt_nm, ctt_nn**: CTT scores
|
||||||
|
- **rataan_used, sb_used, normalization_mode_used**: Normalization metadata
|
||||||
|
- **irt_theta, irt_theta_se, irt_information**: IRT scores
|
||||||
|
- **scoring_mode_used**: Which mode was used
|
||||||
|
|
||||||
|
#### Tryout Config
|
||||||
|
- **id**: Primary key
|
||||||
|
- **website_id, tryout_id**: Composite key
|
||||||
|
- **scoring_mode**: 'ctt', 'irt', 'hybrid'
|
||||||
|
- **selection_mode**: 'fixed', 'adaptive', 'hybrid'
|
||||||
|
- **normalization_mode**: 'static', 'dynamic', 'hybrid'
|
||||||
|
- **static_rataan, static_sb, min_sample_for_dynamic**: Normalization settings
|
||||||
|
- **min_calibration_sample, theta_estimation_method**: IRT settings
|
||||||
|
- **hybrid_transition_slot, fallback_to_ctt_on_error**: Transition settings
|
||||||
|
|
||||||
|
#### Tryout Stats
|
||||||
|
- **id**: Primary key
|
||||||
|
- **website_id, tryout_id**: Composite key
|
||||||
|
- **participant_count**: Number of completed sessions
|
||||||
|
- **total_nm_sum, total_nm_sq_sum**: Running sums for mean/SD calc
|
||||||
|
- **current_rataan, current_sb**: Calculated values
|
||||||
|
- **min_nm, max_nm**: Score range
|
||||||
|
- **last_calculated_at, last_participant_id**: Metadata
|
||||||
|
|
||||||
|
### 5.2 Data Relationships
|
||||||
|
- Items → User Answers (1:N, CASCADE delete)
|
||||||
|
- Items → Items (self-reference via basis_item_id for AI generation)
|
||||||
|
- Tryout Config → User Answers (1:N via website_id, tryout_id)
|
||||||
|
- Tryout Stats → User Answers (1:N via website_id, tryout_id)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Technical Constraints
|
||||||
|
|
||||||
|
### 6.1 Tech Stack (Fixed)
|
||||||
|
- **Backend**: FastAPI (Python)
|
||||||
|
- **Database**: PostgreSQL (via aaPanel PgSQL Manager)
|
||||||
|
- **ORM**: SQLAlchemy
|
||||||
|
- **Admin**: FastAPI Admin
|
||||||
|
- **AI**: OpenRouter API (Qwen3 Coder 480B, Llama 3.3 70B)
|
||||||
|
- **Deployment**: aaPanel VPS (Python Manager)
|
||||||
|
|
||||||
|
### 6.2 External Dependencies
|
||||||
|
- **OpenRouter API**: Must handle rate limits, timeouts, errors
|
||||||
|
- **WordPress**: REST API integration, authentication
|
||||||
|
- **Excel**: openpyxl for import, pandas for data processing
|
||||||
|
|
||||||
|
### 6.3 Mathematical Constraints
|
||||||
|
- **CTT**: Must use EXACT client formulas (p, bobot, NM, NN)
|
||||||
|
- **IRT**: 1PL Rasch model only (no a, c parameters initially)
|
||||||
|
- **Normalization**: Mean=500, SD=100 target
|
||||||
|
- **Ranges**: θ, b ∈ [-3, +3], NM, NN ∈ [0, 1000]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. User Stories
|
||||||
|
|
||||||
|
### 7.1 Administrator Stories
|
||||||
|
**US-7.1.1** As an administrator, I want to import questions from Excel so that I can migrate existing content without manual entry.
|
||||||
|
- Priority: High
|
||||||
|
- Acceptance: FR-7.1, FR-7.2, FR-7.3
|
||||||
|
|
||||||
|
**US-7.1.2** As an administrator, I want to configure normalization mode (static/dynamic/hybrid) so that I can control how scores are normalized.
|
||||||
|
- Priority: High
|
||||||
|
- Acceptance: FR-4.3, FR-8.4
|
||||||
|
|
||||||
|
**US-7.1.3** As an administrator, I want to view calibration status so that I can know when IRT is ready for production.
|
||||||
|
- Priority: Medium
|
||||||
|
- Acceptance: FR-8.3
|
||||||
|
|
||||||
|
**US-7.1.4** As an administrator, I want to choose scoring mode (CTT/IRT/hybrid) so that I can gradually adopt advanced features.
|
||||||
|
- Priority: High
|
||||||
|
- Acceptance: FR-3.1, FR-3.2, FR-3.3
|
||||||
|
|
||||||
|
### 7.2 Student Stories
|
||||||
|
**US-7.2.1** As a student, I want to take adaptive tests so that I get questions matching my ability level.
|
||||||
|
- Priority: High
|
||||||
|
- Acceptance: FR-6.2, FR-2.1, FR-2.2
|
||||||
|
|
||||||
|
**US-7.2.2** As a student, I want to see my normalized score (NN) so that I can compare my performance with others.
|
||||||
|
- Priority: High
|
||||||
|
- Acceptance: FR-1.4, FR-4.2
|
||||||
|
|
||||||
|
**US-7.2.3** As a student, I want a seamless experience where any technical issues (IRT fallback, AI generation failures) are handled without interrupting my test.
|
||||||
|
- Priority: High
|
||||||
|
- Acceptance: Seamless fallback (student unaware of internal mode switching), no error messages visible to students
|
||||||
|
|
||||||
|
### 7.3 Content Creator Stories
|
||||||
|
**US-7.3.1** As a content creator, I want to generate question variants via AI so that I don't have to manually create 3 difficulty levels.
|
||||||
|
- Priority: High
|
||||||
|
- Acceptance: FR-5.1, FR-5.2, FR-5.3, FR-5.4
|
||||||
|
|
||||||
|
**US-7.3.2** As a content creator, I want to reuse existing questions with different difficulty levels so that I can maximize question pool efficiency.
|
||||||
|
- Priority: Medium
|
||||||
|
- Acceptance: FR-5.3, FR-6.3
|
||||||
|
|
||||||
|
### 7.4 Technical Administrator Stories
|
||||||
|
**US-7.4.1** As a technical administrator, I want to manage multiple WordPress sites from one backend so that I don't have to duplicate infrastructure.
|
||||||
|
- Priority: High
|
||||||
|
- Acceptance: FR-9.1, FR-9.2
|
||||||
|
|
||||||
|
**US-7.4.2** As a technical administrator, I want to monitor calibration progress so that I can plan IRT rollout.
|
||||||
|
- Priority: Medium
|
||||||
|
- Acceptance: FR-2.3, FR-8.3
|
||||||
|
|
||||||
|
**US-7.4.3** As a technical administrator, I want access to internal scoring details (CTT vs IRT comparison, normalization metrics) for debugging and monitoring, while students only see primary scores.
|
||||||
|
- Priority: Medium
|
||||||
|
- Acceptance: Admin visibility of all internal metrics, student visibility limited to final NN score only
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Success Criteria
|
||||||
|
|
||||||
|
### 8.1 Technical Validation
|
||||||
|
- ✅ CTT scores match client Excel to 4 decimal places (100% formula accuracy)
|
||||||
|
- ✅ Dynamic normalization produces mean=500±5, SD=100±5 after 100 users
|
||||||
|
- ✅ IRT calibration covers >80% items with 500+ responses per item
|
||||||
|
- ✅ CTT vs IRT NN difference <20 points (moderate agreement)
|
||||||
|
- ✅ Fallback rate <5% (IRT → CTT on error)
|
||||||
|
|
||||||
|
### 8.2 Educational Validation
|
||||||
|
- ✅ IRT measurement precision: SE <0.5 after 15 items
|
||||||
|
- ✅ Normalization quality: Distribution skewness <0.5
|
||||||
|
- ✅ Adaptive efficiency: 30% reduction in test length (15 IRT = 30 CTT items for same precision)
|
||||||
|
- ✅ Student satisfaction: >80% prefer adaptive mode in surveys
|
||||||
|
- ✅ Admin adoption: >70% tryouts use hybrid mode within 3 months
|
||||||
|
|
||||||
|
### 8.3 Business Validation
|
||||||
|
- ✅ Zero data loss during CTT→IRT transition
|
||||||
|
- ✅ Reversible: Can disable IRT and revert to CTT anytime
|
||||||
|
- ✅ Non-destructive: Existing Excel workflow remains functional
|
||||||
|
- ✅ Cost efficiency: 99.9% question reuse vs 90,000 unique questions for 1000 users
|
||||||
|
- ✅ Multi-site scalability: One backend supports unlimited WordPress sites
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Risk Mitigation
|
||||||
|
|
||||||
|
### 9.1 Technical Risks
|
||||||
|
| Risk | Impact | Probability | Mitigation |
|
||||||
|
|------|--------|-------------|------------|
|
||||||
|
| IRT calibration fails (insufficient data) | High | Medium | Fallback to CTT mode, enable hybrid transition |
|
||||||
|
| OpenRouter API down/unavailable | Medium | Low | Cache questions, serve static variants |
|
||||||
|
| Excel formula mismatch | High | Low | Unit tests with client Excel data |
|
||||||
|
| Database performance degradation | Medium | Low | Indexing, read replicas, query optimization |
|
||||||
|
|
||||||
|
### 9.2 Business Risks
|
||||||
|
| Risk | Impact | Probability | Mitigation |
|
||||||
|
|------|--------|-------------|------------|
|
||||||
|
| Administrators refuse to use IRT (too complex) | High | Medium | Hybrid mode with CTT-first UI |
|
||||||
|
| Students dislike adaptive tests | Medium | Low | A/B testing, optional mode |
|
||||||
|
| Excel workflow changes (client updates) | High | Low | Version control, flexible import parser |
|
||||||
|
| Multi-site data isolation failure | Critical | Low | Website_id validation, RBAC |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Migration Strategy
|
||||||
|
|
||||||
|
### 10.1 Phase 1: Import Existing Data (Week 1)
|
||||||
|
- Export current Sejoli Tryout data to Excel
|
||||||
|
- Run import script to load items and configurations
|
||||||
|
- Configure CTT mode with static normalization
|
||||||
|
- Validate: CTT scores match Excel 100%
|
||||||
|
|
||||||
|
### 10.2 Phase 2: Collect Calibration Data (Week 2-4)
|
||||||
|
- Students use tryout normally (CTT mode)
|
||||||
|
- Backend logs all responses
|
||||||
|
- Monitor calibration progress (items.calibrated status)
|
||||||
|
- Collect running statistics (tryout_stats)
|
||||||
|
|
||||||
|
### 10.3 Phase 3: Enable Dynamic Normalization (Week 5)
|
||||||
|
- Check participant count ≥ 100
|
||||||
|
- Update normalization_mode='hybrid'
|
||||||
|
- Test with 10-20 new students
|
||||||
|
- Verify: Normalized distribution has mean≈500, SD≈100
|
||||||
|
|
||||||
|
### 10.4 Phase 4: Enable IRT Adaptive (Week 6+)
|
||||||
|
- After 90% items calibrated + 1000+ responses
|
||||||
|
- Update scoring_mode='irt', selection_mode='adaptive'
|
||||||
|
- Enable AI generation for Mudah/Sulit variants
|
||||||
|
- Monitor fallback rate, measurement precision
|
||||||
|
|
||||||
|
### 10.5 Rollback Plan
|
||||||
|
- Any phase is reversible
|
||||||
|
- Revert to CTT mode if IRT issues occur
|
||||||
|
- **Score preservation**: Historical IRT scores kept as-is; CTT applied only to new sessions after rollback
|
||||||
|
- Disable AI generation if costs too high
|
||||||
|
- Revert to static normalization if dynamic unstable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Future Enhancements
|
||||||
|
|
||||||
|
### 11.1 Short-term (3-6 months)
|
||||||
|
- **2PL/3PL IRT**: Add discrimination (a) and guessing (c) parameters
|
||||||
|
- **Item Response Categorization**: Bloom's Taxonomy, cognitive domains
|
||||||
|
- **Advanced AI Models**: Fine-tune models for specific subjects
|
||||||
|
- **Data Retention Policy**: Define archival and anonymization strategy (currently: keep all data)
|
||||||
|
|
||||||
|
### 11.2 Long-term (6-12 months)
|
||||||
|
- **Multi-dimensional IRT**: Measure multiple skills per question
|
||||||
|
- **Automatic Item Difficulty Adjustment**: AI calibrates b parameters
|
||||||
|
- **Predictive Analytics**: Student performance forecasting
|
||||||
|
- **Integration with LMS**: Moodle, Canvas API support
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Glossary
|
||||||
|
|
||||||
|
| Term | Definition |
|
||||||
|
|------|------------|
|
||||||
|
| **p (TK)** | Proportion correct / Tingkat Kesukaran (CTT difficulty) |
|
||||||
|
| **Bobot** | 1-p weight (CTT scoring weight) |
|
||||||
|
| **NM** | Nilai Mentah (raw score 0-1000) |
|
||||||
|
| **NN** | Nilai Nasional (normalized 500±100) |
|
||||||
|
| **Rataan** | Mean of NM scores |
|
||||||
|
| **SB** | Simpangan Baku (standard deviation of NM) |
|
||||||
|
| **θ (theta)** | IRT ability (-3 to +3) |
|
||||||
|
| **b** | IRT difficulty (-3 to +3) |
|
||||||
|
| **SE** | Standard error (precision) |
|
||||||
|
| **CAT** | Computerized Adaptive Testing |
|
||||||
|
| **MLE** | Maximum Likelihood Estimation |
|
||||||
|
| **CTT** | Classical Test Theory |
|
||||||
|
| **IRT** | Item Response Theory |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Appendices
|
||||||
|
|
||||||
|
### 13.1 Formula Reference
|
||||||
|
- **CTT p**: `p = Σ Benar / Total Peserta`
|
||||||
|
- **CTT Bobot**: `Bobot = 1 - p`
|
||||||
|
- **CTT NM**: `NM = (Total_Bobot_Siswa / Total_Bobot_Max) × 1000`
|
||||||
|
- **CTT NN**: `NN = 500 + 100 × ((NM - Rataan) / SB)`
|
||||||
|
- **IRT 1PL**: `P(θ) = 1 / (1 + e^-(θ - b))`
|
||||||
|
- **CTT→IRT conversion**: `b ≈ -ln((1-p)/p)`
|
||||||
|
- **θ→NN mapping**: `NN = 500 + (θ / 3) × 500`
|
||||||
|
|
||||||
|
### 13.2 Difficulty Categories
|
||||||
|
| CTT p | CTT Category | Level | IRT b Range |
|
||||||
|
|-------|--------------|-------|-------------|
|
||||||
|
| p < 0.30 | Sukar | Sulit | b > 0.85 |
|
||||||
|
| 0.30 ≤ p ≤ 0.70 | Sedang | Sedang | -0.85 ≤ b ≤ 0.85 |
|
||||||
|
| p > 0.70 | Mudah | Mudah | b < -0.85 |
|
||||||
|
|
||||||
|
### 13.3 API Quick Reference
|
||||||
|
- `POST /api/v1/session/{session_id}/next_item` - Get next question
|
||||||
|
- `POST /api/v1/session/{session_id}/complete` - Submit and score
|
||||||
|
- `GET /api/v1/tryout/{tryout_id}/config` - Get configuration
|
||||||
|
- `PUT /api/v1/tryout/{tryout_id}/normalization` - Update normalization
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Reporting Requirements
|
||||||
|
|
||||||
|
### 14.1 Student Performance Reports
|
||||||
|
**FR-14.1.1** System must provide individual student performance reports
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Report all student sessions (CTT, IRT, hybrid)
|
||||||
|
- Include NM, NN scores per session
|
||||||
|
- Include time spent per question
|
||||||
|
- Include total_benar, total_bobot_earned
|
||||||
|
- Export to CSV/Excel
|
||||||
|
|
||||||
|
**FR-14.1.2** System must provide aggregate student performance reports
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Group by tryout, website_id, date range
|
||||||
|
- Show average NM, NN, theta per group
|
||||||
|
- Show distribution (min, max, median, std dev)
|
||||||
|
- Show pass/fail rates
|
||||||
|
- Export to CSV/Excel
|
||||||
|
|
||||||
|
### 14.2 Item Analysis Reports
|
||||||
|
**FR-14.2.1** System must provide item difficulty reports
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Show CTT p-value per item
|
||||||
|
- Show IRT b-parameter per item
|
||||||
|
- Show calibration status
|
||||||
|
- Show discrimination index (if available)
|
||||||
|
- Filter by difficulty category (Mudah/Sedang/Sulit)
|
||||||
|
|
||||||
|
**FR-14.2.2** System must provide item information function reports
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Show item information value at different theta levels
|
||||||
|
- Visualize item characteristic curves (optional)
|
||||||
|
- Show optimal theta range for each item
|
||||||
|
|
||||||
|
### 14.3 Calibration Status Reports
|
||||||
|
**FR-14.3.1** System must provide calibration progress reports
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Show total items per tryout
|
||||||
|
- Show calibrated items count and percentage
|
||||||
|
- Show items awaiting calibration
|
||||||
|
- Show average calibration sample size
|
||||||
|
- Show estimated time to reach calibration threshold
|
||||||
|
- Highlight ready-for-IRT rollout status (≥90% calibrated)
|
||||||
|
|
||||||
|
### 14.4 Tryout Comparison Reports
|
||||||
|
**FR-14.4.1** System must provide tryout comparison across dates
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Compare NM/NN distributions across different tryout dates
|
||||||
|
- Show trends over time (e.g., monthly averages)
|
||||||
|
- Show normalization changes impact (static → dynamic)
|
||||||
|
|
||||||
|
**FR-14.4.2** System must provide tryout comparison across subjects
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Compare performance across different subjects (Mat SD vs Bahasa SMA)
|
||||||
|
- Show subject-specific calibration status
|
||||||
|
- Show IRT accuracy differences per subject
|
||||||
|
|
||||||
|
### 14.5 Reporting Infrastructure
|
||||||
|
**FR-14.5.1** System must provide report scheduling
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Admin can schedule daily/weekly/monthly reports
|
||||||
|
- Reports emailed to admin on schedule
|
||||||
|
- Report templates configurable (e.g., calibration status every Monday)
|
||||||
|
|
||||||
|
**FR-14.5.2** System must provide report export formats
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Export to CSV
|
||||||
|
- Export to Excel (.xlsx)
|
||||||
|
- Export to PDF (with charts if available)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Document End**
|
||||||
|
|
||||||
|
**Document Version:** 1.1
|
||||||
|
**Created:** March 21, 2026
|
||||||
|
**Updated:** March 21, 2026 (Clarifications Incorporated)
|
||||||
|
**Author:** Product Team (based on Technical Specification v1.2.0)
|
||||||
|
**Status:** Draft - Ready for Implementation
|
||||||
|
**Status:** Draft for Review
|
||||||
147
alembic.ini
Normal file
147
alembic.ini
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# path to migration scripts.
|
||||||
|
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||||
|
# format, relative to the token %(here)s which refers to the location of this
|
||||||
|
# ini file
|
||||||
|
script_location = %(here)s/alembic
|
||||||
|
|
||||||
|
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||||
|
# Uncomment the line below if you want the files to be prepended with date and time
|
||||||
|
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||||
|
# for all available tokens
|
||||||
|
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# sys.path path, will be prepended to sys.path if present.
|
||||||
|
# defaults to the current working directory. for multiple paths, the path separator
|
||||||
|
# is defined by "path_separator" below.
|
||||||
|
prepend_sys_path = .
|
||||||
|
|
||||||
|
|
||||||
|
# timezone to use when rendering the date within the migration file
|
||||||
|
# as well as the filename.
|
||||||
|
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
|
||||||
|
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
||||||
|
# string value is passed to ZoneInfo()
|
||||||
|
# leave blank for localtime
|
||||||
|
# timezone =
|
||||||
|
|
||||||
|
# max length of characters to apply to the "slug" field
|
||||||
|
# truncate_slug_length = 40
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
# set to 'true' to allow .pyc and .pyo files without
|
||||||
|
# a source .py file to be detected as revisions in the
|
||||||
|
# versions/ directory
|
||||||
|
# sourceless = false
|
||||||
|
|
||||||
|
# version location specification; This defaults
|
||||||
|
# to <script_location>/versions. When using multiple version
|
||||||
|
# directories, initial revisions must be specified with --version-path.
|
||||||
|
# The path separator used here should be the separator specified by "path_separator"
|
||||||
|
# below.
|
||||||
|
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||||
|
|
||||||
|
# path_separator; This indicates what character is used to split lists of file
|
||||||
|
# paths, including version_locations and prepend_sys_path within configparser
|
||||||
|
# files such as alembic.ini.
|
||||||
|
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||||
|
# to provide os-dependent path splitting.
|
||||||
|
#
|
||||||
|
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||||
|
# take place if path_separator is not present in alembic.ini. If this
|
||||||
|
# option is omitted entirely, fallback logic is as follows:
|
||||||
|
#
|
||||||
|
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||||
|
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||||
|
# behavior of splitting on spaces and/or commas.
|
||||||
|
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||||
|
# behavior of splitting on spaces, commas, or colons.
|
||||||
|
#
|
||||||
|
# Valid values for path_separator are:
|
||||||
|
#
|
||||||
|
# path_separator = :
|
||||||
|
# path_separator = ;
|
||||||
|
# path_separator = space
|
||||||
|
# path_separator = newline
|
||||||
|
#
|
||||||
|
# Use os.pathsep. Default configuration used for new projects.
|
||||||
|
path_separator = os
|
||||||
|
|
||||||
|
# set to 'true' to search source files recursively
|
||||||
|
# in each "version_locations" directory
|
||||||
|
# new in Alembic version 1.10
|
||||||
|
# recursive_version_locations = false
|
||||||
|
|
||||||
|
# the output encoding used when revision files
|
||||||
|
# are written from script.py.mako
|
||||||
|
# output_encoding = utf-8
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
# post_write_hooks defines scripts or Python functions that are run
|
||||||
|
# on newly generated revision scripts. See the documentation for further
|
||||||
|
# detail and examples
|
||||||
|
|
||||||
|
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||||
|
# hooks = black
|
||||||
|
# black.type = console_scripts
|
||||||
|
# black.entrypoint = black
|
||||||
|
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = module
|
||||||
|
# ruff.module = ruff
|
||||||
|
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = exec
|
||||||
|
# ruff.executable = ruff
|
||||||
|
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Logging configuration. This is also consumed by the user-maintained
|
||||||
|
# env.py script only.
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARNING
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARNING
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
1
alembic/README
Normal file
1
alembic/README
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Generic single-database configuration.
|
||||||
99
alembic/env.py
Normal file
99
alembic/env.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"""
|
||||||
|
Alembic environment configuration for async PostgreSQL migrations.
|
||||||
|
|
||||||
|
Configures Alembic to work with SQLAlchemy async engine and models.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from sqlalchemy import pool
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
# Import models and Base
|
||||||
|
sys.path.insert(0, ".")
|
||||||
|
from app.database import Base
|
||||||
|
from app.models import * # noqa: F401, F403
|
||||||
|
|
||||||
|
# Import settings for database URL
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# Get settings and set database URL
|
||||||
|
settings = get_settings()
|
||||||
|
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||||
|
|
||||||
|
# add your model's MetaData object here
|
||||||
|
# for 'autogenerate' support
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
"""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def do_run_migrations(connection: Connection) -> None:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_async_migrations() -> None:
|
||||||
|
"""Run migrations in 'online' mode.
|
||||||
|
|
||||||
|
In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
"""
|
||||||
|
connectable = async_engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with connectable.connect() as connection:
|
||||||
|
await connection.run_sync(do_run_migrations)
|
||||||
|
|
||||||
|
await connectable.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations in 'online' mode."""
|
||||||
|
asyncio.run(run_async_migrations())
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
28
alembic/script.py.mako
Normal file
28
alembic/script.py.mako
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
7
app/__init__.py
Normal file
7
app/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
IRT Bank Soal - Adaptive Question Bank System
|
||||||
|
|
||||||
|
Main application package.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = "1.0.0"
|
||||||
625
app/admin.py
Normal file
625
app/admin.py
Normal file
@@ -0,0 +1,625 @@
|
|||||||
|
"""
|
||||||
|
FastAPI Admin configuration for IRT Bank Soal system.
|
||||||
|
|
||||||
|
Provides admin panel for managing tryouts, items, sessions, users, and tryout stats.
|
||||||
|
Includes custom actions for calibration, AI generation toggle, and normalization reset.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi_admin.app import app as admin_app
|
||||||
|
from fastapi_admin.resources import (
|
||||||
|
Field,
|
||||||
|
Link,
|
||||||
|
Model,
|
||||||
|
)
|
||||||
|
from fastapi_admin.widgets import displays, inputs
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import Item, Session, Tryout, TryoutStats, User
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Authentication Provider
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class AdminAuthProvider:
|
||||||
|
"""
|
||||||
|
Authentication provider for FastAPI Admin.
|
||||||
|
|
||||||
|
Supports two modes:
|
||||||
|
1. WordPress JWT token integration (production)
|
||||||
|
2. Basic auth for testing (development)
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def login(
|
||||||
|
self,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Authenticate user and return token.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
username: Username
|
||||||
|
password: Password
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Access token if authentication successful, None otherwise
|
||||||
|
"""
|
||||||
|
# Development mode: basic auth
|
||||||
|
if settings.ENVIRONMENT == "development":
|
||||||
|
# Allow admin/admin or admin/password for testing
|
||||||
|
if (username == "admin" and password in ["admin", "password"]):
|
||||||
|
return f"dev_token_{username}"
|
||||||
|
|
||||||
|
# Production mode: WordPress JWT token validation
|
||||||
|
# For now, return None - implement WordPress integration when needed
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def logout(self, request: Request) -> bool:
|
||||||
|
"""
|
||||||
|
Logout user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if logout successful
|
||||||
|
"""
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def get_current_user(self, request: Request) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
Get current authenticated user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User data if authenticated, None otherwise
|
||||||
|
"""
|
||||||
|
token = request.cookies.get("admin_token") or request.headers.get("Authorization")
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Development mode: validate dev token
|
||||||
|
if settings.ENVIRONMENT == "development" and token.startswith("dev_token_"):
|
||||||
|
username = token.replace("dev_token_", "")
|
||||||
|
return {
|
||||||
|
"id": 1,
|
||||||
|
"username": username,
|
||||||
|
"is_superuser": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Admin Model Resources
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TryoutResource(Model):
|
||||||
|
"""
|
||||||
|
Admin resource for Tryout model.
|
||||||
|
|
||||||
|
Displays tryout configuration and provides calibration and AI generation actions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
label = "Tryouts"
|
||||||
|
model = Tryout
|
||||||
|
page_size = 20
|
||||||
|
|
||||||
|
# Fields to display
|
||||||
|
fields = [
|
||||||
|
Field(name="id", label="ID", input_=inputs.Input(), display=displays.Display()),
|
||||||
|
Field(name="website_id", label="Website ID", input_=inputs.Input(), display=displays.Display()),
|
||||||
|
Field(name="tryout_id", label="Tryout ID", input_=inputs.Input(), display=displays.Display()),
|
||||||
|
Field(name="name", label="Name", input_=inputs.Input(), display=displays.Display()),
|
||||||
|
Field(
|
||||||
|
name="description",
|
||||||
|
label="Description",
|
||||||
|
input_=inputs.TextArea(),
|
||||||
|
display=displays.Display(),
|
||||||
|
),
|
||||||
|
Field(
|
||||||
|
name="scoring_mode",
|
||||||
|
label="Scoring Mode",
|
||||||
|
input_=inputs.Select(options=["ctt", "irt", "hybrid"], default="ctt"),
|
||||||
|
display=displays.Select(choices=["ctt", "irt", "hybrid"]),
|
||||||
|
),
|
||||||
|
Field(
|
||||||
|
name="selection_mode",
|
||||||
|
label="Selection Mode",
|
||||||
|
input_=inputs.Select(options=["fixed", "adaptive", "hybrid"], default="fixed"),
|
||||||
|
display=displays.Select(choices=["fixed", "adaptive", "hybrid"]),
|
||||||
|
),
|
||||||
|
Field(
|
||||||
|
name="normalization_mode",
|
||||||
|
label="Normalization Mode",
|
||||||
|
input_=inputs.Select(options=["static", "dynamic", "hybrid"], default="static"),
|
||||||
|
display=displays.Select(choices=["static", "dynamic", "hybrid"]),
|
||||||
|
),
|
||||||
|
Field(
|
||||||
|
name="min_sample_for_dynamic",
|
||||||
|
label="Min Sample for Dynamic",
|
||||||
|
input_=inputs.Input(type="number"),
|
||||||
|
display=displays.Display(),
|
||||||
|
),
|
||||||
|
Field(
|
||||||
|
name="static_rataan",
|
||||||
|
label="Static Mean (Rataan)",
|
||||||
|
input_=inputs.Input(type="number"),
|
||||||
|
display=displays.Display(),
|
||||||
|
),
|
||||||
|
Field(
|
||||||
|
name="static_sb",
|
||||||
|
label="Static Std Dev (SB)",
|
||||||
|
input_=inputs.Input(type="number"),
|
||||||
|
display=displays.Display(),
|
||||||
|
),
|
||||||
|
Field(
|
||||||
|
name="ai_generation_enabled",
|
||||||
|
label="Enable AI Generation",
|
||||||
|
input_=inputs.Switch(),
|
||||||
|
display=displays.Boolean(true_text="Enabled", false_text="Disabled"),
|
||||||
|
),
|
||||||
|
Field(
|
||||||
|
name="hybrid_transition_slot",
|
||||||
|
label="Hybrid Transition Slot",
|
||||||
|
input_=inputs.Input(type="number"),
|
||||||
|
display=displays.Display(),
|
||||||
|
),
|
||||||
|
Field(
|
||||||
|
name="min_calibration_sample",
|
||||||
|
label="Min Calibration Sample",
|
||||||
|
input_=inputs.Input(type="number"),
|
||||||
|
display=displays.Display(),
|
||||||
|
),
|
||||||
|
Field(
|
||||||
|
name="theta_estimation_method",
|
||||||
|
label="Theta Estimation Method",
|
||||||
|
input_=inputs.Select(options=["mle", "map", "eap"], default="mle"),
|
||||||
|
display=displays.Select(choices=["mle", "map", "eap"]),
|
||||||
|
),
|
||||||
|
Field(
|
||||||
|
name="fallback_to_ctt_on_error",
|
||||||
|
label="Fallback to CTT on Error",
|
||||||
|
input_=inputs.Switch(),
|
||||||
|
display=displays.Boolean(true_text="Yes", false_text="No"),
|
||||||
|
),
|
||||||
|
Field(name="created_at", label="Created At", input_=inputs.DateTime(), display=displays.DateTime()),
|
||||||
|
Field(name="updated_at", label="Updated At", input_=inputs.DateTime(), display=displays.DateTime()),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ItemResource(Model):
|
||||||
|
"""
|
||||||
|
Admin resource for Item model.
|
||||||
|
|
||||||
|
Displays items with CTT and IRT parameters, and calibration status.
|
||||||
|
"""
|
||||||
|
|
||||||
|
label = "Items"
|
||||||
|
model = Item
|
||||||
|
page_size = 50
|
||||||
|
|
||||||
|
# Fields to display
|
||||||
|
fields = [
|
||||||
|
Field(name="id", label="ID", input_=inputs.Input(), display=displays.Display()),
|
||||||
|
Field(name="tryout_id", label="Tryout ID", input_=inputs.Input(), display=displays.Display()),
|
||||||
|
Field(name="website_id", label="Website ID", input_=inputs.Input(), display=displays.Display()),
|
||||||
|
Field(name="slot", label="Slot", input_=inputs.Input(type="number"), display=displays.Display()),
|
||||||
|
Field(
|
||||||
|
name="level",
|
||||||
|
label="Difficulty Level",
|
||||||
|
input_=inputs.Select(options=["mudah", "sedang", "sulit"], default="sedang"),
|
||||||
|
display=displays.Display(),
|
||||||
|
),
|
||||||
|
Field(
|
||||||
|
name="stem",
|
||||||
|
label="Question Stem",
|
||||||
|
input_=inputs.TextArea(),
|
||||||
|
display=displays.Text(maxlen=100),
|
||||||
|
),
|
||||||
|
Field(name="options", label="Options", input_=inputs.Json(), display=displays.Json()),
|
||||||
|
Field(name="correct_answer", label="Correct Answer", input_=inputs.Input(), display=displays.Display()),
|
||||||
|
Field(
|
||||||
|
name="explanation",
|
||||||
|
label="Explanation",
|
||||||
|
input_=inputs.TextArea(),
|
||||||
|
display=displays.Text(maxlen=100),
|
||||||
|
),
|
||||||
|
Field(
|
||||||
|
name="ctt_p",
|
||||||
|
label="CTT p-value",
|
||||||
|
input_=inputs.Input(type="number"),
|
||||||
|
display=displays.Display(),
|
||||||
|
),
|
||||||
|
Field(
|
||||||
|
name="ctt_bobot",
|
||||||
|
label="CTT Bobot",
|
||||||
|
input_=inputs.Input(type="number"),
|
||||||
|
display=displays.Display(),
|
||||||
|
),
|
||||||
|
Field(
|
||||||
|
name="ctt_category",
|
||||||
|
label="CTT Category",
|
||||||
|
input_=inputs.Select(options=["mudah", "sedang", "sulit"]),
|
||||||
|
display=displays.Display(),
|
||||||
|
),
|
||||||
|
Field(
|
||||||
|
name="irt_b",
|
||||||
|
label="IRT b-parameter",
|
||||||
|
input_=inputs.Input(type="number"),
|
||||||
|
display=displays.Display(),
|
||||||
|
),
|
||||||
|
Field(
|
||||||
|
name="irt_se",
|
||||||
|
label="IRT SE",
|
||||||
|
input_=inputs.Input(type="number"),
|
||||||
|
display=displays.Display(),
|
||||||
|
),
|
||||||
|
Field(
|
||||||
|
name="calibrated",
|
||||||
|
label="Calibrated",
|
||||||
|
input_=inputs.Switch(),
|
||||||
|
display=displays.Boolean(true_text="Yes", false_text="No"),
|
||||||
|
),
|
||||||
|
Field(
|
||||||
|
name="calibration_sample_size",
|
||||||
|
label="Calibration Sample Size",
|
||||||
|
input_=inputs.Input(type="number"),
|
||||||
|
display=displays.Display(),
|
||||||
|
),
|
||||||
|
Field(
|
||||||
|
name="generated_by",
|
||||||
|
label="Generated By",
|
||||||
|
input_=inputs.Select(options=["manual", "ai"], default="manual"),
|
||||||
|
display=displays.Display(),
|
||||||
|
),
|
||||||
|
Field(name="ai_model", label="AI Model", input_=inputs.Input(), display=displays.Display()),
|
||||||
|
Field(
|
||||||
|
name="basis_item_id",
|
||||||
|
label="Basis Item ID",
|
||||||
|
input_=inputs.Input(type="number"),
|
||||||
|
display=displays.Display(),
|
||||||
|
),
|
||||||
|
Field(name="created_at", label="Created At", input_=inputs.DateTime(), display=displays.DateTime()),
|
||||||
|
Field(name="updated_at", label="Updated At", input_=inputs.DateTime(), display=displays.DateTime()),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class UserResource(Model):
|
||||||
|
"""
|
||||||
|
Admin resource for User model.
|
||||||
|
|
||||||
|
Displays WordPress users and their tryout sessions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
label = "Users"
|
||||||
|
model = User
|
||||||
|
page_size = 50
|
||||||
|
|
||||||
|
# Fields
|
||||||
|
fields = [
|
||||||
|
Field(name="id", label="ID", input_=inputs.Input(), display=displays.Display()),
|
||||||
|
Field(name="wp_user_id", label="WordPress User ID", input_=inputs.Input(), display=displays.Display()),
|
||||||
|
Field(name="website_id", label="Website ID", input_=inputs.Input(), display=displays.Display()),
|
||||||
|
Field(name="created_at", label="Created At", input_=inputs.DateTime(), display=displays.DateTime()),
|
||||||
|
Field(name="updated_at", label="Updated At", input_=inputs.DateTime(), display=displays.DateTime()),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class SessionResource(Model):
|
||||||
|
"""
|
||||||
|
Admin resource for Session model.
|
||||||
|
|
||||||
|
Displays tryout sessions with scoring results (NM, NN, theta).
|
||||||
|
"""
|
||||||
|
|
||||||
|
label = "Sessions"
|
||||||
|
model = Session
|
||||||
|
page_size = 50
|
||||||
|
|
||||||
|
# Fields
|
||||||
|
fields = [
|
||||||
|
Field(name="id", label="ID", input_=inputs.Input(), display=displays.Display()),
|
||||||
|
Field(name="session_id", label="Session ID", input_=inputs.Input(), display=displays.Display()),
|
||||||
|
Field(name="wp_user_id", label="WordPress User ID", input_=inputs.Input(), display=displays.Display()),
|
||||||
|
Field(name="website_id", label="Website ID", input_=inputs.Input(), display=displays.Display()),
|
||||||
|
Field(name="tryout_id", label="Tryout ID", input_=inputs.Input(), display=displays.Display()),
|
||||||
|
Field(name="start_time", label="Start Time", input_=inputs.DateTime(), display=displays.DateTime()),
|
||||||
|
Field(name="end_time", label="End Time", input_=inputs.DateTime(), display=displays.DateTime()),
|
||||||
|
Field(
|
||||||
|
name="is_completed",
|
||||||
|
label="Completed",
|
||||||
|
input_=inputs.Switch(),
|
||||||
|
display=displays.Boolean(true_text="Yes", false_text="No"),
|
||||||
|
),
|
||||||
|
Field(
|
||||||
|
name="scoring_mode_used",
|
||||||
|
label="Scoring Mode Used",
|
||||||
|
input_=inputs.Select(options=["ctt", "irt", "hybrid"]),
|
||||||
|
display=displays.Display(),
|
||||||
|
),
|
||||||
|
Field(name="total_benar", label="Total Benar", input_=inputs.Input(type="number"), display=displays.Display()),
|
||||||
|
Field(name="total_bobot_earned", label="Total Bobot Earned", input_=inputs.Input(type="number"), display=displays.Display()),
|
||||||
|
Field(name="NM", label="NM Score", input_=inputs.Input(type="number"), display=displays.Display()),
|
||||||
|
Field(name="NN", label="NN Score", input_=inputs.Input(type="number"), display=displays.Display()),
|
||||||
|
Field(name="theta", label="Theta", input_=inputs.Input(type="number"), display=displays.Display()),
|
||||||
|
Field(name="theta_se", label="Theta SE", input_=inputs.Input(type="number"), display=displays.Display()),
|
||||||
|
Field(name="rataan_used", label="Rataan Used", input_=inputs.Input(type="number"), display=displays.Display()),
|
||||||
|
Field(name="sb_used", label="SB Used", input_=inputs.Input(type="number"), display=displays.Display()),
|
||||||
|
Field(name="created_at", label="Created At", input_=inputs.DateTime(), display=displays.DateTime()),
|
||||||
|
Field(name="updated_at", label="Updated At", input_=inputs.DateTime(), display=displays.DateTime()),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TryoutStatsResource(Model):
|
||||||
|
"""
|
||||||
|
Admin resource for TryoutStats model.
|
||||||
|
|
||||||
|
Displays tryout-level statistics and provides normalization reset action.
|
||||||
|
"""
|
||||||
|
|
||||||
|
label = "Tryout Stats"
|
||||||
|
model = TryoutStats
|
||||||
|
page_size = 20
|
||||||
|
|
||||||
|
# Fields
|
||||||
|
fields = [
|
||||||
|
Field(name="id", label="ID", input_=inputs.Input(), display=displays.Display()),
|
||||||
|
Field(name="website_id", label="Website ID", input_=inputs.Input(), display=displays.Display()),
|
||||||
|
Field(name="tryout_id", label="Tryout ID", input_=inputs.Input(), display=displays.Display()),
|
||||||
|
Field(
|
||||||
|
name="participant_count",
|
||||||
|
label="Participant Count",
|
||||||
|
input_=inputs.Input(type="number"),
|
||||||
|
display=displays.Display(),
|
||||||
|
),
|
||||||
|
Field(
|
||||||
|
name="total_nm_sum",
|
||||||
|
label="Total NM Sum",
|
||||||
|
input_=inputs.Input(type="number"),
|
||||||
|
display=displays.Display(),
|
||||||
|
),
|
||||||
|
Field(
|
||||||
|
name="total_nm_sq_sum",
|
||||||
|
label="Total NM Squared Sum",
|
||||||
|
input_=inputs.Input(type="number"),
|
||||||
|
display=displays.Display(),
|
||||||
|
),
|
||||||
|
Field(name="rataan", label="Rataan", input_=inputs.Input(type="number"), display=displays.Display()),
|
||||||
|
Field(name="sb", label="SB", input_=inputs.Input(type="number"), display=displays.Display()),
|
||||||
|
Field(name="min_nm", label="Min NM", input_=inputs.Input(type="number"), display=displays.Display()),
|
||||||
|
Field(name="max_nm", label="Max NM", input_=inputs.Input(type="number"), display=displays.Display()),
|
||||||
|
Field(
|
||||||
|
name="last_calculated",
|
||||||
|
label="Last Calculated",
|
||||||
|
input_=inputs.DateTime(),
|
||||||
|
display=displays.DateTime(),
|
||||||
|
),
|
||||||
|
Field(name="created_at", label="Created At", input_=inputs.DateTime(), display=displays.DateTime()),
|
||||||
|
Field(name="updated_at", label="Updated At", input_=inputs.DateTime(), display=displays.DateTime()),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Custom Dashboard Views
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class CalibrationDashboardLink(Link):
|
||||||
|
"""
|
||||||
|
Link to calibration status dashboard.
|
||||||
|
|
||||||
|
Displays calibration percentage and items awaiting calibration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
label = "Calibration Status"
|
||||||
|
icon = "fas fa-chart-line"
|
||||||
|
url = "/admin/calibration_status"
|
||||||
|
|
||||||
|
async def get(self, request: Request) -> Dict[str, Any]:
|
||||||
|
"""Get calibration status for all tryouts."""
|
||||||
|
# Get all tryouts
|
||||||
|
db_gen = get_db()
|
||||||
|
db = await db_gen.__anext__()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await db.execute(
|
||||||
|
select(
|
||||||
|
Tryout.id,
|
||||||
|
Tryout.tryout_id,
|
||||||
|
Tryout.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tryouts = result.all()
|
||||||
|
|
||||||
|
calibration_data = []
|
||||||
|
for tryout_id, tryout_str, name in tryouts:
|
||||||
|
# Get calibration status
|
||||||
|
from app.services.irt_calibration import get_calibration_status
|
||||||
|
|
||||||
|
status = await get_calibration_status(tryout_str, 1, db)
|
||||||
|
calibration_data.append({
|
||||||
|
"tryout_id": tryout_str,
|
||||||
|
"name": name,
|
||||||
|
"total_items": status["total_items"],
|
||||||
|
"calibrated_items": status["calibrated_items"],
|
||||||
|
"calibration_percentage": status["calibration_percentage"],
|
||||||
|
"ready_for_irt": status["ready_for_irt"],
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"data": calibration_data,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
await db_gen.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
class ItemStatisticsLink(Link):
|
||||||
|
"""
|
||||||
|
Link to item statistics view.
|
||||||
|
|
||||||
|
Displays items grouped by difficulty level with calibration status.
|
||||||
|
"""
|
||||||
|
|
||||||
|
label = "Item Statistics"
|
||||||
|
icon = "fas fa-chart-bar"
|
||||||
|
url = "/admin/item_statistics"
|
||||||
|
|
||||||
|
async def get(self, request: Request) -> Dict[str, Any]:
|
||||||
|
"""Get item statistics grouped by difficulty level."""
|
||||||
|
db_gen = get_db()
|
||||||
|
db = await db_gen.__anext__()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get items grouped by level
|
||||||
|
result = await db.execute(
|
||||||
|
select(
|
||||||
|
Item.level,
|
||||||
|
)
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
levels = result.scalars().all()
|
||||||
|
|
||||||
|
stats = []
|
||||||
|
for level in levels:
|
||||||
|
# Get items for this level
|
||||||
|
item_result = await db.execute(
|
||||||
|
select(Item)
|
||||||
|
.where(Item.level == level)
|
||||||
|
.order_by(Item.slot)
|
||||||
|
.limit(10)
|
||||||
|
)
|
||||||
|
items = item_result.scalars().all()
|
||||||
|
|
||||||
|
# Calculate average correctness rate
|
||||||
|
total_responses = sum(item.calibration_sample_size for item in items)
|
||||||
|
calibrated_count = sum(1 for item in items if item.calibrated)
|
||||||
|
|
||||||
|
level_stats = {
|
||||||
|
"level": level,
|
||||||
|
"total_items": len(items),
|
||||||
|
"calibrated_items": calibrated_count,
|
||||||
|
"calibration_percentage": (calibrated_count / len(items) * 100) if len(items) > 0 else 0,
|
||||||
|
"total_responses": total_responses,
|
||||||
|
"avg_correctness": sum(item.ctt_p or 0 for item in items) / len(items) if len(items) > 0 else 0,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": item.id,
|
||||||
|
"slot": item.slot,
|
||||||
|
"calibrated": item.calibrated,
|
||||||
|
"ctt_p": item.ctt_p,
|
||||||
|
"irt_b": item.irt_b,
|
||||||
|
"calibration_sample_size": item.calibration_sample_size,
|
||||||
|
}
|
||||||
|
for item in items
|
||||||
|
],
|
||||||
|
}
|
||||||
|
stats.append(level_stats)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"data": stats,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
await db_gen.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
class SessionOverviewLink(Link):
|
||||||
|
"""
|
||||||
|
Link to session overview view.
|
||||||
|
|
||||||
|
Displays sessions with scores (NM, NN, theta) and completion status.
|
||||||
|
"""
|
||||||
|
|
||||||
|
label = "Session Overview"
|
||||||
|
icon = "fas fa-users"
|
||||||
|
url = "/admin/session_overview"
|
||||||
|
|
||||||
|
async def get(self, request: Request) -> Dict[str, Any]:
|
||||||
|
"""Get session overview with filters."""
|
||||||
|
db_gen = get_db()
|
||||||
|
db = await db_gen.__anext__()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get recent sessions
|
||||||
|
result = await db.execute(
|
||||||
|
select(Session)
|
||||||
|
.order_by(Session.created_at.desc())
|
||||||
|
.limit(50)
|
||||||
|
)
|
||||||
|
sessions = result.scalars().all()
|
||||||
|
|
||||||
|
session_data = [
|
||||||
|
{
|
||||||
|
"session_id": session.session_id,
|
||||||
|
"wp_user_id": session.wp_user_id,
|
||||||
|
"tryout_id": session.tryout_id,
|
||||||
|
"is_completed": session.is_completed,
|
||||||
|
"scoring_mode_used": session.scoring_mode_used,
|
||||||
|
"total_benar": session.total_benar,
|
||||||
|
"NM": session.NM,
|
||||||
|
"NN": session.NN,
|
||||||
|
"theta": session.theta,
|
||||||
|
"theta_se": session.theta_se,
|
||||||
|
"start_time": session.start_time.isoformat() if session.start_time else None,
|
||||||
|
"end_time": session.end_time.isoformat() if session.end_time else None,
|
||||||
|
}
|
||||||
|
for session in sessions
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"data": session_data,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
await db_gen.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Initialize FastAPI Admin
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def create_admin_app() -> Any:
|
||||||
|
"""
|
||||||
|
Create and configure FastAPI Admin application.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
FastAPI app with admin panel
|
||||||
|
"""
|
||||||
|
# Configure admin app
|
||||||
|
admin_app.settings.logo_url = "/static/logo.png"
|
||||||
|
admin_app.settings.site_title = "IRT Bank Soal Admin"
|
||||||
|
admin_app.settings.site_description = "Admin Panel for Adaptive Question Bank System"
|
||||||
|
|
||||||
|
# Register authentication provider
|
||||||
|
admin_app.settings.auth_provider = AdminAuthProvider()
|
||||||
|
|
||||||
|
# Register model resources
|
||||||
|
admin_app.register(TryoutResource)
|
||||||
|
admin_app.register(ItemResource)
|
||||||
|
admin_app.register(UserResource)
|
||||||
|
admin_app.register(SessionResource)
|
||||||
|
admin_app.register(TryoutStatsResource)
|
||||||
|
|
||||||
|
# Register dashboard links
|
||||||
|
admin_app.register(CalibrationDashboardLink)
|
||||||
|
admin_app.register(ItemStatisticsLink)
|
||||||
|
admin_app.register(SessionOverviewLink)
|
||||||
|
|
||||||
|
return admin_app
|
||||||
|
|
||||||
|
|
||||||
|
# Export admin app for mounting in main.py
|
||||||
|
admin = create_admin_app()
|
||||||
5
app/api/__init__.py
Normal file
5
app/api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
API module for IRT Bank Soal.
|
||||||
|
|
||||||
|
Contains FastAPI routers and endpoint definitions.
|
||||||
|
"""
|
||||||
25
app/api/v1/__init__.py
Normal file
25
app/api/v1/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""
|
||||||
|
API v1 Router configuration.
|
||||||
|
|
||||||
|
Defines all API v1 endpoints and their prefixes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.v1 import session
|
||||||
|
|
||||||
|
api_router = APIRouter()
|
||||||
|
|
||||||
|
# Include session endpoints
|
||||||
|
api_router.include_router(
|
||||||
|
session.router,
|
||||||
|
prefix="/session",
|
||||||
|
tags=["session"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Include admin endpoints
|
||||||
|
api_router.include_router(
|
||||||
|
session.admin_router,
|
||||||
|
prefix="/admin",
|
||||||
|
tags=["admin"]
|
||||||
|
)
|
||||||
388
app/api/v1/session.py
Normal file
388
app/api/v1/session.py
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
"""
|
||||||
|
Session API endpoints for CAT item selection.
|
||||||
|
|
||||||
|
Provides endpoints for:
|
||||||
|
- GET /api/v1/session/{session_id}/next_item - Get next question
|
||||||
|
- POST /api/v1/admin/cat/test - Admin playground for testing CAT
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import Item, Session, Tryout
|
||||||
|
from app.services.cat_selection import (
|
||||||
|
CATSelectionError,
|
||||||
|
get_next_item,
|
||||||
|
should_terminate,
|
||||||
|
simulate_cat_selection,
|
||||||
|
update_theta,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Default SE threshold for termination
|
||||||
|
DEFAULT_SE_THRESHOLD = 0.5
|
||||||
|
|
||||||
|
# Session router for student-facing endpoints
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Admin router for admin-only endpoints
|
||||||
|
admin_router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ============== Request/Response Models ==============
|
||||||
|
|
||||||
|
class NextItemResponse(BaseModel):
|
||||||
|
"""Response for next item endpoint."""
|
||||||
|
status: Literal["item", "completed"] = "item"
|
||||||
|
item_id: Optional[int] = None
|
||||||
|
stem: Optional[str] = None
|
||||||
|
options: Optional[dict] = None
|
||||||
|
slot: Optional[int] = None
|
||||||
|
level: Optional[str] = None
|
||||||
|
selection_method: Optional[str] = None
|
||||||
|
reason: Optional[str] = None
|
||||||
|
current_theta: Optional[float] = None
|
||||||
|
current_se: Optional[float] = None
|
||||||
|
items_answered: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SubmitAnswerRequest(BaseModel):
|
||||||
|
"""Request for submitting an answer."""
|
||||||
|
item_id: int = Field(..., description="Item ID being answered")
|
||||||
|
response: str = Field(..., description="User's answer (A, B, C, D)")
|
||||||
|
time_spent: int = Field(default=0, ge=0, description="Time spent on question (seconds)")
|
||||||
|
|
||||||
|
|
||||||
|
class SubmitAnswerResponse(BaseModel):
|
||||||
|
"""Response for submitting an answer."""
|
||||||
|
is_correct: bool
|
||||||
|
correct_answer: str
|
||||||
|
explanation: Optional[str] = None
|
||||||
|
theta: Optional[float] = None
|
||||||
|
theta_se: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CATTestRequest(BaseModel):
|
||||||
|
"""Request for admin CAT test endpoint."""
|
||||||
|
tryout_id: str = Field(..., description="Tryout identifier")
|
||||||
|
website_id: int = Field(..., description="Website identifier")
|
||||||
|
initial_theta: float = Field(default=0.0, ge=-3.0, le=3.0, description="Initial theta value")
|
||||||
|
selection_mode: Literal["fixed", "adaptive", "hybrid"] = Field(
|
||||||
|
default="adaptive", description="Selection mode"
|
||||||
|
)
|
||||||
|
max_items: int = Field(default=15, ge=1, le=100, description="Maximum items to simulate")
|
||||||
|
se_threshold: float = Field(
|
||||||
|
default=0.5, ge=0.1, le=3.0, description="SE threshold for termination"
|
||||||
|
)
|
||||||
|
hybrid_transition_slot: int = Field(
|
||||||
|
default=10, ge=1, description="Slot to transition in hybrid mode"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CATTestResponse(BaseModel):
|
||||||
|
"""Response for admin CAT test endpoint."""
|
||||||
|
tryout_id: str
|
||||||
|
website_id: int
|
||||||
|
initial_theta: float
|
||||||
|
selection_mode: str
|
||||||
|
total_items: int
|
||||||
|
final_theta: float
|
||||||
|
final_se: float
|
||||||
|
se_threshold_met: bool
|
||||||
|
items: list
|
||||||
|
|
||||||
|
|
||||||
|
# ============== Session Endpoints ==============
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{session_id}/next_item",
|
||||||
|
response_model=NextItemResponse,
|
||||||
|
summary="Get next item for session",
|
||||||
|
description="Returns the next question for a session based on the tryout's selection mode."
|
||||||
|
)
|
||||||
|
async def get_next_item_endpoint(
|
||||||
|
session_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
) -> NextItemResponse:
|
||||||
|
"""
|
||||||
|
Get the next item for a session.
|
||||||
|
|
||||||
|
Validates session exists and is not completed.
|
||||||
|
Gets Tryout config (scoring_mode, selection_mode, max_items).
|
||||||
|
Calls appropriate selection function based on selection_mode.
|
||||||
|
Returns item or completion status.
|
||||||
|
"""
|
||||||
|
# Get session
|
||||||
|
session_query = select(Session).where(Session.session_id == session_id)
|
||||||
|
session_result = await db.execute(session_query)
|
||||||
|
session = session_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Session {session_id} not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if session.is_completed:
|
||||||
|
return NextItemResponse(
|
||||||
|
status="completed",
|
||||||
|
reason="Session already completed"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get tryout config
|
||||||
|
tryout_query = select(Tryout).where(
|
||||||
|
Tryout.tryout_id == session.tryout_id,
|
||||||
|
Tryout.website_id == session.website_id
|
||||||
|
)
|
||||||
|
tryout_result = await db.execute(tryout_query)
|
||||||
|
tryout = tryout_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not tryout:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Tryout {session.tryout_id} not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check termination conditions
|
||||||
|
termination = await should_terminate(
|
||||||
|
db,
|
||||||
|
session_id,
|
||||||
|
max_items=None, # Will be set from tryout config if needed
|
||||||
|
se_threshold=DEFAULT_SE_THRESHOLD
|
||||||
|
)
|
||||||
|
|
||||||
|
if termination.should_terminate:
|
||||||
|
return NextItemResponse(
|
||||||
|
status="completed",
|
||||||
|
reason=termination.reason,
|
||||||
|
current_theta=session.theta,
|
||||||
|
current_se=session.theta_se,
|
||||||
|
items_answered=termination.items_answered
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get next item based on selection mode
|
||||||
|
try:
|
||||||
|
result = await get_next_item(
|
||||||
|
db,
|
||||||
|
session_id,
|
||||||
|
selection_mode=tryout.selection_mode,
|
||||||
|
hybrid_transition_slot=tryout.hybrid_transition_slot or 10,
|
||||||
|
ai_generation_enabled=tryout.ai_generation_enabled
|
||||||
|
)
|
||||||
|
except CATSelectionError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.item is None:
|
||||||
|
return NextItemResponse(
|
||||||
|
status="completed",
|
||||||
|
reason=result.reason,
|
||||||
|
current_theta=session.theta,
|
||||||
|
current_se=session.theta_se,
|
||||||
|
items_answered=termination.items_answered
|
||||||
|
)
|
||||||
|
|
||||||
|
item = result.item
|
||||||
|
|
||||||
|
return NextItemResponse(
|
||||||
|
status="item",
|
||||||
|
item_id=item.id,
|
||||||
|
stem=item.stem,
|
||||||
|
options=item.options,
|
||||||
|
slot=item.slot,
|
||||||
|
level=item.level,
|
||||||
|
selection_method=result.selection_method,
|
||||||
|
reason=result.reason,
|
||||||
|
current_theta=session.theta,
|
||||||
|
current_se=session.theta_se,
|
||||||
|
items_answered=termination.items_answered
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{session_id}/submit_answer",
|
||||||
|
response_model=SubmitAnswerResponse,
|
||||||
|
summary="Submit answer for item",
|
||||||
|
description="Submit an answer for an item and update theta estimate."
|
||||||
|
)
|
||||||
|
async def submit_answer_endpoint(
|
||||||
|
session_id: str,
|
||||||
|
request: SubmitAnswerRequest,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
) -> SubmitAnswerResponse:
|
||||||
|
"""
|
||||||
|
Submit an answer for an item.
|
||||||
|
|
||||||
|
Validates session and item.
|
||||||
|
Checks correctness.
|
||||||
|
Updates theta estimate.
|
||||||
|
Records response time.
|
||||||
|
"""
|
||||||
|
# Get session
|
||||||
|
session_query = select(Session).where(Session.session_id == session_id)
|
||||||
|
session_result = await db.execute(session_query)
|
||||||
|
session = session_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Session {session_id} not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if session.is_completed:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Session already completed"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get item
|
||||||
|
item_query = select(Item).where(Item.id == request.item_id)
|
||||||
|
item_result = await db.execute(item_query)
|
||||||
|
item = item_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Item {request.item_id} not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check correctness
|
||||||
|
is_correct = request.response.upper() == item.correct_answer.upper()
|
||||||
|
|
||||||
|
# Update theta
|
||||||
|
theta, theta_se = await update_theta(db, session_id, request.item_id, is_correct)
|
||||||
|
|
||||||
|
# Create user answer record
|
||||||
|
from app.models import UserAnswer
|
||||||
|
|
||||||
|
user_answer = UserAnswer(
|
||||||
|
session_id=session_id,
|
||||||
|
wp_user_id=session.wp_user_id,
|
||||||
|
website_id=session.website_id,
|
||||||
|
tryout_id=session.tryout_id,
|
||||||
|
item_id=request.item_id,
|
||||||
|
response=request.response.upper(),
|
||||||
|
is_correct=is_correct,
|
||||||
|
time_spent=request.time_spent,
|
||||||
|
scoring_mode_used=session.scoring_mode_used,
|
||||||
|
bobot_earned=item.ctt_bobot if is_correct and item.ctt_bobot else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(user_answer)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return SubmitAnswerResponse(
|
||||||
|
is_correct=is_correct,
|
||||||
|
correct_answer=item.correct_answer,
|
||||||
|
explanation=item.explanation,
|
||||||
|
theta=theta,
|
||||||
|
theta_se=theta_se
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============== Admin Endpoints ==============
|
||||||
|
|
||||||
|
@admin_router.post(
|
||||||
|
"/cat/test",
|
||||||
|
response_model=CATTestResponse,
|
||||||
|
summary="Test CAT selection algorithm",
|
||||||
|
description="Admin playground for testing adaptive selection behavior."
|
||||||
|
)
|
||||||
|
async def test_cat_endpoint(
|
||||||
|
request: CATTestRequest,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
) -> CATTestResponse:
|
||||||
|
"""
|
||||||
|
Test CAT selection algorithm.
|
||||||
|
|
||||||
|
Simulates CAT selection for a tryout and returns
|
||||||
|
the sequence of selected items with theta progression.
|
||||||
|
"""
|
||||||
|
# Verify tryout exists
|
||||||
|
tryout_query = select(Tryout).where(
|
||||||
|
Tryout.tryout_id == request.tryout_id,
|
||||||
|
Tryout.website_id == request.website_id
|
||||||
|
)
|
||||||
|
tryout_result = await db.execute(tryout_query)
|
||||||
|
tryout = tryout_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not tryout:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Tryout {request.tryout_id} not found for website {request.website_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run simulation
|
||||||
|
result = await simulate_cat_selection(
|
||||||
|
db,
|
||||||
|
tryout_id=request.tryout_id,
|
||||||
|
website_id=request.website_id,
|
||||||
|
initial_theta=request.initial_theta,
|
||||||
|
selection_mode=request.selection_mode,
|
||||||
|
max_items=request.max_items,
|
||||||
|
se_threshold=request.se_threshold,
|
||||||
|
hybrid_transition_slot=request.hybrid_transition_slot
|
||||||
|
)
|
||||||
|
|
||||||
|
if "error" in result:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=result["error"]
|
||||||
|
)
|
||||||
|
|
||||||
|
return CATTestResponse(**result)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.get(
|
||||||
|
"/session/{session_id}/status",
|
||||||
|
summary="Get session status",
|
||||||
|
description="Get detailed session status including theta and SE."
|
||||||
|
)
|
||||||
|
async def get_session_status_endpoint(
|
||||||
|
session_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Get session status for admin monitoring.
|
||||||
|
"""
|
||||||
|
# Get session
|
||||||
|
session_query = select(Session).where(Session.session_id == session_id)
|
||||||
|
session_result = await db.execute(session_query)
|
||||||
|
session = session_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Session {session_id} not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Count answers
|
||||||
|
from sqlalchemy import func
|
||||||
|
from app.models import UserAnswer
|
||||||
|
|
||||||
|
count_query = select(func.count(UserAnswer.id)).where(
|
||||||
|
UserAnswer.session_id == session_id
|
||||||
|
)
|
||||||
|
count_result = await db.execute(count_query)
|
||||||
|
items_answered = count_result.scalar() or 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"session_id": session.session_id,
|
||||||
|
"wp_user_id": session.wp_user_id,
|
||||||
|
"tryout_id": session.tryout_id,
|
||||||
|
"is_completed": session.is_completed,
|
||||||
|
"theta": session.theta,
|
||||||
|
"theta_se": session.theta_se,
|
||||||
|
"items_answered": items_answered,
|
||||||
|
"scoring_mode_used": session.scoring_mode_used,
|
||||||
|
"NM": session.NM,
|
||||||
|
"NN": session.NN,
|
||||||
|
"start_time": session.start_time.isoformat() if session.start_time else None,
|
||||||
|
"end_time": session.end_time.isoformat() if session.end_time else None
|
||||||
|
}
|
||||||
3
app/core/__init__.py
Normal file
3
app/core/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
Core configuration and database utilities.
|
||||||
|
"""
|
||||||
115
app/core/config.py
Normal file
115
app/core/config.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""
|
||||||
|
Application configuration using Pydantic Settings.
|
||||||
|
|
||||||
|
Loads configuration from environment variables with validation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Literal, List, Union
|
||||||
|
|
||||||
|
from pydantic import Field, field_validator
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Application settings loaded from environment variables."""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
case_sensitive=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL: str = Field(
|
||||||
|
default="postgresql+asyncpg://postgres:postgres@localhost:5432/irt_bank_soal",
|
||||||
|
description="PostgreSQL database URL with asyncpg driver",
|
||||||
|
)
|
||||||
|
|
||||||
|
# FastAPI
|
||||||
|
SECRET_KEY: str = Field(
|
||||||
|
default="dev-secret-key-change-in-production",
|
||||||
|
description="Secret key for JWT token signing",
|
||||||
|
)
|
||||||
|
API_V1_STR: str = Field(default="/api/v1", description="API v1 prefix")
|
||||||
|
PROJECT_NAME: str = Field(default="IRT Bank Soal", description="Project name")
|
||||||
|
ENVIRONMENT: Literal["development", "staging", "production"] = Field(
|
||||||
|
default="development", description="Environment name"
|
||||||
|
)
|
||||||
|
|
||||||
|
# OpenRouter (AI Generation)
|
||||||
|
OPENROUTER_API_KEY: str = Field(
|
||||||
|
default="", description="OpenRouter API key for AI generation"
|
||||||
|
)
|
||||||
|
OPENROUTER_MODEL_QWEN: str = Field(
|
||||||
|
default="qwen/qwen-2.5-coder-32b-instruct",
|
||||||
|
description="Qwen model identifier",
|
||||||
|
)
|
||||||
|
OPENROUTER_MODEL_LLAMA: str = Field(
|
||||||
|
default="meta-llama/llama-3.3-70b-instruct",
|
||||||
|
description="Llama model identifier",
|
||||||
|
)
|
||||||
|
OPENROUTER_TIMEOUT: int = Field(default=30, description="OpenRouter API timeout in seconds")
|
||||||
|
|
||||||
|
# WordPress Integration
|
||||||
|
WORDPRESS_API_URL: str = Field(
|
||||||
|
default="", description="WordPress REST API base URL"
|
||||||
|
)
|
||||||
|
WORDPRESS_AUTH_TOKEN: str = Field(
|
||||||
|
default="", description="WordPress JWT authentication token"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Redis (Celery)
|
||||||
|
REDIS_URL: str = Field(
|
||||||
|
default="redis://localhost:6379/0", description="Redis connection URL"
|
||||||
|
)
|
||||||
|
CELERY_BROKER_URL: str = Field(
|
||||||
|
default="redis://localhost:6379/0", description="Celery broker URL"
|
||||||
|
)
|
||||||
|
CELERY_RESULT_BACKEND: str = Field(
|
||||||
|
default="redis://localhost:6379/0", description="Celery result backend URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS - stored as list, accepts comma-separated string from env
|
||||||
|
ALLOWED_ORIGINS: List[str] = Field(
|
||||||
|
default=["http://localhost:3000"],
|
||||||
|
description="List of allowed CORS origins",
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("ALLOWED_ORIGINS", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def parse_allowed_origins(cls, v: Union[str, List[str]]) -> List[str]:
|
||||||
|
"""Parse comma-separated origins into list."""
|
||||||
|
if isinstance(v, str):
|
||||||
|
return [origin.strip() for origin in v.split(",") if origin.strip()]
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
# Global settings instance
|
||||||
|
_settings: Union[Settings, None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
"""
|
||||||
|
Get application settings instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Settings: Application settings
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If settings not initialized
|
||||||
|
"""
|
||||||
|
global _settings
|
||||||
|
if _settings is None:
|
||||||
|
_settings = Settings()
|
||||||
|
return _settings
|
||||||
|
|
||||||
|
|
||||||
|
def init_settings(settings: Settings) -> None:
|
||||||
|
"""
|
||||||
|
Initialize settings with custom instance (useful for testing).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
settings: Settings instance to use
|
||||||
|
"""
|
||||||
|
global _settings
|
||||||
|
_settings = settings
|
||||||
85
app/database.py
Normal file
85
app/database.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"""
|
||||||
|
Database configuration and session management for async PostgreSQL.
|
||||||
|
|
||||||
|
Uses SQLAlchemy 2.0 async ORM with asyncpg driver.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import (
|
||||||
|
AsyncSession,
|
||||||
|
async_sessionmaker,
|
||||||
|
create_async_engine,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# Create async engine with connection pooling
|
||||||
|
engine = create_async_engine(
|
||||||
|
settings.DATABASE_URL,
|
||||||
|
echo=settings.ENVIRONMENT == "development", # Log SQL in development
|
||||||
|
pool_pre_ping=True, # Verify connections before using
|
||||||
|
pool_size=10, # Number of connections to maintain
|
||||||
|
max_overflow=20, # Max additional connections beyond pool_size
|
||||||
|
pool_recycle=3600, # Recycle connections after 1 hour
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create async session factory
|
||||||
|
AsyncSessionLocal = async_sessionmaker(
|
||||||
|
engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False, # Prevent attributes from being expired after commit
|
||||||
|
autocommit=False,
|
||||||
|
autoflush=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
"""Base class for all database models."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""
|
||||||
|
Dependency for getting async database session.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
AsyncSession: Database session
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
@app.get("/items/")
|
||||||
|
async def get_items(db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(select(Item))
|
||||||
|
return result.scalars().all()
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def init_db() -> None:
|
||||||
|
"""
|
||||||
|
Initialize database - create all tables.
|
||||||
|
|
||||||
|
Note: In production, use Alembic migrations instead.
|
||||||
|
This is useful for development and testing.
|
||||||
|
"""
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
|
||||||
|
async def close_db() -> None:
|
||||||
|
"""Close database connections."""
|
||||||
|
await engine.dispose()
|
||||||
204
app/main.py
Normal file
204
app/main.py
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
"""
|
||||||
|
IRT Bank Soal - Adaptive Question Bank System
|
||||||
|
|
||||||
|
Main FastAPI application entry point.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- CTT (Classical Test Theory) scoring with exact Excel formulas
|
||||||
|
- IRT (Item Response Theory) support for adaptive testing
|
||||||
|
- Multi-website support for WordPress integration
|
||||||
|
- AI-powered question generation
|
||||||
|
"""
|
||||||
|
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.admin import admin as admin_app
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.database import close_db, init_db
|
||||||
|
from app.routers import (
|
||||||
|
admin_router,
|
||||||
|
ai_router,
|
||||||
|
import_export_router,
|
||||||
|
reports_router,
|
||||||
|
sessions_router,
|
||||||
|
tryouts_router,
|
||||||
|
wordpress_router,
|
||||||
|
)
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
|
"""
|
||||||
|
Application lifespan manager.
|
||||||
|
|
||||||
|
Handles startup and shutdown events.
|
||||||
|
"""
|
||||||
|
# Startup: Initialize database
|
||||||
|
await init_db()
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
# Shutdown: Close database connections
|
||||||
|
await close_db()
|
||||||
|
|
||||||
|
|
||||||
|
# Initialize FastAPI application
|
||||||
|
app = FastAPI(
|
||||||
|
title="IRT Bank Soal",
|
||||||
|
description="""
|
||||||
|
## Adaptive Question Bank System with IRT/CTT Scoring
|
||||||
|
|
||||||
|
This API provides a comprehensive backend for adaptive assessment systems.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- **CTT Scoring**: Classical Test Theory with exact Excel formula compatibility
|
||||||
|
- **IRT Support**: Item Response Theory for adaptive testing (1PL Rasch model)
|
||||||
|
- **Multi-Site**: Single backend serving multiple WordPress sites
|
||||||
|
- **AI Generation**: Automatic question variant generation
|
||||||
|
|
||||||
|
### Scoring Formulas (PRD Section 13.1)
|
||||||
|
- **CTT p-value**: `p = Σ Benar / Total Peserta`
|
||||||
|
- **CTT Bobot**: `Bobot = 1 - p`
|
||||||
|
- **CTT NM**: `NM = (Total_Bobot_Siswa / Total_Bobot_Max) × 1000`
|
||||||
|
- **CTT NN**: `NN = 500 + 100 × ((NM - Rataan) / SB)`
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
Most endpoints require `X-Website-ID` header for multi-site isolation.
|
||||||
|
""",
|
||||||
|
version="1.0.0",
|
||||||
|
docs_url="/docs",
|
||||||
|
redoc_url="/redoc",
|
||||||
|
openapi_url="/openapi.json",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configure CORS middleware
|
||||||
|
# Parse ALLOWED_ORIGINS from settings (comma-separated string)
|
||||||
|
allowed_origins = settings.ALLOWED_ORIGINS
|
||||||
|
if isinstance(allowed_origins, str):
|
||||||
|
allowed_origins = [origin.strip() for origin in allowed_origins.split(",") if origin.strip()]
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=allowed_origins,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Health check endpoint
|
||||||
|
@app.get(
|
||||||
|
"/",
|
||||||
|
summary="Health check",
|
||||||
|
description="Returns API status and version information.",
|
||||||
|
tags=["health"],
|
||||||
|
)
|
||||||
|
async def root():
|
||||||
|
"""
|
||||||
|
Health check endpoint.
|
||||||
|
|
||||||
|
Returns basic API information for monitoring and load balancer checks.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"status": "healthy",
|
||||||
|
"service": "IRT Bank Soal",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"docs": "/docs",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get(
|
||||||
|
"/health",
|
||||||
|
summary="Detailed health check",
|
||||||
|
description="Returns detailed health status including database connectivity.",
|
||||||
|
tags=["health"],
|
||||||
|
)
|
||||||
|
async def health_check():
|
||||||
|
"""
|
||||||
|
Detailed health check endpoint.
|
||||||
|
|
||||||
|
Includes database connectivity verification.
|
||||||
|
"""
|
||||||
|
from app.database import engine
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
db_status = "unknown"
|
||||||
|
try:
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
await conn.execute(text("SELECT 1"))
|
||||||
|
db_status = "connected"
|
||||||
|
except Exception as e:
|
||||||
|
db_status = f"error: {str(e)}"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "healthy" if db_status == "connected" else "degraded",
|
||||||
|
"service": "IRT Bank Soal",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"database": db_status,
|
||||||
|
"environment": settings.ENVIRONMENT,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Include API routers with version prefix
|
||||||
|
app.include_router(
|
||||||
|
import_export_router,
|
||||||
|
)
|
||||||
|
app.include_router(
|
||||||
|
sessions_router,
|
||||||
|
prefix=f"{settings.API_V1_STR}",
|
||||||
|
)
|
||||||
|
app.include_router(
|
||||||
|
tryouts_router,
|
||||||
|
prefix=f"{settings.API_V1_STR}",
|
||||||
|
)
|
||||||
|
app.include_router(
|
||||||
|
wordpress_router,
|
||||||
|
prefix=f"{settings.API_V1_STR}",
|
||||||
|
)
|
||||||
|
app.include_router(
|
||||||
|
ai_router,
|
||||||
|
prefix=f"{settings.API_V1_STR}",
|
||||||
|
)
|
||||||
|
app.include_router(
|
||||||
|
reports_router,
|
||||||
|
prefix=f"{settings.API_V1_STR}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Mount FastAPI Admin panel
|
||||||
|
app.mount("/admin", admin_app)
|
||||||
|
|
||||||
|
|
||||||
|
# Include admin API router for custom actions
|
||||||
|
app.include_router(
|
||||||
|
admin_router,
|
||||||
|
prefix=f"{settings.API_V1_STR}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Placeholder routers for future implementation
|
||||||
|
# These will be implemented in subsequent phases
|
||||||
|
|
||||||
|
# app.include_router(
|
||||||
|
# items_router,
|
||||||
|
# prefix=f"{settings.API_V1_STR}",
|
||||||
|
# tags=["items"],
|
||||||
|
# )
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
uvicorn.run(
|
||||||
|
"app.main:app",
|
||||||
|
host="0.0.0.0",
|
||||||
|
port=8000,
|
||||||
|
reload=settings.ENVIRONMENT == "development",
|
||||||
|
)
|
||||||
25
app/models/__init__.py
Normal file
25
app/models/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""
|
||||||
|
Database models for IRT Bank Soal system.
|
||||||
|
|
||||||
|
Exports all SQLAlchemy ORM models for use in the application.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
from app.models.item import Item
|
||||||
|
from app.models.session import Session
|
||||||
|
from app.models.tryout import Tryout
|
||||||
|
from app.models.tryout_stats import TryoutStats
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.user_answer import UserAnswer
|
||||||
|
from app.models.website import Website
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Base",
|
||||||
|
"User",
|
||||||
|
"Website",
|
||||||
|
"Tryout",
|
||||||
|
"Item",
|
||||||
|
"Session",
|
||||||
|
"UserAnswer",
|
||||||
|
"TryoutStats",
|
||||||
|
]
|
||||||
222
app/models/item.py
Normal file
222
app/models/item.py
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
"""
|
||||||
|
Item model for questions with CTT and IRT parameters.
|
||||||
|
|
||||||
|
Represents individual questions with both classical test theory (CTT)
|
||||||
|
and item response theory (IRT) parameters.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Union
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
CheckConstraint,
|
||||||
|
DateTime,
|
||||||
|
Float,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
JSON,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Item(Base):
|
||||||
|
"""
|
||||||
|
Item model representing individual questions.
|
||||||
|
|
||||||
|
Supports both CTT (p, bobot, category) and IRT (b, se) parameters.
|
||||||
|
Tracks AI generation metadata and calibration status.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Primary key
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
website_id: Website identifier
|
||||||
|
slot: Question position in tryout
|
||||||
|
level: Difficulty level (mudah, sedang, sulit)
|
||||||
|
stem: Question text
|
||||||
|
options: JSON array of answer options
|
||||||
|
correct_answer: Correct option (A, B, C, D)
|
||||||
|
explanation: Answer explanation
|
||||||
|
ctt_p: CTT difficulty (proportion correct)
|
||||||
|
ctt_bobot: CTT weight (1 - p)
|
||||||
|
ctt_category: CTT difficulty category
|
||||||
|
irt_b: IRT difficulty parameter [-3, +3]
|
||||||
|
irt_se: IRT standard error
|
||||||
|
calibrated: Calibration status
|
||||||
|
calibration_sample_size: Sample size for calibration
|
||||||
|
generated_by: Generation source (manual, ai)
|
||||||
|
ai_model: AI model used (if generated by AI)
|
||||||
|
basis_item_id: Original item ID (for AI variants)
|
||||||
|
created_at: Record creation timestamp
|
||||||
|
updated_at: Record update timestamp
|
||||||
|
tryout: Tryout relationship
|
||||||
|
user_answers: User responses to this item
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "items"
|
||||||
|
|
||||||
|
# Primary key
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
|
||||||
|
# Foreign keys
|
||||||
|
tryout_id: Mapped[str] = mapped_column(
|
||||||
|
String(255), nullable=False, index=True, comment="Tryout identifier"
|
||||||
|
)
|
||||||
|
website_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("websites.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
comment="Website identifier",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Position and difficulty
|
||||||
|
slot: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, comment="Question position in tryout"
|
||||||
|
)
|
||||||
|
level: Mapped[Literal["mudah", "sedang", "sulit"]] = mapped_column(
|
||||||
|
String(50), nullable=False, comment="Difficulty level"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Question content
|
||||||
|
stem: Mapped[str] = mapped_column(Text, nullable=False, comment="Question text")
|
||||||
|
options: Mapped[dict] = mapped_column(
|
||||||
|
JSON,
|
||||||
|
nullable=False,
|
||||||
|
comment="JSON object with options (e.g., {\"A\": \"option1\", \"B\": \"option2\"})",
|
||||||
|
)
|
||||||
|
correct_answer: Mapped[str] = mapped_column(
|
||||||
|
String(10), nullable=False, comment="Correct option (A, B, C, D)"
|
||||||
|
)
|
||||||
|
explanation: Mapped[Union[str, None]] = mapped_column(
|
||||||
|
Text, nullable=True, comment="Answer explanation"
|
||||||
|
)
|
||||||
|
|
||||||
|
# CTT parameters
|
||||||
|
ctt_p: Mapped[Union[float, None]] = mapped_column(
|
||||||
|
Float,
|
||||||
|
nullable=True,
|
||||||
|
comment="CTT difficulty (proportion correct)",
|
||||||
|
)
|
||||||
|
ctt_bobot: Mapped[Union[float, None]] = mapped_column(
|
||||||
|
Float,
|
||||||
|
nullable=True,
|
||||||
|
comment="CTT weight (1 - p)",
|
||||||
|
)
|
||||||
|
ctt_category: Mapped[Union[Literal["mudah", "sedang", "sulit"], None]] = mapped_column(
|
||||||
|
String(50),
|
||||||
|
nullable=True,
|
||||||
|
comment="CTT difficulty category",
|
||||||
|
)
|
||||||
|
|
||||||
|
# IRT parameters (1PL Rasch model)
|
||||||
|
irt_b: Mapped[Union[float, None]] = mapped_column(
|
||||||
|
Float,
|
||||||
|
nullable=True,
|
||||||
|
comment="IRT difficulty parameter [-3, +3]",
|
||||||
|
)
|
||||||
|
irt_se: Mapped[Union[float, None]] = mapped_column(
|
||||||
|
Float,
|
||||||
|
nullable=True,
|
||||||
|
comment="IRT standard error",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calibration status
|
||||||
|
calibrated: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=False, comment="Calibration status"
|
||||||
|
)
|
||||||
|
calibration_sample_size: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
default=0,
|
||||||
|
comment="Sample size for calibration",
|
||||||
|
)
|
||||||
|
|
||||||
|
# AI generation metadata
|
||||||
|
generated_by: Mapped[Literal["manual", "ai"]] = mapped_column(
|
||||||
|
String(50),
|
||||||
|
nullable=False,
|
||||||
|
default="manual",
|
||||||
|
comment="Generation source",
|
||||||
|
)
|
||||||
|
ai_model: Mapped[Union[str, None]] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=True,
|
||||||
|
comment="AI model used (if generated by AI)",
|
||||||
|
)
|
||||||
|
basis_item_id: Mapped[Union[int, None]] = mapped_column(
|
||||||
|
ForeignKey("items.id", ondelete="SET NULL", onupdate="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
comment="Original item ID (for AI variants)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default="NOW()"
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default="NOW()",
|
||||||
|
onupdate="NOW()",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
tryout: Mapped["Tryout"] = relationship(
|
||||||
|
"Tryout", back_populates="items", lazy="selectin"
|
||||||
|
)
|
||||||
|
user_answers: Mapped[list["UserAnswer"]] = relationship(
|
||||||
|
"UserAnswer", back_populates="item", lazy="selectin", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
basis_item: Mapped[Union["Item", None]] = relationship(
|
||||||
|
"Item",
|
||||||
|
remote_side=[id],
|
||||||
|
back_populates="variants",
|
||||||
|
lazy="selectin",
|
||||||
|
single_parent=True,
|
||||||
|
)
|
||||||
|
variants: Mapped[list["Item"]] = relationship(
|
||||||
|
"Item",
|
||||||
|
back_populates="basis_item",
|
||||||
|
lazy="selectin",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Constraints and indexes
|
||||||
|
__table_args__ = (
|
||||||
|
Index(
|
||||||
|
"ix_items_tryout_id_website_id_slot",
|
||||||
|
"tryout_id",
|
||||||
|
"website_id",
|
||||||
|
"slot",
|
||||||
|
"level",
|
||||||
|
unique=True,
|
||||||
|
),
|
||||||
|
Index("ix_items_calibrated", "calibrated"),
|
||||||
|
Index("ix_items_basis_item_id", "basis_item_id"),
|
||||||
|
# IRT b parameter constraint [-3, +3]
|
||||||
|
CheckConstraint(
|
||||||
|
"irt_b IS NULL OR (irt_b >= -3 AND irt_b <= 3)",
|
||||||
|
"ck_irt_b_range",
|
||||||
|
),
|
||||||
|
# CTT p constraint [0, 1]
|
||||||
|
CheckConstraint(
|
||||||
|
"ctt_p IS NULL OR (ctt_p >= 0 AND ctt_p <= 1)",
|
||||||
|
"ck_ctt_p_range",
|
||||||
|
),
|
||||||
|
# CTT bobot constraint [0, 1]
|
||||||
|
CheckConstraint(
|
||||||
|
"ctt_bobot IS NULL OR (ctt_bobot >= 0 AND ctt_bobot <= 1)",
|
||||||
|
"ck_ctt_bobot_range",
|
||||||
|
),
|
||||||
|
# Slot must be positive
|
||||||
|
CheckConstraint("slot > 0", "ck_slot_positive"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Item(id={self.id}, slot={self.slot}, level={self.level})>"
|
||||||
193
app/models/session.py
Normal file
193
app/models/session.py
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
"""
|
||||||
|
Session model for tryout attempt tracking.
|
||||||
|
|
||||||
|
Represents a student's attempt at a tryout with scoring information.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Union
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
CheckConstraint,
|
||||||
|
DateTime,
|
||||||
|
Float,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Session(Base):
|
||||||
|
"""
|
||||||
|
Session model representing a student's tryout attempt.
|
||||||
|
|
||||||
|
Tracks session metadata, scoring results, and IRT estimates.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Primary key
|
||||||
|
session_id: Unique session identifier
|
||||||
|
wp_user_id: WordPress user ID
|
||||||
|
website_id: Website identifier
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
start_time: Session start timestamp
|
||||||
|
end_time: Session end timestamp
|
||||||
|
is_completed: Completion status
|
||||||
|
scoring_mode_used: Scoring mode used for this session
|
||||||
|
total_benar: Total correct answers
|
||||||
|
total_bobot_earned: Total weight earned
|
||||||
|
NM: Nilai Mentah (raw score) [0, 1000]
|
||||||
|
NN: Nilai Nasional (normalized score) [0, 1000]
|
||||||
|
theta: IRT ability estimate [-3, +3]
|
||||||
|
theta_se: IRT standard error
|
||||||
|
rataan_used: Mean value used for normalization
|
||||||
|
sb_used: Standard deviation used for normalization
|
||||||
|
created_at: Record creation timestamp
|
||||||
|
updated_at: Record update timestamp
|
||||||
|
user: User relationship
|
||||||
|
tryout: Tryout relationship
|
||||||
|
user_answers: User's responses in this session
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "sessions"
|
||||||
|
|
||||||
|
# Primary key
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
|
||||||
|
# Session identifier (globally unique)
|
||||||
|
session_id: Mapped[str] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=False,
|
||||||
|
unique=True,
|
||||||
|
index=True,
|
||||||
|
comment="Unique session identifier",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Foreign keys
|
||||||
|
wp_user_id: Mapped[str] = mapped_column(
|
||||||
|
String(255), nullable=False, index=True, comment="WordPress user ID"
|
||||||
|
)
|
||||||
|
website_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("websites.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
comment="Website identifier",
|
||||||
|
)
|
||||||
|
tryout_id: Mapped[str] = mapped_column(
|
||||||
|
String(255), nullable=False, index=True, comment="Tryout identifier"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
start_time: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default="NOW()"
|
||||||
|
)
|
||||||
|
end_time: Mapped[Union[datetime, None]] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True, comment="Session end timestamp"
|
||||||
|
)
|
||||||
|
is_completed: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=False, comment="Completion status"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Scoring metadata
|
||||||
|
scoring_mode_used: Mapped[Literal["ctt", "irt", "hybrid"]] = mapped_column(
|
||||||
|
String(50),
|
||||||
|
nullable=False,
|
||||||
|
comment="Scoring mode used for this session",
|
||||||
|
)
|
||||||
|
|
||||||
|
# CTT scoring results
|
||||||
|
total_benar: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=0, comment="Total correct answers"
|
||||||
|
)
|
||||||
|
total_bobot_earned: Mapped[float] = mapped_column(
|
||||||
|
Float, nullable=False, default=0.0, comment="Total weight earned"
|
||||||
|
)
|
||||||
|
NM: Mapped[Union[int, None]] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
nullable=True,
|
||||||
|
comment="Nilai Mentah (raw score) [0, 1000]",
|
||||||
|
)
|
||||||
|
NN: Mapped[Union[int, None]] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
nullable=True,
|
||||||
|
comment="Nilai Nasional (normalized score) [0, 1000]",
|
||||||
|
)
|
||||||
|
|
||||||
|
# IRT scoring results
|
||||||
|
theta: Mapped[Union[float, None]] = mapped_column(
|
||||||
|
Float,
|
||||||
|
nullable=True,
|
||||||
|
comment="IRT ability estimate [-3, +3]",
|
||||||
|
)
|
||||||
|
theta_se: Mapped[Union[float, None]] = mapped_column(
|
||||||
|
Float,
|
||||||
|
nullable=True,
|
||||||
|
comment="IRT standard error",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Normalization metadata
|
||||||
|
rataan_used: Mapped[Union[float, None]] = mapped_column(
|
||||||
|
Float,
|
||||||
|
nullable=True,
|
||||||
|
comment="Mean value used for normalization",
|
||||||
|
)
|
||||||
|
sb_used: Mapped[Union[float, None]] = mapped_column(
|
||||||
|
Float,
|
||||||
|
nullable=True,
|
||||||
|
comment="Standard deviation used for normalization",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default="NOW()"
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default="NOW()",
|
||||||
|
onupdate="NOW()",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user: Mapped["User"] = relationship(
|
||||||
|
"User", back_populates="sessions", lazy="selectin"
|
||||||
|
)
|
||||||
|
tryout: Mapped["Tryout"] = relationship(
|
||||||
|
"Tryout", back_populates="sessions", lazy="selectin"
|
||||||
|
)
|
||||||
|
user_answers: Mapped[list["UserAnswer"]] = relationship(
|
||||||
|
"UserAnswer", back_populates="session", lazy="selectin", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Constraints and indexes
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_sessions_wp_user_id", "wp_user_id"),
|
||||||
|
Index("ix_sessions_website_id", "website_id"),
|
||||||
|
Index("ix_sessions_tryout_id", "tryout_id"),
|
||||||
|
Index("ix_sessions_is_completed", "is_completed"),
|
||||||
|
# Score constraints [0, 1000]
|
||||||
|
CheckConstraint(
|
||||||
|
"NM IS NULL OR (NM >= 0 AND NM <= 1000)",
|
||||||
|
"ck_nm_range",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"NN IS NULL OR (NN >= 0 AND NN <= 1000)",
|
||||||
|
"ck_nn_range",
|
||||||
|
),
|
||||||
|
# IRT theta constraint [-3, +3]
|
||||||
|
CheckConstraint(
|
||||||
|
"theta IS NULL OR (theta >= -3 AND theta <= 3)",
|
||||||
|
"ck_theta_range",
|
||||||
|
),
|
||||||
|
# Total correct must be non-negative
|
||||||
|
CheckConstraint("total_benar >= 0", "ck_total_benar_non_negative"),
|
||||||
|
# Total bobot must be non-negative
|
||||||
|
CheckConstraint("total_bobot_earned >= 0", "ck_total_bobot_non_negative"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Session(session_id={self.session_id}, tryout_id={self.tryout_id})>"
|
||||||
184
app/models/tryout.py
Normal file
184
app/models/tryout.py
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
"""
|
||||||
|
Tryout model with configuration for assessment sessions.
|
||||||
|
|
||||||
|
Represents tryout exams with configurable scoring, selection, and normalization modes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Union
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, CheckConstraint, DateTime, Float, ForeignKey, Index, Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Tryout(Base):
|
||||||
|
"""
|
||||||
|
Tryout model with configuration for assessment sessions.
|
||||||
|
|
||||||
|
Supports multiple scoring modes (CTT, IRT, hybrid), selection strategies
|
||||||
|
(fixed, adaptive, hybrid), and normalization modes (static, dynamic, hybrid).
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Primary key
|
||||||
|
website_id: Website identifier
|
||||||
|
tryout_id: Tryout identifier (unique per website)
|
||||||
|
name: Tryout name
|
||||||
|
description: Tryout description
|
||||||
|
scoring_mode: Scoring algorithm (ctt, irt, hybrid)
|
||||||
|
selection_mode: Item selection strategy (fixed, adaptive, hybrid)
|
||||||
|
normalization_mode: Normalization method (static, dynamic, hybrid)
|
||||||
|
min_sample_for_dynamic: Minimum sample size for dynamic normalization
|
||||||
|
static_rataan: Static mean value for manual normalization
|
||||||
|
static_sb: Static standard deviation for manual normalization
|
||||||
|
AI_generation_enabled: Enable/disable AI question generation
|
||||||
|
hybrid_transition_slot: Slot number to transition from fixed to adaptive
|
||||||
|
min_calibration_sample: Minimum responses needed for IRT calibration
|
||||||
|
theta_estimation_method: Method for estimating theta (mle, map, eap)
|
||||||
|
fallback_to_ctt_on_error: Fallback to CTT if IRT fails
|
||||||
|
created_at: Record creation timestamp
|
||||||
|
updated_at: Record update timestamp
|
||||||
|
website: Website relationship
|
||||||
|
items: Items in this tryout
|
||||||
|
sessions: Sessions for this tryout
|
||||||
|
stats: Tryout statistics
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "tryouts"
|
||||||
|
|
||||||
|
# Primary key
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
|
||||||
|
# Foreign keys
|
||||||
|
website_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("websites.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
comment="Website identifier",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Tryout identifier (unique per website)
|
||||||
|
tryout_id: Mapped[str] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
comment="Tryout identifier (unique per website)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Basic information
|
||||||
|
name: Mapped[str] = mapped_column(
|
||||||
|
String(255), nullable=False, comment="Tryout name"
|
||||||
|
)
|
||||||
|
description: Mapped[Union[str, None]] = mapped_column(
|
||||||
|
String(1000), nullable=True, comment="Tryout description"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Scoring mode: ctt (Classical Test Theory), irt (Item Response Theory), hybrid
|
||||||
|
scoring_mode: Mapped[Literal["ctt", "irt", "hybrid"]] = mapped_column(
|
||||||
|
String(50), nullable=False, default="ctt", comment="Scoring mode"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Selection mode: fixed (slot order), adaptive (CAT), hybrid (mixed)
|
||||||
|
selection_mode: Mapped[Literal["fixed", "adaptive", "hybrid"]] = mapped_column(
|
||||||
|
String(50), nullable=False, default="fixed", comment="Item selection mode"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Normalization mode: static (hardcoded), dynamic (real-time), hybrid
|
||||||
|
normalization_mode: Mapped[Literal["static", "dynamic", "hybrid"]] = mapped_column(
|
||||||
|
String(50), nullable=False, default="static", comment="Normalization mode"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Normalization settings
|
||||||
|
min_sample_for_dynamic: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
default=100,
|
||||||
|
comment="Minimum sample size for dynamic normalization",
|
||||||
|
)
|
||||||
|
static_rataan: Mapped[float] = mapped_column(
|
||||||
|
Float,
|
||||||
|
nullable=False,
|
||||||
|
default=500.0,
|
||||||
|
comment="Static mean value for manual normalization",
|
||||||
|
)
|
||||||
|
static_sb: Mapped[float] = mapped_column(
|
||||||
|
Float,
|
||||||
|
nullable=False,
|
||||||
|
default=100.0,
|
||||||
|
comment="Static standard deviation for manual normalization",
|
||||||
|
)
|
||||||
|
|
||||||
|
# AI generation settings
|
||||||
|
ai_generation_enabled: Mapped[bool] = mapped_column(
|
||||||
|
Boolean,
|
||||||
|
nullable=False,
|
||||||
|
default=False,
|
||||||
|
comment="Enable/disable AI question generation",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Hybrid mode settings
|
||||||
|
hybrid_transition_slot: Mapped[Union[int, None]] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
nullable=True,
|
||||||
|
comment="Slot number to transition from fixed to adaptive (hybrid mode)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# IRT settings
|
||||||
|
min_calibration_sample: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
default=100,
|
||||||
|
comment="Minimum responses needed for IRT calibration",
|
||||||
|
)
|
||||||
|
theta_estimation_method: Mapped[Literal["mle", "map", "eap"]] = mapped_column(
|
||||||
|
String(50),
|
||||||
|
nullable=False,
|
||||||
|
default="mle",
|
||||||
|
comment="Method for estimating theta",
|
||||||
|
)
|
||||||
|
fallback_to_ctt_on_error: Mapped[bool] = mapped_column(
|
||||||
|
Boolean,
|
||||||
|
nullable=False,
|
||||||
|
default=True,
|
||||||
|
comment="Fallback to CTT if IRT fails",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default="NOW()"
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default="NOW()",
|
||||||
|
onupdate="NOW()",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
website: Mapped["Website"] = relationship(
|
||||||
|
"Website", back_populates="tryouts", lazy="selectin"
|
||||||
|
)
|
||||||
|
items: Mapped[list["Item"]] = relationship(
|
||||||
|
"Item", back_populates="tryout", lazy="selectin", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
sessions: Mapped[list["Session"]] = relationship(
|
||||||
|
"Session", back_populates="tryout", lazy="selectin", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
stats: Mapped["TryoutStats"] = relationship(
|
||||||
|
"TryoutStats", back_populates="tryout", lazy="selectin", uselist=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Constraints and indexes
|
||||||
|
__table_args__ = (
|
||||||
|
Index(
|
||||||
|
"ix_tryouts_website_id_tryout_id", "website_id", "tryout_id", unique=True
|
||||||
|
),
|
||||||
|
CheckConstraint("min_sample_for_dynamic > 0", "ck_min_sample_positive"),
|
||||||
|
CheckConstraint("static_rataan > 0", "ck_static_rataan_positive"),
|
||||||
|
CheckConstraint("static_sb > 0", "ck_static_sb_positive"),
|
||||||
|
CheckConstraint("min_calibration_sample > 0", "ck_min_calibration_positive"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Tryout(id={self.id}, tryout_id={self.tryout_id}, website_id={self.website_id})>"
|
||||||
151
app/models/tryout_stats.py
Normal file
151
app/models/tryout_stats.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"""
|
||||||
|
TryoutStats model for tracking tryout-level statistics.
|
||||||
|
|
||||||
|
Maintains running statistics for dynamic normalization and reporting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
|
from sqlalchemy import CheckConstraint, DateTime, Float, ForeignKey, Index, Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class TryoutStats(Base):
|
||||||
|
"""
|
||||||
|
TryoutStats model for maintaining tryout-level statistics.
|
||||||
|
|
||||||
|
Tracks participant counts, score distributions, and calculated
|
||||||
|
normalization parameters (rataan, sb) for dynamic normalization.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Primary key
|
||||||
|
website_id: Website identifier
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
participant_count: Number of completed sessions
|
||||||
|
total_nm_sum: Running sum of NM scores
|
||||||
|
total_nm_sq_sum: Running sum of squared NM scores (for variance calc)
|
||||||
|
rataan: Calculated mean of NM scores
|
||||||
|
sb: Calculated standard deviation of NM scores
|
||||||
|
min_nm: Minimum NM score observed
|
||||||
|
max_nm: Maximum NM score observed
|
||||||
|
last_calculated: Timestamp of last statistics update
|
||||||
|
created_at: Record creation timestamp
|
||||||
|
updated_at: Record update timestamp
|
||||||
|
tryout: Tryout relationship
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "tryout_stats"
|
||||||
|
|
||||||
|
# Primary key
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
|
||||||
|
# Foreign keys
|
||||||
|
website_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("websites.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
comment="Website identifier",
|
||||||
|
)
|
||||||
|
tryout_id: Mapped[str] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
comment="Tryout identifier",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Running statistics
|
||||||
|
participant_count: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
default=0,
|
||||||
|
comment="Number of completed sessions",
|
||||||
|
)
|
||||||
|
total_nm_sum: Mapped[float] = mapped_column(
|
||||||
|
Float,
|
||||||
|
nullable=False,
|
||||||
|
default=0.0,
|
||||||
|
comment="Running sum of NM scores",
|
||||||
|
)
|
||||||
|
total_nm_sq_sum: Mapped[float] = mapped_column(
|
||||||
|
Float,
|
||||||
|
nullable=False,
|
||||||
|
default=0.0,
|
||||||
|
comment="Running sum of squared NM scores",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculated statistics
|
||||||
|
rataan: Mapped[Union[float, None]] = mapped_column(
|
||||||
|
Float,
|
||||||
|
nullable=True,
|
||||||
|
comment="Calculated mean of NM scores",
|
||||||
|
)
|
||||||
|
sb: Mapped[Union[float, None]] = mapped_column(
|
||||||
|
Float,
|
||||||
|
nullable=True,
|
||||||
|
comment="Calculated standard deviation of NM scores",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Score range
|
||||||
|
min_nm: Mapped[Union[int, None]] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
nullable=True,
|
||||||
|
comment="Minimum NM score observed",
|
||||||
|
)
|
||||||
|
max_nm: Mapped[Union[int, None]] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
nullable=True,
|
||||||
|
comment="Maximum NM score observed",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
last_calculated: Mapped[Union[datetime, None]] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="Timestamp of last statistics update",
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default="NOW()"
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default="NOW()",
|
||||||
|
onupdate="NOW()",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
tryout: Mapped["Tryout"] = relationship(
|
||||||
|
"Tryout", back_populates="stats", lazy="selectin"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Constraints and indexes
|
||||||
|
__table_args__ = (
|
||||||
|
Index(
|
||||||
|
"ix_tryout_stats_website_id_tryout_id",
|
||||||
|
"website_id",
|
||||||
|
"tryout_id",
|
||||||
|
unique=True,
|
||||||
|
),
|
||||||
|
# Participant count must be non-negative
|
||||||
|
CheckConstraint("participant_count >= 0", "ck_participant_count_non_negative"),
|
||||||
|
# Min and max NM must be within valid range [0, 1000]
|
||||||
|
CheckConstraint(
|
||||||
|
"min_nm IS NULL OR (min_nm >= 0 AND min_nm <= 1000)",
|
||||||
|
"ck_min_nm_range",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"max_nm IS NULL OR (max_nm >= 0 AND max_nm <= 1000)",
|
||||||
|
"ck_max_nm_range",
|
||||||
|
),
|
||||||
|
# Min must be less than or equal to max
|
||||||
|
CheckConstraint(
|
||||||
|
"min_nm IS NULL OR max_nm IS NULL OR min_nm <= max_nm",
|
||||||
|
"ck_min_max_nm_order",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<TryoutStats(tryout_id={self.tryout_id}, participant_count={self.participant_count})>"
|
||||||
72
app/models/user.py
Normal file
72
app/models/user.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""
|
||||||
|
User model for WordPress user integration.
|
||||||
|
|
||||||
|
Represents users from WordPress that can take tryouts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Index, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
"""
|
||||||
|
User model representing WordPress users.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Primary key
|
||||||
|
wp_user_id: WordPress user ID (unique per site)
|
||||||
|
website_id: Website identifier (for multi-site support)
|
||||||
|
created_at: Record creation timestamp
|
||||||
|
updated_at: Record update timestamp
|
||||||
|
sessions: User's tryout sessions
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
# Primary key
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
|
||||||
|
# WordPress user ID (unique within website context)
|
||||||
|
wp_user_id: Mapped[int] = mapped_column(
|
||||||
|
String(255), nullable=False, index=True, comment="WordPress user ID"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Website identifier (for multi-site support)
|
||||||
|
website_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("websites.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
comment="Website identifier",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default="NOW()"
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default="NOW()",
|
||||||
|
onupdate="NOW()",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
website: Mapped["Website"] = relationship(
|
||||||
|
"Website", back_populates="users", lazy="selectin"
|
||||||
|
)
|
||||||
|
sessions: Mapped[list["Session"]] = relationship(
|
||||||
|
"Session", back_populates="user", lazy="selectin", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Indexes
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_users_wp_user_id_website_id", "wp_user_id", "website_id", unique=True),
|
||||||
|
Index("ix_users_website_id", "website_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<User(wp_user_id={self.wp_user_id}, website_id={self.website_id})>"
|
||||||
137
app/models/user_answer.py
Normal file
137
app/models/user_answer.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
"""
|
||||||
|
UserAnswer model for tracking individual question responses.
|
||||||
|
|
||||||
|
Represents a student's response to a single question with scoring metadata.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Union
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, CheckConstraint, DateTime, Float, ForeignKey, Index, Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class UserAnswer(Base):
|
||||||
|
"""
|
||||||
|
UserAnswer model representing a student's response to a question.
|
||||||
|
|
||||||
|
Tracks response, correctness, scoring, and timing information.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Primary key
|
||||||
|
session_id: Session identifier
|
||||||
|
wp_user_id: WordPress user ID
|
||||||
|
website_id: Website identifier
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
item_id: Item identifier
|
||||||
|
response: User's answer (A, B, C, D)
|
||||||
|
is_correct: Whether answer is correct
|
||||||
|
time_spent: Time spent on this question (seconds)
|
||||||
|
scoring_mode_used: Scoring mode used
|
||||||
|
bobot_earned: Weight earned for this answer
|
||||||
|
created_at: Record creation timestamp
|
||||||
|
updated_at: Record update timestamp
|
||||||
|
session: Session relationship
|
||||||
|
item: Item relationship
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "user_answers"
|
||||||
|
|
||||||
|
# Primary key
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
|
||||||
|
# Foreign keys
|
||||||
|
session_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("sessions.session_id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
comment="Session identifier",
|
||||||
|
)
|
||||||
|
wp_user_id: Mapped[str] = mapped_column(
|
||||||
|
String(255), nullable=False, index=True, comment="WordPress user ID"
|
||||||
|
)
|
||||||
|
website_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("websites.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
comment="Website identifier",
|
||||||
|
)
|
||||||
|
tryout_id: Mapped[str] = mapped_column(
|
||||||
|
String(255), nullable=False, index=True, comment="Tryout identifier"
|
||||||
|
)
|
||||||
|
item_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("items.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
comment="Item identifier",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Response information
|
||||||
|
response: Mapped[str] = mapped_column(
|
||||||
|
String(10), nullable=False, comment="User's answer (A, B, C, D)"
|
||||||
|
)
|
||||||
|
is_correct: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, comment="Whether answer is correct"
|
||||||
|
)
|
||||||
|
time_spent: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
default=0,
|
||||||
|
comment="Time spent on this question (seconds)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Scoring metadata
|
||||||
|
scoring_mode_used: Mapped[Literal["ctt", "irt", "hybrid"]] = mapped_column(
|
||||||
|
String(50),
|
||||||
|
nullable=False,
|
||||||
|
comment="Scoring mode used",
|
||||||
|
)
|
||||||
|
bobot_earned: Mapped[float] = mapped_column(
|
||||||
|
Float,
|
||||||
|
nullable=False,
|
||||||
|
default=0.0,
|
||||||
|
comment="Weight earned for this answer",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default="NOW()"
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default="NOW()",
|
||||||
|
onupdate="NOW()",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
session: Mapped["Session"] = relationship(
|
||||||
|
"Session", back_populates="user_answers", lazy="selectin"
|
||||||
|
)
|
||||||
|
item: Mapped["Item"] = relationship(
|
||||||
|
"Item", back_populates="user_answers", lazy="selectin"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Constraints and indexes
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_user_answers_session_id", "session_id"),
|
||||||
|
Index("ix_user_answers_wp_user_id", "wp_user_id"),
|
||||||
|
Index("ix_user_answers_website_id", "website_id"),
|
||||||
|
Index("ix_user_answers_tryout_id", "tryout_id"),
|
||||||
|
Index("ix_user_answers_item_id", "item_id"),
|
||||||
|
Index(
|
||||||
|
"ix_user_answers_session_id_item_id",
|
||||||
|
"session_id",
|
||||||
|
"item_id",
|
||||||
|
unique=True,
|
||||||
|
),
|
||||||
|
# Time spent must be non-negative
|
||||||
|
CheckConstraint("time_spent >= 0", "ck_time_spent_non_negative"),
|
||||||
|
# Bobot earned must be non-negative
|
||||||
|
CheckConstraint("bobot_earned >= 0", "ck_bobot_earned_non_negative"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<UserAnswer(id={self.id}, session_id={self.session_id}, item_id={self.item_id})>"
|
||||||
69
app/models/website.py
Normal file
69
app/models/website.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""
|
||||||
|
Website model for multi-site support.
|
||||||
|
|
||||||
|
Represents WordPress websites that use the IRT Bank Soal system.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Website(Base):
|
||||||
|
"""
|
||||||
|
Website model representing WordPress sites.
|
||||||
|
|
||||||
|
Enables multi-site support where a single backend serves multiple
|
||||||
|
WordPress-powered educational sites.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Primary key
|
||||||
|
site_url: WordPress site URL
|
||||||
|
site_name: Human-readable site name
|
||||||
|
created_at: Record creation timestamp
|
||||||
|
updated_at: Record update timestamp
|
||||||
|
users: Users belonging to this website
|
||||||
|
tryouts: Tryouts available on this website
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "websites"
|
||||||
|
|
||||||
|
# Primary key
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
|
||||||
|
# Site information
|
||||||
|
site_url: Mapped[str] = mapped_column(
|
||||||
|
String(512),
|
||||||
|
nullable=False,
|
||||||
|
unique=True,
|
||||||
|
index=True,
|
||||||
|
comment="WordPress site URL",
|
||||||
|
)
|
||||||
|
site_name: Mapped[str] = mapped_column(
|
||||||
|
String(255), nullable=False, comment="Human-readable site name"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default="NOW()"
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default="NOW()",
|
||||||
|
onupdate="NOW()",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
users: Mapped[list["User"]] = relationship(
|
||||||
|
"User", back_populates="website", lazy="selectin", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
tryouts: Mapped[list["Tryout"]] = relationship(
|
||||||
|
"Tryout", back_populates="website", lazy="selectin", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Website(id={self.id}, site_url={self.site_url})>"
|
||||||
13
app/routers/__init__.py
Normal file
13
app/routers/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
"""
|
||||||
|
API routers package.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.routers.sessions import router as sessions_router
|
||||||
|
from app.routers.tryouts import router as tryouts_router
|
||||||
|
from app.routers.reports import router as reports_router
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"sessions_router",
|
||||||
|
"tryouts_router",
|
||||||
|
"reports_router",
|
||||||
|
]
|
||||||
249
app/routers/admin.py
Normal file
249
app/routers/admin.py
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
"""
|
||||||
|
Admin API router for custom admin actions.
|
||||||
|
|
||||||
|
Provides admin-specific endpoints for triggering calibration,
|
||||||
|
toggling AI generation, and resetting normalization.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import Tryout, TryoutStats
|
||||||
|
from app.services.irt_calibration import (
|
||||||
|
calibrate_all,
|
||||||
|
CALIBRATION_SAMPLE_THRESHOLD,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
def get_admin_website_id(
|
||||||
|
x_website_id: Optional[str] = Header(None, alias="X-Website-ID"),
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Extract and validate website_id from request header for admin operations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
x_website_id: Website ID from header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Validated website ID as integer
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If header is missing or invalid
|
||||||
|
"""
|
||||||
|
if x_website_id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="X-Website-ID header is required",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return int(x_website_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="X-Website-ID must be a valid integer",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{tryout_id}/calibrate",
|
||||||
|
summary="Trigger IRT calibration",
|
||||||
|
description="Trigger IRT calibration for all items in this tryout with sufficient response data.",
|
||||||
|
)
|
||||||
|
async def admin_trigger_calibration(
|
||||||
|
tryout_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
website_id: int = Depends(get_admin_website_id),
|
||||||
|
) -> Dict[str, any]:
|
||||||
|
"""
|
||||||
|
Trigger IRT calibration for all items in a tryout.
|
||||||
|
|
||||||
|
Runs calibration for items with >= min_calibration_sample responses.
|
||||||
|
Updates item.irt_b, item.irt_se, and item.calibrated status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
db: Database session
|
||||||
|
website_id: Website ID from header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Calibration results summary
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If tryout not found or calibration fails
|
||||||
|
"""
|
||||||
|
# Verify tryout exists
|
||||||
|
tryout_result = await db.execute(
|
||||||
|
select(Tryout).where(
|
||||||
|
Tryout.website_id == website_id,
|
||||||
|
Tryout.tryout_id == tryout_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tryout = tryout_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if tryout is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Tryout {tryout_id} not found for website {website_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run calibration
|
||||||
|
result = await calibrate_all(
|
||||||
|
tryout_id=tryout_id,
|
||||||
|
website_id=website_id,
|
||||||
|
db=db,
|
||||||
|
min_sample_size=tryout.min_calibration_sample or CALIBRATION_SAMPLE_THRESHOLD,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tryout_id": tryout_id,
|
||||||
|
"total_items": result.total_items,
|
||||||
|
"calibrated_items": result.calibrated_items,
|
||||||
|
"failed_items": result.failed_items,
|
||||||
|
"calibration_percentage": round(result.calibration_percentage * 100, 2),
|
||||||
|
"ready_for_irt": result.ready_for_irt,
|
||||||
|
"message": f"Calibration complete: {result.calibrated_items}/{result.total_items} items calibrated",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{tryout_id}/toggle-ai-generation",
|
||||||
|
summary="Toggle AI generation",
|
||||||
|
description="Toggle AI question generation for a tryout.",
|
||||||
|
)
|
||||||
|
async def admin_toggle_ai_generation(
|
||||||
|
tryout_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
website_id: int = Depends(get_admin_website_id),
|
||||||
|
) -> Dict[str, any]:
|
||||||
|
"""
|
||||||
|
Toggle AI generation for a tryout.
|
||||||
|
|
||||||
|
Updates Tryout.AI_generation_enabled field.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
db: Database session
|
||||||
|
website_id: Website ID from header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated AI generation status
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If tryout not found
|
||||||
|
"""
|
||||||
|
# Get tryout
|
||||||
|
result = await db.execute(
|
||||||
|
select(Tryout).where(
|
||||||
|
Tryout.website_id == website_id,
|
||||||
|
Tryout.tryout_id == tryout_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tryout = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if tryout is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Tryout {tryout_id} not found for website {website_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Toggle AI generation
|
||||||
|
tryout.ai_generation_enabled = not tryout.ai_generation_enabled
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(tryout)
|
||||||
|
|
||||||
|
status = "enabled" if tryout.ai_generation_enabled else "disabled"
|
||||||
|
return {
|
||||||
|
"tryout_id": tryout_id,
|
||||||
|
"ai_generation_enabled": tryout.ai_generation_enabled,
|
||||||
|
"message": f"AI generation {status} for tryout {tryout_id}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{tryout_id}/reset-normalization",
|
||||||
|
summary="Reset normalization",
|
||||||
|
description="Reset normalization to static values and clear incremental stats.",
|
||||||
|
)
|
||||||
|
async def admin_reset_normalization(
|
||||||
|
tryout_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
website_id: int = Depends(get_admin_website_id),
|
||||||
|
) -> Dict[str, any]:
|
||||||
|
"""
|
||||||
|
Reset normalization for a tryout.
|
||||||
|
|
||||||
|
Resets rataan, sb to static values and clears incremental stats.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
db: Database session
|
||||||
|
website_id: Website ID from header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Reset statistics
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If tryout or stats not found
|
||||||
|
"""
|
||||||
|
# Get tryout stats
|
||||||
|
stats_result = await db.execute(
|
||||||
|
select(TryoutStats).where(
|
||||||
|
TryoutStats.website_id == website_id,
|
||||||
|
TryoutStats.tryout_id == tryout_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stats = stats_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if stats is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"TryoutStats for {tryout_id} not found for website {website_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get tryout for static values
|
||||||
|
tryout_result = await db.execute(
|
||||||
|
select(Tryout).where(
|
||||||
|
Tryout.website_id == website_id,
|
||||||
|
Tryout.tryout_id == tryout_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tryout = tryout_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if tryout:
|
||||||
|
# Reset to static values
|
||||||
|
stats.rataan = tryout.static_rataan
|
||||||
|
stats.sb = tryout.static_sb
|
||||||
|
else:
|
||||||
|
# Reset to default values
|
||||||
|
stats.rataan = 500.0
|
||||||
|
stats.sb = 100.0
|
||||||
|
|
||||||
|
# Clear incremental stats
|
||||||
|
old_participant_count = stats.participant_count
|
||||||
|
stats.participant_count = 0
|
||||||
|
stats.total_nm_sum = 0.0
|
||||||
|
stats.total_nm_sq_sum = 0.0
|
||||||
|
stats.min_nm = None
|
||||||
|
stats.max_nm = None
|
||||||
|
stats.last_calculated = None
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(stats)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tryout_id": tryout_id,
|
||||||
|
"rataan": stats.rataan,
|
||||||
|
"sb": stats.sb,
|
||||||
|
"cleared_stats": {
|
||||||
|
"previous_participant_count": old_participant_count,
|
||||||
|
},
|
||||||
|
"message": f"Normalization reset to static values (rataan={stats.rataan}, sb={stats.sb}). Incremental stats cleared.",
|
||||||
|
}
|
||||||
292
app/routers/ai.py
Normal file
292
app/routers/ai.py
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
"""
|
||||||
|
AI Generation Router.
|
||||||
|
|
||||||
|
Admin endpoints for AI question generation playground.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import and_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.item import Item
|
||||||
|
from app.schemas.ai import (
|
||||||
|
AIGeneratePreviewRequest,
|
||||||
|
AIGeneratePreviewResponse,
|
||||||
|
AISaveRequest,
|
||||||
|
AISaveResponse,
|
||||||
|
AIStatsResponse,
|
||||||
|
)
|
||||||
|
from app.services.ai_generation import (
|
||||||
|
generate_question,
|
||||||
|
get_ai_stats,
|
||||||
|
save_ai_question,
|
||||||
|
validate_ai_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin/ai", tags=["admin", "ai-generation"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/generate-preview",
|
||||||
|
response_model=AIGeneratePreviewResponse,
|
||||||
|
summary="Preview AI-generated question",
|
||||||
|
description="""
|
||||||
|
Generate a question preview using AI without saving to database.
|
||||||
|
|
||||||
|
This is an admin playground endpoint for testing AI generation quality.
|
||||||
|
Admins can retry unlimited times until satisfied with the result.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- basis_item_id must reference an existing item at 'sedang' level
|
||||||
|
- target_level must be 'mudah' or 'sulit'
|
||||||
|
- ai_model must be a supported OpenRouter model
|
||||||
|
""",
|
||||||
|
responses={
|
||||||
|
200: {"description": "Question generated successfully (preview mode)"},
|
||||||
|
400: {"description": "Invalid request (wrong level, unsupported model)"},
|
||||||
|
404: {"description": "Basis item not found"},
|
||||||
|
500: {"description": "AI generation failed"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def generate_preview(
|
||||||
|
request: AIGeneratePreviewRequest,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> AIGeneratePreviewResponse:
|
||||||
|
"""
|
||||||
|
Generate AI question preview (no database save).
|
||||||
|
|
||||||
|
- **basis_item_id**: ID of the sedang-level question to base generation on
|
||||||
|
- **target_level**: Target difficulty (mudah/sulit)
|
||||||
|
- **ai_model**: OpenRouter model to use (default: qwen/qwen-2.5-coder-32b-instruct)
|
||||||
|
"""
|
||||||
|
# Validate AI model
|
||||||
|
if not validate_ai_model(request.ai_model):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Unsupported AI model: {request.ai_model}. "
|
||||||
|
f"Supported models: qwen/qwen-2.5-coder-32b-instruct, meta-llama/llama-3.3-70b-instruct",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch basis item
|
||||||
|
result = await db.execute(
|
||||||
|
select(Item).where(Item.id == request.basis_item_id)
|
||||||
|
)
|
||||||
|
basis_item = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not basis_item:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Basis item not found: {request.basis_item_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate basis item is sedang level
|
||||||
|
if basis_item.level != "sedang":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Basis item must be 'sedang' level, got: {basis_item.level}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate question
|
||||||
|
try:
|
||||||
|
generated = await generate_question(
|
||||||
|
basis_item=basis_item,
|
||||||
|
target_level=request.target_level,
|
||||||
|
ai_model=request.ai_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not generated:
|
||||||
|
return AIGeneratePreviewResponse(
|
||||||
|
success=False,
|
||||||
|
error="AI generation failed. Please check logs or try again.",
|
||||||
|
ai_model=request.ai_model,
|
||||||
|
basis_item_id=request.basis_item_id,
|
||||||
|
target_level=request.target_level,
|
||||||
|
)
|
||||||
|
|
||||||
|
return AIGeneratePreviewResponse(
|
||||||
|
success=True,
|
||||||
|
stem=generated.stem,
|
||||||
|
options=generated.options,
|
||||||
|
correct=generated.correct,
|
||||||
|
explanation=generated.explanation,
|
||||||
|
ai_model=request.ai_model,
|
||||||
|
basis_item_id=request.basis_item_id,
|
||||||
|
target_level=request.target_level,
|
||||||
|
cached=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"AI preview generation failed: {e}")
|
||||||
|
return AIGeneratePreviewResponse(
|
||||||
|
success=False,
|
||||||
|
error=f"AI generation error: {str(e)}",
|
||||||
|
ai_model=request.ai_model,
|
||||||
|
basis_item_id=request.basis_item_id,
|
||||||
|
target_level=request.target_level,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/generate-save",
|
||||||
|
response_model=AISaveResponse,
|
||||||
|
summary="Save AI-generated question",
|
||||||
|
description="""
|
||||||
|
Save an AI-generated question to the database.
|
||||||
|
|
||||||
|
This endpoint creates a new Item record with:
|
||||||
|
- generated_by='ai'
|
||||||
|
- ai_model from request
|
||||||
|
- basis_item_id linking to original question
|
||||||
|
- calibrated=False (will be calculated later)
|
||||||
|
""",
|
||||||
|
responses={
|
||||||
|
200: {"description": "Question saved successfully"},
|
||||||
|
400: {"description": "Invalid request data"},
|
||||||
|
404: {"description": "Basis item or tryout not found"},
|
||||||
|
409: {"description": "Item already exists at this slot/level"},
|
||||||
|
500: {"description": "Database save failed"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def generate_save(
|
||||||
|
request: AISaveRequest,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> AISaveResponse:
|
||||||
|
"""
|
||||||
|
Save AI-generated question to database.
|
||||||
|
|
||||||
|
- **stem**: Question text
|
||||||
|
- **options**: Dict with A, B, C, D options
|
||||||
|
- **correct**: Correct answer (A/B/C/D)
|
||||||
|
- **explanation**: Answer explanation (optional)
|
||||||
|
- **tryout_id**: Tryout identifier
|
||||||
|
- **website_id**: Website identifier
|
||||||
|
- **basis_item_id**: Original item ID this was generated from
|
||||||
|
- **slot**: Question slot position
|
||||||
|
- **level**: Difficulty level
|
||||||
|
- **ai_model**: AI model used for generation
|
||||||
|
"""
|
||||||
|
# Verify basis item exists
|
||||||
|
basis_result = await db.execute(
|
||||||
|
select(Item).where(Item.id == request.basis_item_id)
|
||||||
|
)
|
||||||
|
basis_item = basis_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not basis_item:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Basis item not found: {request.basis_item_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for duplicate (same tryout, website, slot, level)
|
||||||
|
existing_result = await db.execute(
|
||||||
|
select(Item).where(
|
||||||
|
and_(
|
||||||
|
Item.tryout_id == request.tryout_id,
|
||||||
|
Item.website_id == request.website_id,
|
||||||
|
Item.slot == request.slot,
|
||||||
|
Item.level == request.level,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
existing = existing_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Item already exists at slot={request.slot}, level={request.level} "
|
||||||
|
f"for tryout={request.tryout_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create GeneratedQuestion from request
|
||||||
|
from app.schemas.ai import GeneratedQuestion
|
||||||
|
|
||||||
|
generated_data = GeneratedQuestion(
|
||||||
|
stem=request.stem,
|
||||||
|
options=request.options,
|
||||||
|
correct=request.correct,
|
||||||
|
explanation=request.explanation,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save to database
|
||||||
|
item_id = await save_ai_question(
|
||||||
|
generated_data=generated_data,
|
||||||
|
tryout_id=request.tryout_id,
|
||||||
|
website_id=request.website_id,
|
||||||
|
basis_item_id=request.basis_item_id,
|
||||||
|
slot=request.slot,
|
||||||
|
level=request.level,
|
||||||
|
ai_model=request.ai_model,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not item_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to save AI-generated question",
|
||||||
|
)
|
||||||
|
|
||||||
|
return AISaveResponse(
|
||||||
|
success=True,
|
||||||
|
item_id=item_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/stats",
|
||||||
|
response_model=AIStatsResponse,
|
||||||
|
summary="Get AI generation statistics",
|
||||||
|
description="""
|
||||||
|
Get statistics about AI-generated questions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- Total AI-generated items count
|
||||||
|
- Items count by model
|
||||||
|
- Cache hit rate (placeholder)
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
async def get_stats(
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> AIStatsResponse:
|
||||||
|
"""
|
||||||
|
Get AI generation statistics.
|
||||||
|
"""
|
||||||
|
stats = await get_ai_stats(db)
|
||||||
|
|
||||||
|
return AIStatsResponse(
|
||||||
|
total_ai_items=stats["total_ai_items"],
|
||||||
|
items_by_model=stats["items_by_model"],
|
||||||
|
cache_hit_rate=stats["cache_hit_rate"],
|
||||||
|
total_cache_hits=stats["total_cache_hits"],
|
||||||
|
total_requests=stats["total_requests"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/models",
|
||||||
|
summary="List supported AI models",
|
||||||
|
description="Returns list of supported AI models for question generation.",
|
||||||
|
)
|
||||||
|
async def list_models() -> dict:
|
||||||
|
"""
|
||||||
|
List supported AI models.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
|
"id": "qwen/qwen-2.5-coder-32b-instruct",
|
||||||
|
"name": "Qwen 2.5 Coder 32B",
|
||||||
|
"description": "Fast and efficient model for question generation",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "meta-llama/llama-3.3-70b-instruct",
|
||||||
|
"name": "Llama 3.3 70B",
|
||||||
|
"description": "High-quality model with better reasoning",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
324
app/routers/import_export.py
Normal file
324
app/routers/import_export.py
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
"""
|
||||||
|
Import/Export API router for Excel question migration.
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
- POST /api/v1/import/preview: Preview Excel import without saving
|
||||||
|
- POST /api/v1/import/questions: Import questions from Excel to database
|
||||||
|
- GET /api/v1/export/questions: Export questions to Excel file
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, Header, HTTPException, UploadFile, status
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.services.excel_import import (
|
||||||
|
bulk_insert_items,
|
||||||
|
export_questions_to_excel,
|
||||||
|
parse_excel_import,
|
||||||
|
validate_excel_structure,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/import-export", tags=["import-export"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_website_id_from_header(
|
||||||
|
x_website_id: Optional[str] = Header(None, alias="X-Website-ID"),
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Extract and validate website_id from request header.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
x_website_id: Website ID from header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Validated website ID as integer
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If header is missing or invalid
|
||||||
|
"""
|
||||||
|
if x_website_id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="X-Website-ID header is required",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return int(x_website_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="X-Website-ID must be a valid integer",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/preview",
|
||||||
|
summary="Preview Excel import",
|
||||||
|
description="Parse Excel file and return preview without saving to database.",
|
||||||
|
)
|
||||||
|
async def preview_import(
|
||||||
|
file: UploadFile = File(..., description="Excel file (.xlsx)"),
|
||||||
|
website_id: int = Depends(get_website_id_from_header),
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Preview Excel import without saving to database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file: Excel file upload (.xlsx format)
|
||||||
|
website_id: Website ID from header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with:
|
||||||
|
- items_count: Number of items parsed
|
||||||
|
- preview: List of item previews
|
||||||
|
- validation_errors: List of validation errors if any
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If file format is invalid or parsing fails
|
||||||
|
"""
|
||||||
|
# Validate file format
|
||||||
|
if not file.filename or not file.filename.lower().endswith('.xlsx'):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="File must be .xlsx format",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save uploaded file to temporary location
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx") as temp_file:
|
||||||
|
content = await file.read()
|
||||||
|
temp_file.write(content)
|
||||||
|
temp_file_path = temp_file.name
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to save uploaded file: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Validate Excel structure
|
||||||
|
validation = validate_excel_structure(temp_file_path)
|
||||||
|
if not validation["valid"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={
|
||||||
|
"error": "Invalid Excel structure",
|
||||||
|
"validation_errors": validation["errors"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse Excel (tryout_id is optional for preview)
|
||||||
|
tryout_id = "preview" # Use dummy tryout_id for preview
|
||||||
|
result = parse_excel_import(
|
||||||
|
temp_file_path,
|
||||||
|
website_id=website_id,
|
||||||
|
tryout_id=tryout_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if result["validation_errors"]:
|
||||||
|
return {
|
||||||
|
"items_count": result["items_count"],
|
||||||
|
"preview": result["items"],
|
||||||
|
"validation_errors": result["validation_errors"],
|
||||||
|
"has_errors": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Return limited preview (first 5 items)
|
||||||
|
preview_items = result["items"][:5]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items_count": result["items_count"],
|
||||||
|
"preview": preview_items,
|
||||||
|
"validation_errors": [],
|
||||||
|
"has_errors": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Clean up temporary file
|
||||||
|
if os.path.exists(temp_file_path):
|
||||||
|
os.unlink(temp_file_path)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/questions",
|
||||||
|
summary="Import questions from Excel",
|
||||||
|
description="Parse Excel file and import questions to database with 100% data integrity.",
|
||||||
|
)
|
||||||
|
async def import_questions(
|
||||||
|
file: UploadFile = File(..., description="Excel file (.xlsx)"),
|
||||||
|
website_id: int = Depends(get_website_id_from_header),
|
||||||
|
tryout_id: str = Form(..., description="Tryout identifier"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Import questions from Excel to database.
|
||||||
|
|
||||||
|
Validates file format, parses Excel content, checks for duplicates,
|
||||||
|
and performs bulk insert with rollback on error.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file: Excel file upload (.xlsx format)
|
||||||
|
website_id: Website ID from header
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
db: Async database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with:
|
||||||
|
- imported: Number of items successfully imported
|
||||||
|
- duplicates: Number of duplicate items skipped
|
||||||
|
- errors: List of errors if any
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If file format is invalid, validation fails, or import fails
|
||||||
|
"""
|
||||||
|
# Validate file format
|
||||||
|
if not file.filename or not file.filename.lower().endswith('.xlsx'):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="File must be .xlsx format",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save uploaded file to temporary location
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx") as temp_file:
|
||||||
|
content = await file.read()
|
||||||
|
temp_file.write(content)
|
||||||
|
temp_file_path = temp_file.name
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to save uploaded file: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Validate Excel structure
|
||||||
|
validation = validate_excel_structure(temp_file_path)
|
||||||
|
if not validation["valid"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={
|
||||||
|
"error": "Invalid Excel structure",
|
||||||
|
"validation_errors": validation["errors"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse Excel
|
||||||
|
result = parse_excel_import(
|
||||||
|
temp_file_path,
|
||||||
|
website_id=website_id,
|
||||||
|
tryout_id=tryout_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for validation errors
|
||||||
|
if result["validation_errors"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={
|
||||||
|
"error": "Validation failed",
|
||||||
|
"validation_errors": result["validation_errors"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if items were parsed
|
||||||
|
if result["items_count"] == 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="No items found in Excel file",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Bulk insert items
|
||||||
|
insert_result = await bulk_insert_items(result["items"], db)
|
||||||
|
|
||||||
|
# Check for insertion errors
|
||||||
|
if insert_result["errors"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail={
|
||||||
|
"error": "Import failed",
|
||||||
|
"errors": insert_result["errors"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for conflicts (duplicates)
|
||||||
|
if insert_result["duplicate_count"] > 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail={
|
||||||
|
"message": f"Import completed with {insert_result['duplicate_count']} duplicate(s) skipped",
|
||||||
|
"imported": insert_result["inserted_count"],
|
||||||
|
"duplicates": insert_result["duplicate_count"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Import successful",
|
||||||
|
"imported": insert_result["inserted_count"],
|
||||||
|
"duplicates": insert_result["duplicate_count"],
|
||||||
|
}
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Clean up temporary file
|
||||||
|
if os.path.exists(temp_file_path):
|
||||||
|
os.unlink(temp_file_path)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/export/questions",
|
||||||
|
summary="Export questions to Excel",
|
||||||
|
description="Export questions for a tryout to Excel file in standardized format.",
|
||||||
|
)
|
||||||
|
async def export_questions(
|
||||||
|
tryout_id: str,
|
||||||
|
website_id: int = Depends(get_website_id_from_header),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> FileResponse:
|
||||||
|
"""
|
||||||
|
Export questions to Excel file.
|
||||||
|
|
||||||
|
Creates Excel file with standardized format:
|
||||||
|
- Row 2: KUNCI (answer key)
|
||||||
|
- Row 4: TK (p-values)
|
||||||
|
- Row 5: BOBOT (weights)
|
||||||
|
- Rows 6+: Question data
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
website_id: Website ID from header
|
||||||
|
db: Async database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
FileResponse with Excel file
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If tryout has no questions or export fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Export questions to Excel
|
||||||
|
output_path = await export_questions_to_excel(
|
||||||
|
tryout_id=tryout_id,
|
||||||
|
website_id=website_id,
|
||||||
|
db=db
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return file for download
|
||||||
|
filename = f"tryout_{tryout_id}_questions.xlsx"
|
||||||
|
return FileResponse(
|
||||||
|
path=output_path,
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
filename=filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=str(e),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Export failed: {str(e)}",
|
||||||
|
)
|
||||||
279
app/routers/normalization.py
Normal file
279
app/routers/normalization.py
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
"""
|
||||||
|
Normalization API router for dynamic normalization management.
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
- GET /tryout/{tryout_id}/normalization: Get normalization configuration
|
||||||
|
- PUT /tryout/{tryout_id}/normalization: Update normalization settings
|
||||||
|
- POST /tryout/{tryout_id}/normalization/reset: Reset normalization stats
|
||||||
|
- GET /tryout/{tryout_id}/normalization/validate: Validate dynamic normalization
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Header, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.services.config_management import (
|
||||||
|
get_normalization_config,
|
||||||
|
reset_normalization_stats,
|
||||||
|
toggle_normalization_mode,
|
||||||
|
update_config,
|
||||||
|
)
|
||||||
|
from app.services.normalization import (
|
||||||
|
validate_dynamic_normalization,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/tryout", tags=["normalization"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_website_id_from_header(
|
||||||
|
x_website_id: Optional[str] = Header(None, alias="X-Website-ID"),
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Extract and validate website_id from request header.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
x_website_id: Website ID from header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Validated website ID as integer
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If header is missing or invalid
|
||||||
|
"""
|
||||||
|
if x_website_id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="X-Website-ID header is required",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return int(x_website_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="X-Website-ID must be a valid integer",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{tryout_id}/normalization",
|
||||||
|
summary="Get normalization configuration",
|
||||||
|
description="Retrieve current normalization configuration including mode, static values, dynamic values, and threshold status.",
|
||||||
|
)
|
||||||
|
async def get_normalization_endpoint(
|
||||||
|
tryout_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
website_id: int = Depends(get_website_id_from_header),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get normalization configuration for a tryout.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Normalization configuration with:
|
||||||
|
- mode (static/dynamic/hybrid)
|
||||||
|
- current rataan, sb (from TryoutStats)
|
||||||
|
- static_rataan, static_sb (from Tryout config)
|
||||||
|
- participant_count
|
||||||
|
- threshold_status (ready for dynamic or not)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If tryout not found
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
config = await get_normalization_config(db, website_id, tryout_id)
|
||||||
|
return config
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/{tryout_id}/normalization",
|
||||||
|
summary="Update normalization settings",
|
||||||
|
description="Update normalization mode and static values for a tryout.",
|
||||||
|
)
|
||||||
|
async def update_normalization_endpoint(
|
||||||
|
tryout_id: str,
|
||||||
|
normalization_mode: Optional[str] = None,
|
||||||
|
static_rataan: Optional[float] = None,
|
||||||
|
static_sb: Optional[float] = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
website_id: int = Depends(get_website_id_from_header),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update normalization settings for a tryout.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
normalization_mode: New normalization mode (static/dynamic/hybrid)
|
||||||
|
static_rataan: New static mean value
|
||||||
|
static_sb: New static standard deviation
|
||||||
|
db: Database session
|
||||||
|
website_id: Website ID from header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated normalization configuration
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If tryout not found or validation fails
|
||||||
|
"""
|
||||||
|
# Build updates dictionary
|
||||||
|
updates = {}
|
||||||
|
|
||||||
|
if normalization_mode is not None:
|
||||||
|
if normalization_mode not in ["static", "dynamic", "hybrid"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid normalization_mode: {normalization_mode}. Must be 'static', 'dynamic', or 'hybrid'",
|
||||||
|
)
|
||||||
|
updates["normalization_mode"] = normalization_mode
|
||||||
|
|
||||||
|
if static_rataan is not None:
|
||||||
|
if static_rataan <= 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="static_rataan must be greater than 0",
|
||||||
|
)
|
||||||
|
updates["static_rataan"] = static_rataan
|
||||||
|
|
||||||
|
if static_sb is not None:
|
||||||
|
if static_sb <= 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="static_sb must be greater than 0",
|
||||||
|
)
|
||||||
|
updates["static_sb"] = static_sb
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="No updates provided",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Update configuration
|
||||||
|
await update_config(db, website_id, tryout_id, updates)
|
||||||
|
|
||||||
|
# Get updated configuration
|
||||||
|
config = await get_normalization_config(db, website_id, tryout_id)
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{tryout_id}/normalization/reset",
|
||||||
|
summary="Reset normalization stats",
|
||||||
|
description="Reset TryoutStats to initial values and switch to static normalization mode.",
|
||||||
|
)
|
||||||
|
async def reset_normalization_endpoint(
|
||||||
|
tryout_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
website_id: int = Depends(get_website_id_from_header),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Reset normalization stats for a tryout.
|
||||||
|
|
||||||
|
Resets TryoutStats to initial values (participant_count=0, sums cleared)
|
||||||
|
and temporarily switches normalization_mode to "static".
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
db: Database session
|
||||||
|
website_id: Website ID from header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success message with updated configuration
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If tryout not found
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
stats = await reset_normalization_stats(db, website_id, tryout_id)
|
||||||
|
config = await get_normalization_config(db, website_id, tryout_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Normalization stats reset successfully",
|
||||||
|
"tryout_id": tryout_id,
|
||||||
|
"participant_count": stats.participant_count,
|
||||||
|
"normalization_mode": config["normalization_mode"],
|
||||||
|
}
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{tryout_id}/normalization/validate",
|
||||||
|
summary="Validate dynamic normalization",
|
||||||
|
description="Validate that dynamic normalization produces expected distribution (mean≈500±5, SD≈100±5).",
|
||||||
|
)
|
||||||
|
async def validate_normalization_endpoint(
|
||||||
|
tryout_id: str,
|
||||||
|
target_mean: float = 500.0,
|
||||||
|
target_sd: float = 100.0,
|
||||||
|
mean_tolerance: float = 5.0,
|
||||||
|
sd_tolerance: float = 5.0,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
website_id: int = Depends(get_website_id_from_header),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Validate dynamic normalization for a tryout.
|
||||||
|
|
||||||
|
Checks if calculated rataan and sb are close to target values.
|
||||||
|
Returns validation status, deviations, warnings, and suggestions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
target_mean: Target mean (default: 500)
|
||||||
|
target_sd: Target standard deviation (default: 100)
|
||||||
|
mean_tolerance: Allowed deviation from target mean (default: 5)
|
||||||
|
sd_tolerance: Allowed deviation from target SD (default: 5)
|
||||||
|
db: Database session
|
||||||
|
website_id: Website ID from header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Validation result with:
|
||||||
|
- is_valid: True if within tolerance
|
||||||
|
- details: Full validation details
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If tryout not found
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
is_valid, details = await validate_dynamic_normalization(
|
||||||
|
db=db,
|
||||||
|
website_id=website_id,
|
||||||
|
tryout_id=tryout_id,
|
||||||
|
target_mean=target_mean,
|
||||||
|
target_sd=target_sd,
|
||||||
|
mean_tolerance=mean_tolerance,
|
||||||
|
sd_tolerance=sd_tolerance,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tryout_id": tryout_id,
|
||||||
|
"is_valid": is_valid,
|
||||||
|
"target_mean": target_mean,
|
||||||
|
"target_sd": target_sd,
|
||||||
|
"mean_tolerance": mean_tolerance,
|
||||||
|
"sd_tolerance": sd_tolerance,
|
||||||
|
"details": details,
|
||||||
|
}
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=str(e),
|
||||||
|
)
|
||||||
792
app/routers/reports.py
Normal file
792
app/routers/reports.py
Normal file
@@ -0,0 +1,792 @@
|
|||||||
|
"""
|
||||||
|
Reports API router for comprehensive reporting.
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
- GET /reports/student/performance: Get student performance report
|
||||||
|
- GET /reports/items/analysis: Get item analysis report
|
||||||
|
- GET /reports/calibration/status: Get calibration status report
|
||||||
|
- GET /reports/tryout/comparison: Get tryout comparison report
|
||||||
|
- POST /reports/schedule: Schedule a report
|
||||||
|
- GET /reports/export/{schedule_id}/{format}: Export scheduled report
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Literal, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Header, status
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.schemas.report import (
|
||||||
|
StudentPerformanceReportOutput,
|
||||||
|
AggregatePerformanceStatsOutput,
|
||||||
|
StudentPerformanceRecordOutput,
|
||||||
|
ItemAnalysisReportOutput,
|
||||||
|
ItemAnalysisRecordOutput,
|
||||||
|
CalibrationStatusReportOutput,
|
||||||
|
CalibrationItemStatusOutput,
|
||||||
|
TryoutComparisonReportOutput,
|
||||||
|
TryoutComparisonRecordOutput,
|
||||||
|
ReportScheduleRequest,
|
||||||
|
ReportScheduleOutput,
|
||||||
|
ReportScheduleResponse,
|
||||||
|
ExportResponse,
|
||||||
|
)
|
||||||
|
from app.services.reporting import (
|
||||||
|
generate_student_performance_report,
|
||||||
|
generate_item_analysis_report,
|
||||||
|
generate_calibration_status_report,
|
||||||
|
generate_tryout_comparison_report,
|
||||||
|
export_report_to_csv,
|
||||||
|
export_report_to_excel,
|
||||||
|
export_report_to_pdf,
|
||||||
|
schedule_report,
|
||||||
|
get_scheduled_report,
|
||||||
|
list_scheduled_reports,
|
||||||
|
cancel_scheduled_report,
|
||||||
|
StudentPerformanceReport,
|
||||||
|
ItemAnalysisReport,
|
||||||
|
CalibrationStatusReport,
|
||||||
|
TryoutComparisonReport,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/reports", tags=["reports"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_website_id_from_header(
|
||||||
|
x_website_id: Optional[str] = Header(None, alias="X-Website-ID"),
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Extract and validate website_id from request header.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
x_website_id: Website ID from header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Validated website ID as integer
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If header is missing or invalid
|
||||||
|
"""
|
||||||
|
if x_website_id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="X-Website-ID header is required",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return int(x_website_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="X-Website-ID must be a valid integer",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Student Performance Report Endpoints
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/student/performance",
|
||||||
|
response_model=StudentPerformanceReportOutput,
|
||||||
|
summary="Get student performance report",
|
||||||
|
description="Generate student performance report with individual and aggregate statistics.",
|
||||||
|
)
|
||||||
|
async def get_student_performance_report(
|
||||||
|
tryout_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
website_id: int = Depends(get_website_id_from_header),
|
||||||
|
date_start: Optional[datetime] = None,
|
||||||
|
date_end: Optional[datetime] = None,
|
||||||
|
format_type: Literal["individual", "aggregate", "both"] = "both",
|
||||||
|
) -> StudentPerformanceReportOutput:
|
||||||
|
"""
|
||||||
|
Get student performance report.
|
||||||
|
|
||||||
|
Returns individual student records and/or aggregate statistics.
|
||||||
|
"""
|
||||||
|
date_range = None
|
||||||
|
if date_start or date_end:
|
||||||
|
date_range = {}
|
||||||
|
if date_start:
|
||||||
|
date_range["start"] = date_start
|
||||||
|
if date_end:
|
||||||
|
date_range["end"] = date_end
|
||||||
|
|
||||||
|
report = await generate_student_performance_report(
|
||||||
|
tryout_id=tryout_id,
|
||||||
|
website_id=website_id,
|
||||||
|
db=db,
|
||||||
|
date_range=date_range,
|
||||||
|
format_type=format_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
return _convert_student_performance_report(report)
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_student_performance_report(report: StudentPerformanceReport) -> StudentPerformanceReportOutput:
|
||||||
|
"""Convert dataclass report to Pydantic output."""
|
||||||
|
date_range_str = None
|
||||||
|
if report.date_range:
|
||||||
|
date_range_str = {}
|
||||||
|
if report.date_range.get("start"):
|
||||||
|
date_range_str["start"] = report.date_range["start"].isoformat()
|
||||||
|
if report.date_range.get("end"):
|
||||||
|
date_range_str["end"] = report.date_range["end"].isoformat()
|
||||||
|
|
||||||
|
return StudentPerformanceReportOutput(
|
||||||
|
generated_at=report.generated_at,
|
||||||
|
tryout_id=report.tryout_id,
|
||||||
|
website_id=report.website_id,
|
||||||
|
date_range=date_range_str,
|
||||||
|
aggregate=AggregatePerformanceStatsOutput(
|
||||||
|
tryout_id=report.aggregate.tryout_id,
|
||||||
|
participant_count=report.aggregate.participant_count,
|
||||||
|
avg_nm=report.aggregate.avg_nm,
|
||||||
|
std_nm=report.aggregate.std_nm,
|
||||||
|
min_nm=report.aggregate.min_nm,
|
||||||
|
max_nm=report.aggregate.max_nm,
|
||||||
|
median_nm=report.aggregate.median_nm,
|
||||||
|
avg_nn=report.aggregate.avg_nn,
|
||||||
|
std_nn=report.aggregate.std_nn,
|
||||||
|
avg_theta=report.aggregate.avg_theta,
|
||||||
|
pass_rate=report.aggregate.pass_rate,
|
||||||
|
avg_time_spent=report.aggregate.avg_time_spent,
|
||||||
|
),
|
||||||
|
individual_records=[
|
||||||
|
StudentPerformanceRecordOutput(
|
||||||
|
session_id=r.session_id,
|
||||||
|
wp_user_id=r.wp_user_id,
|
||||||
|
tryout_id=r.tryout_id,
|
||||||
|
NM=r.NM,
|
||||||
|
NN=r.NN,
|
||||||
|
theta=r.theta,
|
||||||
|
theta_se=r.theta_se,
|
||||||
|
total_benar=r.total_benar,
|
||||||
|
time_spent=r.time_spent,
|
||||||
|
start_time=r.start_time,
|
||||||
|
end_time=r.end_time,
|
||||||
|
scoring_mode_used=r.scoring_mode_used,
|
||||||
|
rataan_used=r.rataan_used,
|
||||||
|
sb_used=r.sb_used,
|
||||||
|
)
|
||||||
|
for r in report.individual_records
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Item Analysis Report Endpoints
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/items/analysis",
|
||||||
|
response_model=ItemAnalysisReportOutput,
|
||||||
|
summary="Get item analysis report",
|
||||||
|
description="Generate item analysis report with difficulty, discrimination, and information functions.",
|
||||||
|
)
|
||||||
|
async def get_item_analysis_report(
|
||||||
|
tryout_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
website_id: int = Depends(get_website_id_from_header),
|
||||||
|
filter_by: Optional[Literal["difficulty", "calibrated", "discrimination"]] = None,
|
||||||
|
difficulty_level: Optional[Literal["mudah", "sedang", "sulit"]] = None,
|
||||||
|
) -> ItemAnalysisReportOutput:
|
||||||
|
"""
|
||||||
|
Get item analysis report.
|
||||||
|
|
||||||
|
Returns item difficulty, discrimination, and information function data.
|
||||||
|
"""
|
||||||
|
report = await generate_item_analysis_report(
|
||||||
|
tryout_id=tryout_id,
|
||||||
|
website_id=website_id,
|
||||||
|
db=db,
|
||||||
|
filter_by=filter_by,
|
||||||
|
difficulty_level=difficulty_level,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ItemAnalysisReportOutput(
|
||||||
|
generated_at=report.generated_at,
|
||||||
|
tryout_id=report.tryout_id,
|
||||||
|
website_id=report.website_id,
|
||||||
|
total_items=report.total_items,
|
||||||
|
items=[
|
||||||
|
ItemAnalysisRecordOutput(
|
||||||
|
item_id=r.item_id,
|
||||||
|
slot=r.slot,
|
||||||
|
level=r.level,
|
||||||
|
ctt_p=r.ctt_p,
|
||||||
|
ctt_bobot=r.ctt_bobot,
|
||||||
|
ctt_category=r.ctt_category,
|
||||||
|
irt_b=r.irt_b,
|
||||||
|
irt_se=r.irt_se,
|
||||||
|
calibrated=r.calibrated,
|
||||||
|
calibration_sample_size=r.calibration_sample_size,
|
||||||
|
correctness_rate=r.correctness_rate,
|
||||||
|
item_total_correlation=r.item_total_correlation,
|
||||||
|
information_values=r.information_values,
|
||||||
|
optimal_theta_range=r.optimal_theta_range,
|
||||||
|
)
|
||||||
|
for r in report.items
|
||||||
|
],
|
||||||
|
summary=report.summary,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Calibration Status Report Endpoints
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/calibration/status",
|
||||||
|
response_model=CalibrationStatusReportOutput,
|
||||||
|
summary="Get calibration status report",
|
||||||
|
description="Generate calibration status report with progress tracking and readiness metrics.",
|
||||||
|
)
|
||||||
|
async def get_calibration_status_report(
|
||||||
|
tryout_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
website_id: int = Depends(get_website_id_from_header),
|
||||||
|
) -> CalibrationStatusReportOutput:
|
||||||
|
"""
|
||||||
|
Get calibration status report.
|
||||||
|
|
||||||
|
Returns calibration progress, items awaiting calibration, and IRT readiness status.
|
||||||
|
"""
|
||||||
|
report = await generate_calibration_status_report(
|
||||||
|
tryout_id=tryout_id,
|
||||||
|
website_id=website_id,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
|
||||||
|
return CalibrationStatusReportOutput(
|
||||||
|
generated_at=report.generated_at,
|
||||||
|
tryout_id=report.tryout_id,
|
||||||
|
website_id=report.website_id,
|
||||||
|
total_items=report.total_items,
|
||||||
|
calibrated_items=report.calibrated_items,
|
||||||
|
calibration_percentage=report.calibration_percentage,
|
||||||
|
items_awaiting_calibration=[
|
||||||
|
CalibrationItemStatusOutput(
|
||||||
|
item_id=r.item_id,
|
||||||
|
slot=r.slot,
|
||||||
|
level=r.level,
|
||||||
|
sample_size=r.sample_size,
|
||||||
|
calibrated=r.calibrated,
|
||||||
|
irt_b=r.irt_b,
|
||||||
|
irt_se=r.irt_se,
|
||||||
|
ctt_p=r.ctt_p,
|
||||||
|
)
|
||||||
|
for r in report.items_awaiting_calibration
|
||||||
|
],
|
||||||
|
avg_calibration_sample_size=report.avg_calibration_sample_size,
|
||||||
|
estimated_time_to_90_percent=report.estimated_time_to_90_percent,
|
||||||
|
ready_for_irt_rollout=report.ready_for_irt_rollout,
|
||||||
|
items=[
|
||||||
|
CalibrationItemStatusOutput(
|
||||||
|
item_id=r.item_id,
|
||||||
|
slot=r.slot,
|
||||||
|
level=r.level,
|
||||||
|
sample_size=r.sample_size,
|
||||||
|
calibrated=r.calibrated,
|
||||||
|
irt_b=r.irt_b,
|
||||||
|
irt_se=r.irt_se,
|
||||||
|
ctt_p=r.ctt_p,
|
||||||
|
)
|
||||||
|
for r in report.items
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Tryout Comparison Report Endpoints
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/tryout/comparison",
|
||||||
|
response_model=TryoutComparisonReportOutput,
|
||||||
|
summary="Get tryout comparison report",
|
||||||
|
description="Generate tryout comparison report across dates or subjects.",
|
||||||
|
)
|
||||||
|
async def get_tryout_comparison_report(
|
||||||
|
tryout_ids: str, # Comma-separated list
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
website_id: int = Depends(get_website_id_from_header),
|
||||||
|
group_by: Literal["date", "subject"] = "date",
|
||||||
|
) -> TryoutComparisonReportOutput:
|
||||||
|
"""
|
||||||
|
Get tryout comparison report.
|
||||||
|
|
||||||
|
Compares tryouts across dates or subjects.
|
||||||
|
"""
|
||||||
|
tryout_id_list = [tid.strip() for tid in tryout_ids.split(",")]
|
||||||
|
|
||||||
|
if len(tryout_id_list) < 2:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="At least 2 tryout IDs are required for comparison",
|
||||||
|
)
|
||||||
|
|
||||||
|
report = await generate_tryout_comparison_report(
|
||||||
|
tryout_ids=tryout_id_list,
|
||||||
|
website_id=website_id,
|
||||||
|
db=db,
|
||||||
|
group_by=group_by,
|
||||||
|
)
|
||||||
|
|
||||||
|
return TryoutComparisonReportOutput(
|
||||||
|
generated_at=report.generated_at,
|
||||||
|
comparison_type=report.comparison_type,
|
||||||
|
tryouts=[
|
||||||
|
TryoutComparisonRecordOutput(
|
||||||
|
tryout_id=r.tryout_id,
|
||||||
|
date=r.date,
|
||||||
|
subject=r.subject,
|
||||||
|
participant_count=r.participant_count,
|
||||||
|
avg_nm=r.avg_nm,
|
||||||
|
avg_nn=r.avg_nn,
|
||||||
|
avg_theta=r.avg_theta,
|
||||||
|
std_nm=r.std_nm,
|
||||||
|
calibration_percentage=r.calibration_percentage,
|
||||||
|
)
|
||||||
|
for r in report.tryouts
|
||||||
|
],
|
||||||
|
trends=report.trends,
|
||||||
|
normalization_impact=report.normalization_impact,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Report Scheduling Endpoints
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/schedule",
|
||||||
|
response_model=ReportScheduleResponse,
|
||||||
|
summary="Schedule a report",
|
||||||
|
description="Schedule a report for automatic generation on a daily, weekly, or monthly basis.",
|
||||||
|
)
|
||||||
|
async def create_report_schedule(
|
||||||
|
request: ReportScheduleRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ReportScheduleResponse:
|
||||||
|
"""
|
||||||
|
Schedule a report.
|
||||||
|
|
||||||
|
Creates a scheduled report that will be generated automatically.
|
||||||
|
"""
|
||||||
|
schedule_id = schedule_report(
|
||||||
|
report_type=request.report_type,
|
||||||
|
schedule=request.schedule,
|
||||||
|
tryout_ids=request.tryout_ids,
|
||||||
|
website_id=request.website_id,
|
||||||
|
recipients=request.recipients,
|
||||||
|
export_format=request.export_format,
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduled = get_scheduled_report(schedule_id)
|
||||||
|
|
||||||
|
return ReportScheduleResponse(
|
||||||
|
schedule_id=schedule_id,
|
||||||
|
message=f"Report scheduled successfully for {request.schedule} generation",
|
||||||
|
next_run=scheduled.next_run if scheduled else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/schedule/{schedule_id}",
|
||||||
|
response_model=ReportScheduleOutput,
|
||||||
|
summary="Get scheduled report details",
|
||||||
|
description="Get details of a scheduled report.",
|
||||||
|
)
|
||||||
|
async def get_scheduled_report_details(
|
||||||
|
schedule_id: str,
|
||||||
|
website_id: int = Depends(get_website_id_from_header),
|
||||||
|
) -> ReportScheduleOutput:
|
||||||
|
"""
|
||||||
|
Get scheduled report details.
|
||||||
|
|
||||||
|
Returns the configuration and status of a scheduled report.
|
||||||
|
"""
|
||||||
|
scheduled = get_scheduled_report(schedule_id)
|
||||||
|
|
||||||
|
if not scheduled:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Scheduled report {schedule_id} not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
if scheduled.website_id != website_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Access denied to this scheduled report",
|
||||||
|
)
|
||||||
|
|
||||||
|
return ReportScheduleOutput(
|
||||||
|
schedule_id=scheduled.schedule_id,
|
||||||
|
report_type=scheduled.report_type,
|
||||||
|
schedule=scheduled.schedule,
|
||||||
|
tryout_ids=scheduled.tryout_ids,
|
||||||
|
website_id=scheduled.website_id,
|
||||||
|
recipients=scheduled.recipients,
|
||||||
|
format=scheduled.format,
|
||||||
|
created_at=scheduled.created_at,
|
||||||
|
last_run=scheduled.last_run,
|
||||||
|
next_run=scheduled.next_run,
|
||||||
|
is_active=scheduled.is_active,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/schedule",
|
||||||
|
response_model=List[ReportScheduleOutput],
|
||||||
|
summary="List scheduled reports",
|
||||||
|
description="List all scheduled reports for a website.",
|
||||||
|
)
|
||||||
|
async def list_scheduled_reports_endpoint(
|
||||||
|
website_id: int = Depends(get_website_id_from_header),
|
||||||
|
) -> List[ReportScheduleOutput]:
|
||||||
|
"""
|
||||||
|
List all scheduled reports.
|
||||||
|
|
||||||
|
Returns all scheduled reports for the current website.
|
||||||
|
"""
|
||||||
|
reports = list_scheduled_reports(website_id=website_id)
|
||||||
|
|
||||||
|
return [
|
||||||
|
ReportScheduleOutput(
|
||||||
|
schedule_id=r.schedule_id,
|
||||||
|
report_type=r.report_type,
|
||||||
|
schedule=r.schedule,
|
||||||
|
tryout_ids=r.tryout_ids,
|
||||||
|
website_id=r.website_id,
|
||||||
|
recipients=r.recipients,
|
||||||
|
format=r.format,
|
||||||
|
created_at=r.created_at,
|
||||||
|
last_run=r.last_run,
|
||||||
|
next_run=r.next_run,
|
||||||
|
is_active=r.is_active,
|
||||||
|
)
|
||||||
|
for r in reports
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/schedule/{schedule_id}",
|
||||||
|
summary="Cancel scheduled report",
|
||||||
|
description="Cancel a scheduled report.",
|
||||||
|
)
|
||||||
|
async def cancel_scheduled_report_endpoint(
|
||||||
|
schedule_id: str,
|
||||||
|
website_id: int = Depends(get_website_id_from_header),
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Cancel a scheduled report.
|
||||||
|
|
||||||
|
Removes the scheduled report from the system.
|
||||||
|
"""
|
||||||
|
scheduled = get_scheduled_report(schedule_id)
|
||||||
|
|
||||||
|
if not scheduled:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Scheduled report {schedule_id} not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
if scheduled.website_id != website_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Access denied to this scheduled report",
|
||||||
|
)
|
||||||
|
|
||||||
|
success = cancel_scheduled_report(schedule_id)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to cancel scheduled report",
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": f"Scheduled report {schedule_id} cancelled successfully",
|
||||||
|
"schedule_id": schedule_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Report Export Endpoints
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/export/{schedule_id}/{format}",
|
||||||
|
summary="Export scheduled report",
|
||||||
|
description="Generate and export a scheduled report in the specified format.",
|
||||||
|
)
|
||||||
|
async def export_scheduled_report(
|
||||||
|
schedule_id: str,
|
||||||
|
format: Literal["csv", "xlsx", "pdf"],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
website_id: int = Depends(get_website_id_from_header),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Export a scheduled report.
|
||||||
|
|
||||||
|
Generates the report and returns it as a file download.
|
||||||
|
"""
|
||||||
|
scheduled = get_scheduled_report(schedule_id)
|
||||||
|
|
||||||
|
if not scheduled:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Scheduled report {schedule_id} not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
if scheduled.website_id != website_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Access denied to this scheduled report",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate report based on type
|
||||||
|
report = None
|
||||||
|
base_filename = f"report_{scheduled.report_type}_{schedule_id}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
if scheduled.report_type == "student_performance":
|
||||||
|
if len(scheduled.tryout_ids) > 0:
|
||||||
|
report = await generate_student_performance_report(
|
||||||
|
tryout_id=scheduled.tryout_ids[0],
|
||||||
|
website_id=website_id,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
elif scheduled.report_type == "item_analysis":
|
||||||
|
if len(scheduled.tryout_ids) > 0:
|
||||||
|
report = await generate_item_analysis_report(
|
||||||
|
tryout_id=scheduled.tryout_ids[0],
|
||||||
|
website_id=website_id,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
elif scheduled.report_type == "calibration_status":
|
||||||
|
if len(scheduled.tryout_ids) > 0:
|
||||||
|
report = await generate_calibration_status_report(
|
||||||
|
tryout_id=scheduled.tryout_ids[0],
|
||||||
|
website_id=website_id,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
elif scheduled.report_type == "tryout_comparison":
|
||||||
|
report = await generate_tryout_comparison_report(
|
||||||
|
tryout_ids=scheduled.tryout_ids,
|
||||||
|
website_id=website_id,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not report:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to generate report",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Export to requested format
|
||||||
|
if format == "csv":
|
||||||
|
file_path = export_report_to_csv(report, base_filename)
|
||||||
|
media_type = "text/csv"
|
||||||
|
elif format == "xlsx":
|
||||||
|
file_path = export_report_to_excel(report, base_filename)
|
||||||
|
media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
else: # pdf
|
||||||
|
file_path = export_report_to_pdf(report, base_filename)
|
||||||
|
media_type = "application/pdf"
|
||||||
|
|
||||||
|
# Return file
|
||||||
|
return FileResponse(
|
||||||
|
path=file_path,
|
||||||
|
media_type=media_type,
|
||||||
|
filename=os.path.basename(file_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to export report: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Direct Export Endpoints (without scheduling)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/student/performance/export/{format}",
|
||||||
|
summary="Export student performance report directly",
|
||||||
|
description="Generate and export student performance report directly without scheduling.",
|
||||||
|
)
|
||||||
|
async def export_student_performance_direct(
|
||||||
|
format: Literal["csv", "xlsx", "pdf"],
|
||||||
|
tryout_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
website_id: int = Depends(get_website_id_from_header),
|
||||||
|
date_start: Optional[datetime] = None,
|
||||||
|
date_end: Optional[datetime] = None,
|
||||||
|
):
|
||||||
|
"""Export student performance report directly."""
|
||||||
|
date_range = None
|
||||||
|
if date_start or date_end:
|
||||||
|
date_range = {}
|
||||||
|
if date_start:
|
||||||
|
date_range["start"] = date_start
|
||||||
|
if date_end:
|
||||||
|
date_range["end"] = date_end
|
||||||
|
|
||||||
|
report = await generate_student_performance_report(
|
||||||
|
tryout_id=tryout_id,
|
||||||
|
website_id=website_id,
|
||||||
|
db=db,
|
||||||
|
date_range=date_range,
|
||||||
|
)
|
||||||
|
|
||||||
|
base_filename = f"student_performance_{tryout_id}"
|
||||||
|
|
||||||
|
if format == "csv":
|
||||||
|
file_path = export_report_to_csv(report, base_filename)
|
||||||
|
media_type = "text/csv"
|
||||||
|
elif format == "xlsx":
|
||||||
|
file_path = export_report_to_excel(report, base_filename)
|
||||||
|
media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
else:
|
||||||
|
file_path = export_report_to_pdf(report, base_filename)
|
||||||
|
media_type = "application/pdf"
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
path=file_path,
|
||||||
|
media_type=media_type,
|
||||||
|
filename=os.path.basename(file_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/items/analysis/export/{format}",
|
||||||
|
summary="Export item analysis report directly",
|
||||||
|
description="Generate and export item analysis report directly without scheduling.",
|
||||||
|
)
|
||||||
|
async def export_item_analysis_direct(
|
||||||
|
format: Literal["csv", "xlsx", "pdf"],
|
||||||
|
tryout_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
website_id: int = Depends(get_website_id_from_header),
|
||||||
|
filter_by: Optional[Literal["difficulty", "calibrated", "discrimination"]] = None,
|
||||||
|
difficulty_level: Optional[Literal["mudah", "sedang", "sulit"]] = None,
|
||||||
|
):
|
||||||
|
"""Export item analysis report directly."""
|
||||||
|
report = await generate_item_analysis_report(
|
||||||
|
tryout_id=tryout_id,
|
||||||
|
website_id=website_id,
|
||||||
|
db=db,
|
||||||
|
filter_by=filter_by,
|
||||||
|
difficulty_level=difficulty_level,
|
||||||
|
)
|
||||||
|
|
||||||
|
base_filename = f"item_analysis_{tryout_id}"
|
||||||
|
|
||||||
|
if format == "csv":
|
||||||
|
file_path = export_report_to_csv(report, base_filename)
|
||||||
|
media_type = "text/csv"
|
||||||
|
elif format == "xlsx":
|
||||||
|
file_path = export_report_to_excel(report, base_filename)
|
||||||
|
media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
else:
|
||||||
|
file_path = export_report_to_pdf(report, base_filename)
|
||||||
|
media_type = "application/pdf"
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
path=file_path,
|
||||||
|
media_type=media_type,
|
||||||
|
filename=os.path.basename(file_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/calibration/status/export/{format}",
|
||||||
|
summary="Export calibration status report directly",
|
||||||
|
description="Generate and export calibration status report directly without scheduling.",
|
||||||
|
)
|
||||||
|
async def export_calibration_status_direct(
|
||||||
|
format: Literal["csv", "xlsx", "pdf"],
|
||||||
|
tryout_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
website_id: int = Depends(get_website_id_from_header),
|
||||||
|
):
|
||||||
|
"""Export calibration status report directly."""
|
||||||
|
report = await generate_calibration_status_report(
|
||||||
|
tryout_id=tryout_id,
|
||||||
|
website_id=website_id,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
|
||||||
|
base_filename = f"calibration_status_{tryout_id}"
|
||||||
|
|
||||||
|
if format == "csv":
|
||||||
|
file_path = export_report_to_csv(report, base_filename)
|
||||||
|
media_type = "text/csv"
|
||||||
|
elif format == "xlsx":
|
||||||
|
file_path = export_report_to_excel(report, base_filename)
|
||||||
|
media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
else:
|
||||||
|
file_path = export_report_to_pdf(report, base_filename)
|
||||||
|
media_type = "application/pdf"
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
path=file_path,
|
||||||
|
media_type=media_type,
|
||||||
|
filename=os.path.basename(file_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/tryout/comparison/export/{format}",
|
||||||
|
summary="Export tryout comparison report directly",
|
||||||
|
description="Generate and export tryout comparison report directly without scheduling.",
|
||||||
|
)
|
||||||
|
async def export_tryout_comparison_direct(
|
||||||
|
format: Literal["csv", "xlsx", "pdf"],
|
||||||
|
tryout_ids: str, # Comma-separated
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
website_id: int = Depends(get_website_id_from_header),
|
||||||
|
group_by: Literal["date", "subject"] = "date",
|
||||||
|
):
|
||||||
|
"""Export tryout comparison report directly."""
|
||||||
|
tryout_id_list = [tid.strip() for tid in tryout_ids.split(",")]
|
||||||
|
|
||||||
|
if len(tryout_id_list) < 2:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="At least 2 tryout IDs are required for comparison",
|
||||||
|
)
|
||||||
|
|
||||||
|
report = await generate_tryout_comparison_report(
|
||||||
|
tryout_ids=tryout_id_list,
|
||||||
|
website_id=website_id,
|
||||||
|
db=db,
|
||||||
|
group_by=group_by,
|
||||||
|
)
|
||||||
|
|
||||||
|
base_filename = "tryout_comparison"
|
||||||
|
|
||||||
|
if format == "csv":
|
||||||
|
file_path = export_report_to_csv(report, base_filename)
|
||||||
|
media_type = "text/csv"
|
||||||
|
elif format == "xlsx":
|
||||||
|
file_path = export_report_to_excel(report, base_filename)
|
||||||
|
media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
else:
|
||||||
|
file_path = export_report_to_pdf(report, base_filename)
|
||||||
|
media_type = "application/pdf"
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
path=file_path,
|
||||||
|
media_type=media_type,
|
||||||
|
filename=os.path.basename(file_path),
|
||||||
|
)
|
||||||
402
app/routers/sessions.py
Normal file
402
app/routers/sessions.py
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
"""
|
||||||
|
Session API router for tryout session management.
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
- POST /session/{session_id}/complete: Submit answers and complete session
|
||||||
|
- GET /session/{session_id}: Get session details
|
||||||
|
- POST /session: Create new session
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Header, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.item import Item
|
||||||
|
from app.models.session import Session
|
||||||
|
from app.models.tryout import Tryout
|
||||||
|
from app.models.tryout_stats import TryoutStats
|
||||||
|
from app.models.user_answer import UserAnswer
|
||||||
|
from app.schemas.session import (
|
||||||
|
SessionCompleteRequest,
|
||||||
|
SessionCompleteResponse,
|
||||||
|
SessionCreateRequest,
|
||||||
|
SessionResponse,
|
||||||
|
UserAnswerOutput,
|
||||||
|
)
|
||||||
|
from app.services.ctt_scoring import (
|
||||||
|
calculate_ctt_bobot,
|
||||||
|
calculate_ctt_nm,
|
||||||
|
calculate_ctt_nn,
|
||||||
|
get_total_bobot_max,
|
||||||
|
update_tryout_stats,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/session", tags=["sessions"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_website_id_from_header(
|
||||||
|
x_website_id: Optional[str] = Header(None, alias="X-Website-ID"),
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Extract and validate website_id from request header.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
x_website_id: Website ID from header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Validated website ID as integer
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If header is missing or invalid
|
||||||
|
"""
|
||||||
|
if x_website_id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="X-Website-ID header is required",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return int(x_website_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="X-Website-ID must be a valid integer",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{session_id}/complete",
|
||||||
|
response_model=SessionCompleteResponse,
|
||||||
|
summary="Complete session with answers",
|
||||||
|
description="Submit user answers, calculate CTT scores, and complete the session.",
|
||||||
|
)
|
||||||
|
async def complete_session(
|
||||||
|
session_id: str,
|
||||||
|
request: SessionCompleteRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
website_id: int = Depends(get_website_id_from_header),
|
||||||
|
) -> SessionCompleteResponse:
|
||||||
|
"""
|
||||||
|
Complete a session by submitting answers and calculating CTT scores.
|
||||||
|
|
||||||
|
Process:
|
||||||
|
1. Validate session exists and is not completed
|
||||||
|
2. For each answer: check is_correct, calculate bobot_earned
|
||||||
|
3. Save UserAnswer records
|
||||||
|
4. Calculate CTT scores (total_benar, total_bobot_earned, NM)
|
||||||
|
5. Update Session with CTT results
|
||||||
|
6. Update TryoutStats incrementally
|
||||||
|
7. Return session with scores
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Unique session identifier
|
||||||
|
request: Session completion request with end_time and user_answers
|
||||||
|
db: Database session
|
||||||
|
website_id: Website ID from header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SessionCompleteResponse with CTT scores
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If session not found, already completed, or validation fails
|
||||||
|
"""
|
||||||
|
# Get session with tryout relationship
|
||||||
|
result = await db.execute(
|
||||||
|
select(Session)
|
||||||
|
.options(selectinload(Session.tryout))
|
||||||
|
.where(
|
||||||
|
Session.session_id == session_id,
|
||||||
|
Session.website_id == website_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if session is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Session {session_id} not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
if session.is_completed:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Session is already completed",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get tryout configuration
|
||||||
|
tryout = session.tryout
|
||||||
|
|
||||||
|
# Get all items for this tryout to calculate bobot
|
||||||
|
items_result = await db.execute(
|
||||||
|
select(Item).where(
|
||||||
|
Item.website_id == website_id,
|
||||||
|
Item.tryout_id == session.tryout_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
items = {item.id: item for item in items_result.scalars().all()}
|
||||||
|
|
||||||
|
# Process each answer
|
||||||
|
total_benar = 0
|
||||||
|
total_bobot_earned = 0.0
|
||||||
|
user_answer_records = []
|
||||||
|
|
||||||
|
for answer_input in request.user_answers:
|
||||||
|
item = items.get(answer_input.item_id)
|
||||||
|
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Item {answer_input.item_id} not found in tryout {session.tryout_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if answer is correct
|
||||||
|
is_correct = answer_input.response.upper() == item.correct_answer.upper()
|
||||||
|
|
||||||
|
# Calculate bobot_earned (only if correct)
|
||||||
|
bobot_earned = 0.0
|
||||||
|
if is_correct:
|
||||||
|
total_benar += 1
|
||||||
|
if item.ctt_bobot is not None:
|
||||||
|
bobot_earned = item.ctt_bobot
|
||||||
|
total_bobot_earned += bobot_earned
|
||||||
|
|
||||||
|
# Create UserAnswer record
|
||||||
|
user_answer = UserAnswer(
|
||||||
|
session_id=session.session_id,
|
||||||
|
wp_user_id=session.wp_user_id,
|
||||||
|
website_id=website_id,
|
||||||
|
tryout_id=session.tryout_id,
|
||||||
|
item_id=item.id,
|
||||||
|
response=answer_input.response.upper(),
|
||||||
|
is_correct=is_correct,
|
||||||
|
time_spent=answer_input.time_spent,
|
||||||
|
scoring_mode_used=session.scoring_mode_used,
|
||||||
|
bobot_earned=bobot_earned,
|
||||||
|
)
|
||||||
|
user_answer_records.append(user_answer)
|
||||||
|
db.add(user_answer)
|
||||||
|
|
||||||
|
# Calculate total_bobot_max for NM calculation
|
||||||
|
try:
|
||||||
|
total_bobot_max = await get_total_bobot_max(
|
||||||
|
db, website_id, session.tryout_id, level="sedang"
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
# Fallback: calculate from items we have
|
||||||
|
total_bobot_max = sum(
|
||||||
|
item.ctt_bobot or 0 for item in items.values() if item.level == "sedang"
|
||||||
|
)
|
||||||
|
if total_bobot_max == 0:
|
||||||
|
# If no bobot values, use count of questions
|
||||||
|
total_bobot_max = len(items)
|
||||||
|
|
||||||
|
# Calculate CTT NM (Nilai Mentah)
|
||||||
|
nm = calculate_ctt_nm(total_bobot_earned, total_bobot_max)
|
||||||
|
|
||||||
|
# Get normalization parameters based on tryout configuration
|
||||||
|
if tryout.normalization_mode == "static":
|
||||||
|
rataan = tryout.static_rataan
|
||||||
|
sb = tryout.static_sb
|
||||||
|
elif tryout.normalization_mode == "dynamic":
|
||||||
|
# Get current stats for dynamic normalization
|
||||||
|
stats_result = await db.execute(
|
||||||
|
select(TryoutStats).where(
|
||||||
|
TryoutStats.website_id == website_id,
|
||||||
|
TryoutStats.tryout_id == session.tryout_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stats = stats_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if stats and stats.participant_count >= tryout.min_sample_for_dynamic:
|
||||||
|
rataan = stats.rataan or tryout.static_rataan
|
||||||
|
sb = stats.sb or tryout.static_sb
|
||||||
|
else:
|
||||||
|
# Not enough data, use static values
|
||||||
|
rataan = tryout.static_rataan
|
||||||
|
sb = tryout.static_sb
|
||||||
|
else: # hybrid
|
||||||
|
# Hybrid: use dynamic if enough data, otherwise static
|
||||||
|
stats_result = await db.execute(
|
||||||
|
select(TryoutStats).where(
|
||||||
|
TryoutStats.website_id == website_id,
|
||||||
|
TryoutStats.tryout_id == session.tryout_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stats = stats_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if stats and stats.participant_count >= tryout.min_sample_for_dynamic:
|
||||||
|
rataan = stats.rataan or tryout.static_rataan
|
||||||
|
sb = stats.sb or tryout.static_sb
|
||||||
|
else:
|
||||||
|
rataan = tryout.static_rataan
|
||||||
|
sb = tryout.static_sb
|
||||||
|
|
||||||
|
# Calculate CTT NN (Nilai Nasional)
|
||||||
|
nn = calculate_ctt_nn(nm, rataan, sb)
|
||||||
|
|
||||||
|
# Update session with results
|
||||||
|
session.end_time = request.end_time
|
||||||
|
session.is_completed = True
|
||||||
|
session.total_benar = total_benar
|
||||||
|
session.total_bobot_earned = total_bobot_earned
|
||||||
|
session.NM = nm
|
||||||
|
session.NN = nn
|
||||||
|
session.rataan_used = rataan
|
||||||
|
session.sb_used = sb
|
||||||
|
|
||||||
|
# Update tryout stats incrementally
|
||||||
|
await update_tryout_stats(db, website_id, session.tryout_id, nm)
|
||||||
|
|
||||||
|
# Commit all changes
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Refresh to get updated relationships
|
||||||
|
await db.refresh(session)
|
||||||
|
|
||||||
|
# Build response
|
||||||
|
return SessionCompleteResponse(
|
||||||
|
id=session.id,
|
||||||
|
session_id=session.session_id,
|
||||||
|
wp_user_id=session.wp_user_id,
|
||||||
|
website_id=session.website_id,
|
||||||
|
tryout_id=session.tryout_id,
|
||||||
|
start_time=session.start_time,
|
||||||
|
end_time=session.end_time,
|
||||||
|
is_completed=session.is_completed,
|
||||||
|
scoring_mode_used=session.scoring_mode_used,
|
||||||
|
total_benar=session.total_benar,
|
||||||
|
total_bobot_earned=session.total_bobot_earned,
|
||||||
|
NM=session.NM,
|
||||||
|
NN=session.NN,
|
||||||
|
rataan_used=session.rataan_used,
|
||||||
|
sb_used=session.sb_used,
|
||||||
|
user_answers=[
|
||||||
|
UserAnswerOutput(
|
||||||
|
id=ua.id,
|
||||||
|
item_id=ua.item_id,
|
||||||
|
response=ua.response,
|
||||||
|
is_correct=ua.is_correct,
|
||||||
|
time_spent=ua.time_spent,
|
||||||
|
bobot_earned=ua.bobot_earned,
|
||||||
|
scoring_mode_used=ua.scoring_mode_used,
|
||||||
|
)
|
||||||
|
for ua in user_answer_records
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{session_id}",
|
||||||
|
response_model=SessionResponse,
|
||||||
|
summary="Get session details",
|
||||||
|
description="Retrieve session details including scores if completed.",
|
||||||
|
)
|
||||||
|
async def get_session(
|
||||||
|
session_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
website_id: int = Depends(get_website_id_from_header),
|
||||||
|
) -> SessionResponse:
|
||||||
|
"""
|
||||||
|
Get session details.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Unique session identifier
|
||||||
|
db: Database session
|
||||||
|
website_id: Website ID from header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SessionResponse with session details
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If session not found
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Session).where(
|
||||||
|
Session.session_id == session_id,
|
||||||
|
Session.website_id == website_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if session is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Session {session_id} not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
return SessionResponse.model_validate(session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/",
|
||||||
|
response_model=SessionResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
summary="Create new session",
|
||||||
|
description="Create a new tryout session for a student.",
|
||||||
|
)
|
||||||
|
async def create_session(
|
||||||
|
request: SessionCreateRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> SessionResponse:
|
||||||
|
"""
|
||||||
|
Create a new session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Session creation request
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SessionResponse with created session
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If tryout not found or session already exists
|
||||||
|
"""
|
||||||
|
# Verify tryout exists
|
||||||
|
tryout_result = await db.execute(
|
||||||
|
select(Tryout).where(
|
||||||
|
Tryout.website_id == request.website_id,
|
||||||
|
Tryout.tryout_id == request.tryout_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tryout = tryout_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if tryout is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Tryout {request.tryout_id} not found for website {request.website_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if session already exists
|
||||||
|
existing_result = await db.execute(
|
||||||
|
select(Session).where(Session.session_id == request.session_id)
|
||||||
|
)
|
||||||
|
existing_session = existing_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing_session:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Session {request.session_id} already exists",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create new session
|
||||||
|
session = Session(
|
||||||
|
session_id=request.session_id,
|
||||||
|
wp_user_id=request.wp_user_id,
|
||||||
|
website_id=request.website_id,
|
||||||
|
tryout_id=request.tryout_id,
|
||||||
|
scoring_mode_used=request.scoring_mode,
|
||||||
|
start_time=datetime.now(timezone.utc),
|
||||||
|
is_completed=False,
|
||||||
|
total_benar=0,
|
||||||
|
total_bobot_earned=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(session)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(session)
|
||||||
|
|
||||||
|
return SessionResponse.model_validate(session)
|
||||||
458
app/routers/tryouts.py
Normal file
458
app/routers/tryouts.py
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
"""
|
||||||
|
Tryout API router for tryout configuration and management.
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
- GET /tryout/{tryout_id}/config: Get tryout configuration
|
||||||
|
- PUT /tryout/{tryout_id}/normalization: Update normalization settings
|
||||||
|
- GET /tryout: List tryouts for a website
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Header, status
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.item import Item
|
||||||
|
from app.models.tryout import Tryout
|
||||||
|
from app.models.tryout_stats import TryoutStats
|
||||||
|
from app.schemas.tryout import (
|
||||||
|
NormalizationUpdateRequest,
|
||||||
|
NormalizationUpdateResponse,
|
||||||
|
TryoutConfigBrief,
|
||||||
|
TryoutConfigResponse,
|
||||||
|
TryoutStatsResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/tryout", tags=["tryouts"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_website_id_from_header(
|
||||||
|
x_website_id: Optional[str] = Header(None, alias="X-Website-ID"),
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Extract and validate website_id from request header.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
x_website_id: Website ID from header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Validated website ID as integer
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If header is missing or invalid
|
||||||
|
"""
|
||||||
|
if x_website_id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="X-Website-ID header is required",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return int(x_website_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="X-Website-ID must be a valid integer",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{tryout_id}/config",
|
||||||
|
response_model=TryoutConfigResponse,
|
||||||
|
summary="Get tryout configuration",
|
||||||
|
description="Retrieve tryout configuration including scoring mode, normalization settings, and current stats.",
|
||||||
|
)
|
||||||
|
async def get_tryout_config(
|
||||||
|
tryout_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
website_id: int = Depends(get_website_id_from_header),
|
||||||
|
) -> TryoutConfigResponse:
|
||||||
|
"""
|
||||||
|
Get tryout configuration.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TryoutConfigResponse with scoring_mode, normalization_mode, and current_stats
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If tryout not found
|
||||||
|
"""
|
||||||
|
# Get tryout with stats
|
||||||
|
result = await db.execute(
|
||||||
|
select(Tryout)
|
||||||
|
.options(selectinload(Tryout.stats))
|
||||||
|
.where(
|
||||||
|
Tryout.website_id == website_id,
|
||||||
|
Tryout.tryout_id == tryout_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tryout = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if tryout is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Tryout {tryout_id} not found for website {website_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build stats response
|
||||||
|
current_stats = None
|
||||||
|
if tryout.stats:
|
||||||
|
current_stats = TryoutStatsResponse(
|
||||||
|
participant_count=tryout.stats.participant_count,
|
||||||
|
rataan=tryout.stats.rataan,
|
||||||
|
sb=tryout.stats.sb,
|
||||||
|
min_nm=tryout.stats.min_nm,
|
||||||
|
max_nm=tryout.stats.max_nm,
|
||||||
|
last_calculated=tryout.stats.last_calculated,
|
||||||
|
)
|
||||||
|
|
||||||
|
return TryoutConfigResponse(
|
||||||
|
id=tryout.id,
|
||||||
|
website_id=tryout.website_id,
|
||||||
|
tryout_id=tryout.tryout_id,
|
||||||
|
name=tryout.name,
|
||||||
|
description=tryout.description,
|
||||||
|
scoring_mode=tryout.scoring_mode,
|
||||||
|
selection_mode=tryout.selection_mode,
|
||||||
|
normalization_mode=tryout.normalization_mode,
|
||||||
|
min_sample_for_dynamic=tryout.min_sample_for_dynamic,
|
||||||
|
static_rataan=tryout.static_rataan,
|
||||||
|
static_sb=tryout.static_sb,
|
||||||
|
ai_generation_enabled=tryout.ai_generation_enabled,
|
||||||
|
hybrid_transition_slot=tryout.hybrid_transition_slot,
|
||||||
|
min_calibration_sample=tryout.min_calibration_sample,
|
||||||
|
theta_estimation_method=tryout.theta_estimation_method,
|
||||||
|
fallback_to_ctt_on_error=tryout.fallback_to_ctt_on_error,
|
||||||
|
current_stats=current_stats,
|
||||||
|
created_at=tryout.created_at,
|
||||||
|
updated_at=tryout.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/{tryout_id}/normalization",
|
||||||
|
response_model=NormalizationUpdateResponse,
|
||||||
|
summary="Update normalization settings",
|
||||||
|
description="Update normalization mode and static values for a tryout.",
|
||||||
|
)
|
||||||
|
async def update_normalization(
|
||||||
|
tryout_id: str,
|
||||||
|
request: NormalizationUpdateRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
website_id: int = Depends(get_website_id_from_header),
|
||||||
|
) -> NormalizationUpdateResponse:
|
||||||
|
"""
|
||||||
|
Update normalization settings for a tryout.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
request: Normalization update request
|
||||||
|
db: Database session
|
||||||
|
website_id: Website ID from header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
NormalizationUpdateResponse with updated settings
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If tryout not found or validation fails
|
||||||
|
"""
|
||||||
|
# Get tryout
|
||||||
|
result = await db.execute(
|
||||||
|
select(Tryout).where(
|
||||||
|
Tryout.website_id == website_id,
|
||||||
|
Tryout.tryout_id == tryout_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tryout = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if tryout is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Tryout {tryout_id} not found for website {website_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update normalization mode if provided
|
||||||
|
if request.normalization_mode is not None:
|
||||||
|
tryout.normalization_mode = request.normalization_mode
|
||||||
|
|
||||||
|
# Update static values if provided
|
||||||
|
if request.static_rataan is not None:
|
||||||
|
tryout.static_rataan = request.static_rataan
|
||||||
|
|
||||||
|
if request.static_sb is not None:
|
||||||
|
tryout.static_sb = request.static_sb
|
||||||
|
|
||||||
|
# Get current stats for participant count
|
||||||
|
stats_result = await db.execute(
|
||||||
|
select(TryoutStats).where(
|
||||||
|
TryoutStats.website_id == website_id,
|
||||||
|
TryoutStats.tryout_id == tryout_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stats = stats_result.scalar_one_or_none()
|
||||||
|
current_participant_count = stats.participant_count if stats else 0
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(tryout)
|
||||||
|
|
||||||
|
return NormalizationUpdateResponse(
|
||||||
|
tryout_id=tryout.tryout_id,
|
||||||
|
normalization_mode=tryout.normalization_mode,
|
||||||
|
static_rataan=tryout.static_rataan,
|
||||||
|
static_sb=tryout.static_sb,
|
||||||
|
will_switch_to_dynamic_at=tryout.min_sample_for_dynamic,
|
||||||
|
current_participant_count=current_participant_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/",
|
||||||
|
response_model=List[TryoutConfigBrief],
|
||||||
|
summary="List tryouts",
|
||||||
|
description="List all tryouts for a website.",
|
||||||
|
)
|
||||||
|
async def list_tryouts(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
website_id: int = Depends(get_website_id_from_header),
|
||||||
|
) -> List[TryoutConfigBrief]:
|
||||||
|
"""
|
||||||
|
List all tryouts for a website.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
website_id: Website ID from header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of TryoutConfigBrief
|
||||||
|
"""
|
||||||
|
# Get tryouts with stats
|
||||||
|
result = await db.execute(
|
||||||
|
select(Tryout)
|
||||||
|
.options(selectinload(Tryout.stats))
|
||||||
|
.where(Tryout.website_id == website_id)
|
||||||
|
)
|
||||||
|
tryouts = result.scalars().all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
TryoutConfigBrief(
|
||||||
|
tryout_id=t.tryout_id,
|
||||||
|
name=t.name,
|
||||||
|
scoring_mode=t.scoring_mode,
|
||||||
|
selection_mode=t.selection_mode,
|
||||||
|
normalization_mode=t.normalization_mode,
|
||||||
|
participant_count=t.stats.participant_count if t.stats else 0,
|
||||||
|
)
|
||||||
|
for t in tryouts
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{tryout_id}/calibration-status",
|
||||||
|
summary="Get calibration status",
|
||||||
|
description="Get IRT calibration status for items in this tryout.",
|
||||||
|
)
|
||||||
|
async def get_calibration_status(
|
||||||
|
tryout_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
website_id: int = Depends(get_website_id_from_header),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get calibration status for items in a tryout.
|
||||||
|
|
||||||
|
Returns statistics on how many items are calibrated and ready for IRT.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
db: Database session
|
||||||
|
website_id: Website ID from header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Calibration status summary
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If tryout not found
|
||||||
|
"""
|
||||||
|
# Verify tryout exists
|
||||||
|
tryout_result = await db.execute(
|
||||||
|
select(Tryout).where(
|
||||||
|
Tryout.website_id == website_id,
|
||||||
|
Tryout.tryout_id == tryout_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tryout = tryout_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if tryout is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Tryout {tryout_id} not found for website {website_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get calibration statistics
|
||||||
|
stats_result = await db.execute(
|
||||||
|
select(
|
||||||
|
func.count().label("total_items"),
|
||||||
|
func.sum(func.cast(Item.calibrated, type_=func.INTEGER)).label("calibrated_items"),
|
||||||
|
func.avg(Item.calibration_sample_size).label("avg_sample_size"),
|
||||||
|
).where(
|
||||||
|
Item.website_id == website_id,
|
||||||
|
Item.tryout_id == tryout_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stats = stats_result.first()
|
||||||
|
|
||||||
|
total_items = stats.total_items or 0
|
||||||
|
calibrated_items = stats.calibrated_items or 0
|
||||||
|
calibration_percentage = (calibrated_items / total_items * 100) if total_items > 0 else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tryout_id": tryout_id,
|
||||||
|
"total_items": total_items,
|
||||||
|
"calibrated_items": calibrated_items,
|
||||||
|
"calibration_percentage": round(calibration_percentage, 2),
|
||||||
|
"avg_sample_size": round(stats.avg_sample_size, 2) if stats.avg_sample_size else 0,
|
||||||
|
"min_calibration_sample": tryout.min_calibration_sample,
|
||||||
|
"ready_for_irt": calibration_percentage >= 90,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{tryout_id}/calibrate",
|
||||||
|
summary="Trigger IRT calibration",
|
||||||
|
description="Trigger IRT calibration for all items in this tryout with sufficient response data.",
|
||||||
|
)
|
||||||
|
async def trigger_calibration(
|
||||||
|
tryout_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
website_id: int = Depends(get_website_id_from_header),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Trigger IRT calibration for all items in a tryout.
|
||||||
|
|
||||||
|
Runs calibration for items with >= min_calibration_sample responses.
|
||||||
|
Updates item.irt_b, item.irt_se, and item.calibrated status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
db: Database session
|
||||||
|
website_id: Website ID from header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Calibration results summary
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If tryout not found or calibration fails
|
||||||
|
"""
|
||||||
|
from app.services.irt_calibration import (
|
||||||
|
calibrate_all,
|
||||||
|
CALIBRATION_SAMPLE_THRESHOLD,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify tryout exists
|
||||||
|
tryout_result = await db.execute(
|
||||||
|
select(Tryout).where(
|
||||||
|
Tryout.website_id == website_id,
|
||||||
|
Tryout.tryout_id == tryout_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tryout = tryout_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if tryout is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Tryout {tryout_id} not found for website {website_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run calibration
|
||||||
|
result = await calibrate_all(
|
||||||
|
tryout_id=tryout_id,
|
||||||
|
website_id=website_id,
|
||||||
|
db=db,
|
||||||
|
min_sample_size=tryout.min_calibration_sample or CALIBRATION_SAMPLE_THRESHOLD,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tryout_id": tryout_id,
|
||||||
|
"total_items": result.total_items,
|
||||||
|
"calibrated_items": result.calibrated_items,
|
||||||
|
"failed_items": result.failed_items,
|
||||||
|
"calibration_percentage": round(result.calibration_percentage * 100, 2),
|
||||||
|
"ready_for_irt": result.ready_for_irt,
|
||||||
|
"message": f"Calibration complete: {result.calibrated_items}/{result.total_items} items calibrated",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{tryout_id}/calibrate/{item_id}",
|
||||||
|
summary="Trigger IRT calibration for single item",
|
||||||
|
description="Trigger IRT calibration for a specific item.",
|
||||||
|
)
|
||||||
|
async def trigger_item_calibration(
|
||||||
|
tryout_id: str,
|
||||||
|
item_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
website_id: int = Depends(get_website_id_from_header),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Trigger IRT calibration for a single item.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
item_id: Item ID to calibrate
|
||||||
|
db: Database session
|
||||||
|
website_id: Website ID from header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Calibration result for the item
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If tryout or item not found
|
||||||
|
"""
|
||||||
|
from app.services.irt_calibration import calibrate_item, CALIBRATION_SAMPLE_THRESHOLD
|
||||||
|
|
||||||
|
# Verify tryout exists
|
||||||
|
tryout_result = await db.execute(
|
||||||
|
select(Tryout).where(
|
||||||
|
Tryout.website_id == website_id,
|
||||||
|
Tryout.tryout_id == tryout_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tryout = tryout_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if tryout is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Tryout {tryout_id} not found for website {website_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify item belongs to this tryout
|
||||||
|
item_result = await db.execute(
|
||||||
|
select(Item).where(
|
||||||
|
Item.id == item_id,
|
||||||
|
Item.website_id == website_id,
|
||||||
|
Item.tryout_id == tryout_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
item = item_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Item {item_id} not found in tryout {tryout_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run calibration
|
||||||
|
result = await calibrate_item(
|
||||||
|
item_id=item_id,
|
||||||
|
db=db,
|
||||||
|
min_sample_size=tryout.min_calibration_sample or CALIBRATION_SAMPLE_THRESHOLD,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"item_id": result.item_id,
|
||||||
|
"status": result.status.value,
|
||||||
|
"irt_b": result.irt_b,
|
||||||
|
"irt_se": result.irt_se,
|
||||||
|
"sample_size": result.sample_size,
|
||||||
|
"message": result.message,
|
||||||
|
}
|
||||||
384
app/routers/wordpress.py
Normal file
384
app/routers/wordpress.py
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
"""
|
||||||
|
WordPress Integration API Router.
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
- POST /wordpress/sync_users: Synchronize users from WordPress
|
||||||
|
- POST /wordpress/verify_session: Verify WordPress session/token
|
||||||
|
- GET /wordpress/website/{website_id}/users: Get all users for a website
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Header, status
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.website import Website
|
||||||
|
from app.schemas.wordpress import (
|
||||||
|
SyncUsersResponse,
|
||||||
|
SyncStatsResponse,
|
||||||
|
UserListResponse,
|
||||||
|
VerifySessionRequest,
|
||||||
|
VerifySessionResponse,
|
||||||
|
WordPressUserResponse,
|
||||||
|
)
|
||||||
|
from app.services.wordpress_auth import (
|
||||||
|
get_wordpress_user,
|
||||||
|
sync_wordpress_users,
|
||||||
|
verify_website_exists,
|
||||||
|
verify_wordpress_token,
|
||||||
|
get_or_create_user,
|
||||||
|
WordPressAPIError,
|
||||||
|
WordPressRateLimitError,
|
||||||
|
WordPressTokenInvalidError,
|
||||||
|
WebsiteNotFoundError,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/wordpress", tags=["wordpress"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_website_id_from_header(
|
||||||
|
x_website_id: Optional[str] = Header(None, alias="X-Website-ID"),
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Extract and validate website_id from request header.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
x_website_id: Website ID from header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Validated website ID as integer
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If header is missing or invalid
|
||||||
|
"""
|
||||||
|
if x_website_id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="X-Website-ID header is required",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return int(x_website_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="X-Website-ID must be a valid integer",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_valid_website(
|
||||||
|
website_id: int,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> Website:
|
||||||
|
"""
|
||||||
|
Validate website_id exists and return Website model.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
website_id: Website identifier
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Website model instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If website not found
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await verify_website_exists(website_id, db)
|
||||||
|
except WebsiteNotFoundError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Website {website_id} not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/sync_users",
|
||||||
|
response_model=SyncUsersResponse,
|
||||||
|
summary="Synchronize users from WordPress",
|
||||||
|
description="Fetch all users from WordPress API and sync to local database. Requires admin WordPress token.",
|
||||||
|
)
|
||||||
|
async def sync_users_endpoint(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
website_id: int = Depends(get_website_id_from_header),
|
||||||
|
authorization: Optional[str] = Header(None, alias="Authorization"),
|
||||||
|
) -> SyncUsersResponse:
|
||||||
|
"""
|
||||||
|
Synchronize users from WordPress to local database.
|
||||||
|
|
||||||
|
Process:
|
||||||
|
1. Validate website_id exists
|
||||||
|
2. Extract admin token from Authorization header
|
||||||
|
3. Fetch all users from WordPress API
|
||||||
|
4. Upsert: Update existing users, insert new users
|
||||||
|
5. Return sync statistics
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
website_id: Website ID from header
|
||||||
|
authorization: Authorization header with Bearer token
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SyncUsersResponse with sync statistics
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If website not found, token invalid, or API error
|
||||||
|
"""
|
||||||
|
# Validate website exists
|
||||||
|
await get_valid_website(website_id, db)
|
||||||
|
|
||||||
|
# Extract token from Authorization header
|
||||||
|
if authorization is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Authorization header is required",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse Bearer token
|
||||||
|
parts = authorization.split()
|
||||||
|
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid Authorization header format. Use: Bearer {token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
admin_token = parts[1]
|
||||||
|
|
||||||
|
try:
|
||||||
|
sync_stats = await sync_wordpress_users(
|
||||||
|
website_id=website_id,
|
||||||
|
admin_token=admin_token,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
|
||||||
|
return SyncUsersResponse(
|
||||||
|
synced=SyncStatsResponse(
|
||||||
|
inserted=sync_stats.inserted,
|
||||||
|
updated=sync_stats.updated,
|
||||||
|
total=sync_stats.total,
|
||||||
|
errors=sync_stats.errors,
|
||||||
|
),
|
||||||
|
website_id=website_id,
|
||||||
|
message=f"Sync completed: {sync_stats.inserted} inserted, {sync_stats.updated} updated",
|
||||||
|
)
|
||||||
|
|
||||||
|
except WordPressTokenInvalidError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail=str(e),
|
||||||
|
)
|
||||||
|
except WordPressRateLimitError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail=str(e),
|
||||||
|
)
|
||||||
|
except WordPressAPIError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail=str(e),
|
||||||
|
)
|
||||||
|
except WebsiteNotFoundError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/verify_session",
|
||||||
|
response_model=VerifySessionResponse,
|
||||||
|
summary="Verify WordPress session",
|
||||||
|
description="Verify WordPress JWT token and user identity.",
|
||||||
|
)
|
||||||
|
async def verify_session_endpoint(
|
||||||
|
request: VerifySessionRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> VerifySessionResponse:
|
||||||
|
"""
|
||||||
|
Verify WordPress session/token.
|
||||||
|
|
||||||
|
Process:
|
||||||
|
1. Validate website_id exists
|
||||||
|
2. Call WordPress API to verify token
|
||||||
|
3. Verify wp_user_id matches token owner
|
||||||
|
4. Get or create local user
|
||||||
|
5. Return validation result
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: VerifySessionRequest with wp_user_id, token, website_id
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
VerifySessionResponse with validation result
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If website not found or API error
|
||||||
|
"""
|
||||||
|
# Validate website exists
|
||||||
|
await get_valid_website(request.website_id, db)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Verify token with WordPress
|
||||||
|
wp_user_info = await verify_wordpress_token(
|
||||||
|
token=request.token,
|
||||||
|
website_id=request.website_id,
|
||||||
|
wp_user_id=request.wp_user_id,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
|
||||||
|
if wp_user_info is None:
|
||||||
|
return VerifySessionResponse(
|
||||||
|
valid=False,
|
||||||
|
error="User ID mismatch or invalid credentials",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get or create local user
|
||||||
|
user = await get_or_create_user(
|
||||||
|
wp_user_id=request.wp_user_id,
|
||||||
|
website_id=request.website_id,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
|
||||||
|
return VerifySessionResponse(
|
||||||
|
valid=True,
|
||||||
|
user=WordPressUserResponse.model_validate(user),
|
||||||
|
wp_user_info={
|
||||||
|
"username": wp_user_info.username,
|
||||||
|
"email": wp_user_info.email,
|
||||||
|
"display_name": wp_user_info.display_name,
|
||||||
|
"roles": wp_user_info.roles,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
except WordPressTokenInvalidError as e:
|
||||||
|
return VerifySessionResponse(
|
||||||
|
valid=False,
|
||||||
|
error=f"Invalid credentials: {str(e)}",
|
||||||
|
)
|
||||||
|
except WordPressRateLimitError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail=str(e),
|
||||||
|
)
|
||||||
|
except WordPressAPIError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail=str(e),
|
||||||
|
)
|
||||||
|
except WebsiteNotFoundError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/website/{website_id}/users",
|
||||||
|
response_model=UserListResponse,
|
||||||
|
summary="Get users for website",
|
||||||
|
description="Retrieve all users for a specific website from local database with pagination.",
|
||||||
|
)
|
||||||
|
async def get_website_users(
|
||||||
|
website_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 50,
|
||||||
|
) -> UserListResponse:
|
||||||
|
"""
|
||||||
|
Get all users for a website.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
website_id: Website identifier
|
||||||
|
db: Database session
|
||||||
|
page: Page number (default: 1)
|
||||||
|
page_size: Number of users per page (default: 50, max: 100)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
UserListResponse with paginated user list
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If website not found
|
||||||
|
"""
|
||||||
|
# Validate website exists
|
||||||
|
await get_valid_website(website_id, db)
|
||||||
|
|
||||||
|
# Clamp page_size
|
||||||
|
page_size = min(max(1, page_size), 100)
|
||||||
|
page = max(1, page)
|
||||||
|
|
||||||
|
# Get total count
|
||||||
|
count_result = await db.execute(
|
||||||
|
select(func.count()).select_from(User).where(User.website_id == website_id)
|
||||||
|
)
|
||||||
|
total = count_result.scalar() or 0
|
||||||
|
|
||||||
|
# Calculate pagination
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
total_pages = (total + page_size - 1) // page_size if total > 0 else 1
|
||||||
|
|
||||||
|
# Get users
|
||||||
|
result = await db.execute(
|
||||||
|
select(User)
|
||||||
|
.where(User.website_id == website_id)
|
||||||
|
.order_by(User.id)
|
||||||
|
.offset(offset)
|
||||||
|
.limit(page_size)
|
||||||
|
)
|
||||||
|
users = result.scalars().all()
|
||||||
|
|
||||||
|
return UserListResponse(
|
||||||
|
users=[WordPressUserResponse.model_validate(user) for user in users],
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
total_pages=total_pages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/website/{website_id}/user/{wp_user_id}",
|
||||||
|
response_model=WordPressUserResponse,
|
||||||
|
summary="Get specific user",
|
||||||
|
description="Retrieve a specific user by WordPress user ID.",
|
||||||
|
)
|
||||||
|
async def get_user_endpoint(
|
||||||
|
website_id: int,
|
||||||
|
wp_user_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> WordPressUserResponse:
|
||||||
|
"""
|
||||||
|
Get a specific user by WordPress user ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
website_id: Website identifier
|
||||||
|
wp_user_id: WordPress user ID
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
WordPressUserResponse with user data
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If website or user not found
|
||||||
|
"""
|
||||||
|
# Validate website exists
|
||||||
|
await get_valid_website(website_id, db)
|
||||||
|
|
||||||
|
# Get user
|
||||||
|
user = await get_wordpress_user(
|
||||||
|
wp_user_id=wp_user_id,
|
||||||
|
website_id=website_id,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"User {wp_user_id} not found for website {website_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
return WordPressUserResponse.model_validate(user)
|
||||||
65
app/schemas/__init__.py
Normal file
65
app/schemas/__init__.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""
|
||||||
|
Pydantic schemas package.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.schemas.ai import (
|
||||||
|
AIGeneratePreviewRequest,
|
||||||
|
AIGeneratePreviewResponse,
|
||||||
|
AISaveRequest,
|
||||||
|
AISaveResponse,
|
||||||
|
AIStatsResponse,
|
||||||
|
GeneratedQuestion,
|
||||||
|
)
|
||||||
|
from app.schemas.session import (
|
||||||
|
SessionCompleteRequest,
|
||||||
|
SessionCompleteResponse,
|
||||||
|
SessionCreateRequest,
|
||||||
|
SessionResponse,
|
||||||
|
UserAnswerInput,
|
||||||
|
UserAnswerOutput,
|
||||||
|
)
|
||||||
|
from app.schemas.tryout import (
|
||||||
|
NormalizationUpdateRequest,
|
||||||
|
NormalizationUpdateResponse,
|
||||||
|
TryoutConfigBrief,
|
||||||
|
TryoutConfigResponse,
|
||||||
|
TryoutStatsResponse,
|
||||||
|
)
|
||||||
|
from app.schemas.wordpress import (
|
||||||
|
SyncStatsResponse,
|
||||||
|
SyncUsersResponse,
|
||||||
|
UserListResponse,
|
||||||
|
VerifySessionRequest,
|
||||||
|
VerifySessionResponse,
|
||||||
|
WordPressUserResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# AI schemas
|
||||||
|
"AIGeneratePreviewRequest",
|
||||||
|
"AIGeneratePreviewResponse",
|
||||||
|
"AISaveRequest",
|
||||||
|
"AISaveResponse",
|
||||||
|
"AIStatsResponse",
|
||||||
|
"GeneratedQuestion",
|
||||||
|
# Session schemas
|
||||||
|
"UserAnswerInput",
|
||||||
|
"UserAnswerOutput",
|
||||||
|
"SessionCompleteRequest",
|
||||||
|
"SessionCompleteResponse",
|
||||||
|
"SessionCreateRequest",
|
||||||
|
"SessionResponse",
|
||||||
|
# Tryout schemas
|
||||||
|
"TryoutConfigResponse",
|
||||||
|
"TryoutStatsResponse",
|
||||||
|
"TryoutConfigBrief",
|
||||||
|
"NormalizationUpdateRequest",
|
||||||
|
"NormalizationUpdateResponse",
|
||||||
|
# WordPress schemas
|
||||||
|
"SyncStatsResponse",
|
||||||
|
"SyncUsersResponse",
|
||||||
|
"UserListResponse",
|
||||||
|
"VerifySessionRequest",
|
||||||
|
"VerifySessionResponse",
|
||||||
|
"WordPressUserResponse",
|
||||||
|
]
|
||||||
102
app/schemas/ai.py
Normal file
102
app/schemas/ai.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"""
|
||||||
|
Pydantic schemas for AI generation endpoints.
|
||||||
|
|
||||||
|
Request/response models for admin AI generation playground.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, Literal, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
class AIGeneratePreviewRequest(BaseModel):
|
||||||
|
basis_item_id: int = Field(
|
||||||
|
..., description="ID of the basis item (must be sedang level)"
|
||||||
|
)
|
||||||
|
target_level: Literal["mudah", "sulit"] = Field(
|
||||||
|
..., description="Target difficulty level for generated question"
|
||||||
|
)
|
||||||
|
ai_model: str = Field(
|
||||||
|
default="qwen/qwen-2.5-coder-32b-instruct",
|
||||||
|
description="AI model to use for generation",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AIGeneratePreviewResponse(BaseModel):
|
||||||
|
success: bool = Field(..., description="Whether generation was successful")
|
||||||
|
stem: Optional[str] = None
|
||||||
|
options: Optional[Dict[str, str]] = None
|
||||||
|
correct: Optional[str] = None
|
||||||
|
explanation: Optional[str] = None
|
||||||
|
ai_model: Optional[str] = None
|
||||||
|
basis_item_id: Optional[int] = None
|
||||||
|
target_level: Optional[str] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
cached: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class AISaveRequest(BaseModel):
|
||||||
|
stem: str = Field(..., description="Question stem")
|
||||||
|
options: Dict[str, str] = Field(
|
||||||
|
..., description="Answer options (A, B, C, D)"
|
||||||
|
)
|
||||||
|
correct: str = Field(..., description="Correct answer (A/B/C/D)")
|
||||||
|
explanation: Optional[str] = None
|
||||||
|
tryout_id: str = Field(..., description="Tryout identifier")
|
||||||
|
website_id: int = Field(..., description="Website identifier")
|
||||||
|
basis_item_id: int = Field(..., description="Basis item ID")
|
||||||
|
slot: int = Field(..., description="Question slot position")
|
||||||
|
level: Literal["mudah", "sedang", "sulit"] = Field(
|
||||||
|
..., description="Difficulty level"
|
||||||
|
)
|
||||||
|
ai_model: str = Field(
|
||||||
|
default="qwen/qwen-2.5-coder-32b-instruct",
|
||||||
|
description="AI model used for generation",
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("correct")
|
||||||
|
@classmethod
|
||||||
|
def validate_correct(cls, v: str) -> str:
|
||||||
|
if v.upper() not in ["A", "B", "C", "D"]:
|
||||||
|
raise ValueError("Correct answer must be A, B, C, or D")
|
||||||
|
return v.upper()
|
||||||
|
|
||||||
|
@field_validator("options")
|
||||||
|
@classmethod
|
||||||
|
def validate_options(cls, v: Dict[str, str]) -> Dict[str, str]:
|
||||||
|
required_keys = {"A", "B", "C", "D"}
|
||||||
|
if not required_keys.issubset(set(v.keys())):
|
||||||
|
raise ValueError("Options must contain keys A, B, C, D")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class AISaveResponse(BaseModel):
|
||||||
|
success: bool = Field(..., description="Whether save was successful")
|
||||||
|
item_id: Optional[int] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AIStatsResponse(BaseModel):
|
||||||
|
total_ai_items: int = Field(..., description="Total AI-generated items")
|
||||||
|
items_by_model: Dict[str, int] = Field(
|
||||||
|
default_factory=dict, description="Items count by AI model"
|
||||||
|
)
|
||||||
|
cache_hit_rate: float = Field(
|
||||||
|
default=0.0, description="Cache hit rate (0.0 to 1.0)"
|
||||||
|
)
|
||||||
|
total_cache_hits: int = Field(default=0, description="Total cache hits")
|
||||||
|
total_requests: int = Field(default=0, description="Total generation requests")
|
||||||
|
|
||||||
|
|
||||||
|
class GeneratedQuestion(BaseModel):
|
||||||
|
stem: str
|
||||||
|
options: Dict[str, str]
|
||||||
|
correct: str
|
||||||
|
explanation: Optional[str] = None
|
||||||
|
|
||||||
|
@field_validator("correct")
|
||||||
|
@classmethod
|
||||||
|
def validate_correct(cls, v: str) -> str:
|
||||||
|
if v.upper() not in ["A", "B", "C", "D"]:
|
||||||
|
raise ValueError("Correct answer must be A, B, C, or D")
|
||||||
|
return v.upper()
|
||||||
264
app/schemas/report.py
Normal file
264
app/schemas/report.py
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
"""
|
||||||
|
Pydantic schemas for Report API endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict, List, Literal, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Student Performance Report Schemas
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class StudentPerformanceRecordOutput(BaseModel):
|
||||||
|
"""Individual student performance record output."""
|
||||||
|
|
||||||
|
session_id: str
|
||||||
|
wp_user_id: str
|
||||||
|
tryout_id: str
|
||||||
|
NM: Optional[int] = None
|
||||||
|
NN: Optional[int] = None
|
||||||
|
theta: Optional[float] = None
|
||||||
|
theta_se: Optional[float] = None
|
||||||
|
total_benar: int
|
||||||
|
time_spent: int # Total time in seconds
|
||||||
|
start_time: Optional[datetime] = None
|
||||||
|
end_time: Optional[datetime] = None
|
||||||
|
scoring_mode_used: str
|
||||||
|
rataan_used: Optional[float] = None
|
||||||
|
sb_used: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AggregatePerformanceStatsOutput(BaseModel):
|
||||||
|
"""Aggregate statistics for student performance output."""
|
||||||
|
|
||||||
|
tryout_id: str
|
||||||
|
participant_count: int
|
||||||
|
avg_nm: Optional[float] = None
|
||||||
|
std_nm: Optional[float] = None
|
||||||
|
min_nm: Optional[int] = None
|
||||||
|
max_nm: Optional[int] = None
|
||||||
|
median_nm: Optional[float] = None
|
||||||
|
avg_nn: Optional[float] = None
|
||||||
|
std_nn: Optional[float] = None
|
||||||
|
avg_theta: Optional[float] = None
|
||||||
|
pass_rate: float # Percentage with NN >= 500
|
||||||
|
avg_time_spent: float # Average time in seconds
|
||||||
|
|
||||||
|
|
||||||
|
class StudentPerformanceReportOutput(BaseModel):
|
||||||
|
"""Complete student performance report output."""
|
||||||
|
|
||||||
|
generated_at: datetime
|
||||||
|
tryout_id: str
|
||||||
|
website_id: int
|
||||||
|
date_range: Optional[Dict[str, str]] = None
|
||||||
|
aggregate: AggregatePerformanceStatsOutput
|
||||||
|
individual_records: List[StudentPerformanceRecordOutput] = []
|
||||||
|
|
||||||
|
|
||||||
|
class StudentPerformanceReportRequest(BaseModel):
|
||||||
|
"""Request schema for student performance report."""
|
||||||
|
|
||||||
|
tryout_id: str = Field(..., description="Tryout identifier")
|
||||||
|
website_id: int = Field(..., description="Website identifier")
|
||||||
|
date_start: Optional[datetime] = Field(None, description="Filter by start date")
|
||||||
|
date_end: Optional[datetime] = Field(None, description="Filter by end date")
|
||||||
|
format_type: Literal["individual", "aggregate", "both"] = Field(
|
||||||
|
default="both", description="Report format"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Item Analysis Report Schemas
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class ItemAnalysisRecordOutput(BaseModel):
|
||||||
|
"""Item analysis record output for a single item."""
|
||||||
|
|
||||||
|
item_id: int
|
||||||
|
slot: int
|
||||||
|
level: str
|
||||||
|
ctt_p: Optional[float] = None
|
||||||
|
ctt_bobot: Optional[float] = None
|
||||||
|
ctt_category: Optional[str] = None
|
||||||
|
irt_b: Optional[float] = None
|
||||||
|
irt_se: Optional[float] = None
|
||||||
|
calibrated: bool
|
||||||
|
calibration_sample_size: int
|
||||||
|
correctness_rate: float
|
||||||
|
item_total_correlation: Optional[float] = None
|
||||||
|
information_values: Dict[float, float] = Field(default_factory=dict)
|
||||||
|
optimal_theta_range: str = "N/A"
|
||||||
|
|
||||||
|
|
||||||
|
class ItemAnalysisReportOutput(BaseModel):
|
||||||
|
"""Complete item analysis report output."""
|
||||||
|
|
||||||
|
generated_at: datetime
|
||||||
|
tryout_id: str
|
||||||
|
website_id: int
|
||||||
|
total_items: int
|
||||||
|
items: List[ItemAnalysisRecordOutput]
|
||||||
|
summary: Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class ItemAnalysisReportRequest(BaseModel):
|
||||||
|
"""Request schema for item analysis report."""
|
||||||
|
|
||||||
|
tryout_id: str = Field(..., description="Tryout identifier")
|
||||||
|
website_id: int = Field(..., description="Website identifier")
|
||||||
|
filter_by: Optional[Literal["difficulty", "calibrated", "discrimination"]] = Field(
|
||||||
|
None, description="Filter items by category"
|
||||||
|
)
|
||||||
|
difficulty_level: Optional[Literal["mudah", "sedang", "sulit"]] = Field(
|
||||||
|
None, description="Filter by difficulty level (only when filter_by='difficulty')"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Calibration Status Report Schemas
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class CalibrationItemStatusOutput(BaseModel):
|
||||||
|
"""Calibration status for a single item output."""
|
||||||
|
|
||||||
|
item_id: int
|
||||||
|
slot: int
|
||||||
|
level: str
|
||||||
|
sample_size: int
|
||||||
|
calibrated: bool
|
||||||
|
irt_b: Optional[float] = None
|
||||||
|
irt_se: Optional[float] = None
|
||||||
|
ctt_p: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CalibrationStatusReportOutput(BaseModel):
|
||||||
|
"""Complete calibration status report output."""
|
||||||
|
|
||||||
|
generated_at: datetime
|
||||||
|
tryout_id: str
|
||||||
|
website_id: int
|
||||||
|
total_items: int
|
||||||
|
calibrated_items: int
|
||||||
|
calibration_percentage: float
|
||||||
|
items_awaiting_calibration: List[CalibrationItemStatusOutput]
|
||||||
|
avg_calibration_sample_size: float
|
||||||
|
estimated_time_to_90_percent: Optional[str] = None
|
||||||
|
ready_for_irt_rollout: bool
|
||||||
|
items: List[CalibrationItemStatusOutput]
|
||||||
|
|
||||||
|
|
||||||
|
class CalibrationStatusReportRequest(BaseModel):
|
||||||
|
"""Request schema for calibration status report."""
|
||||||
|
|
||||||
|
tryout_id: str = Field(..., description="Tryout identifier")
|
||||||
|
website_id: int = Field(..., description="Website identifier")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Tryout Comparison Report Schemas
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TryoutComparisonRecordOutput(BaseModel):
|
||||||
|
"""Tryout comparison data point output."""
|
||||||
|
|
||||||
|
tryout_id: str
|
||||||
|
date: Optional[str] = None
|
||||||
|
subject: Optional[str] = None
|
||||||
|
participant_count: int
|
||||||
|
avg_nm: Optional[float] = None
|
||||||
|
avg_nn: Optional[float] = None
|
||||||
|
avg_theta: Optional[float] = None
|
||||||
|
std_nm: Optional[float] = None
|
||||||
|
calibration_percentage: float
|
||||||
|
|
||||||
|
|
||||||
|
class TryoutComparisonReportOutput(BaseModel):
|
||||||
|
"""Complete tryout comparison report output."""
|
||||||
|
|
||||||
|
generated_at: datetime
|
||||||
|
comparison_type: Literal["date", "subject"]
|
||||||
|
tryouts: List[TryoutComparisonRecordOutput]
|
||||||
|
trends: Optional[Dict[str, Any]] = None
|
||||||
|
normalization_impact: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TryoutComparisonReportRequest(BaseModel):
|
||||||
|
"""Request schema for tryout comparison report."""
|
||||||
|
|
||||||
|
tryout_ids: List[str] = Field(..., min_length=2, description="List of tryout IDs to compare")
|
||||||
|
website_id: int = Field(..., description="Website identifier")
|
||||||
|
group_by: Literal["date", "subject"] = Field(
|
||||||
|
default="date", description="Group comparison by date or subject"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Report Scheduling Schemas
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class ReportScheduleRequest(BaseModel):
|
||||||
|
"""Request schema for scheduling a report."""
|
||||||
|
|
||||||
|
report_type: Literal["student_performance", "item_analysis", "calibration_status", "tryout_comparison"] = Field(
|
||||||
|
..., description="Type of report to generate"
|
||||||
|
)
|
||||||
|
schedule: Literal["daily", "weekly", "monthly"] = Field(
|
||||||
|
..., description="Schedule frequency"
|
||||||
|
)
|
||||||
|
tryout_ids: List[str] = Field(..., description="List of tryout IDs for the report")
|
||||||
|
website_id: int = Field(..., description="Website identifier")
|
||||||
|
recipients: List[str] = Field(..., description="List of email addresses to send report to")
|
||||||
|
export_format: Literal["csv", "xlsx", "pdf"] = Field(
|
||||||
|
default="xlsx", description="Export format for the report"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ReportScheduleOutput(BaseModel):
|
||||||
|
"""Output schema for scheduled report."""
|
||||||
|
|
||||||
|
schedule_id: str
|
||||||
|
report_type: str
|
||||||
|
schedule: str
|
||||||
|
tryout_ids: List[str]
|
||||||
|
website_id: int
|
||||||
|
recipients: List[str]
|
||||||
|
format: str
|
||||||
|
created_at: datetime
|
||||||
|
last_run: Optional[datetime] = None
|
||||||
|
next_run: Optional[datetime] = None
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ReportScheduleResponse(BaseModel):
|
||||||
|
"""Response schema for schedule creation."""
|
||||||
|
|
||||||
|
schedule_id: str
|
||||||
|
message: str
|
||||||
|
next_run: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Export Schemas
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class ExportRequest(BaseModel):
|
||||||
|
"""Request schema for exporting a report."""
|
||||||
|
|
||||||
|
schedule_id: str = Field(..., description="Schedule ID to generate report for")
|
||||||
|
export_format: Literal["csv", "xlsx", "pdf"] = Field(
|
||||||
|
default="xlsx", description="Export format"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExportResponse(BaseModel):
|
||||||
|
"""Response schema for export request."""
|
||||||
|
|
||||||
|
file_path: str
|
||||||
|
file_name: str
|
||||||
|
format: str
|
||||||
|
generated_at: datetime
|
||||||
|
download_url: Optional[str] = None
|
||||||
108
app/schemas/session.py
Normal file
108
app/schemas/session.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"""
|
||||||
|
Pydantic schemas for Session API endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Literal, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class UserAnswerInput(BaseModel):
|
||||||
|
"""Input schema for a single user answer."""
|
||||||
|
|
||||||
|
item_id: int = Field(..., description="Item/question ID")
|
||||||
|
response: str = Field(..., min_length=1, max_length=10, description="User's answer (A, B, C, D)")
|
||||||
|
time_spent: int = Field(default=0, ge=0, description="Time spent on this question (seconds)")
|
||||||
|
|
||||||
|
|
||||||
|
class SessionCompleteRequest(BaseModel):
|
||||||
|
"""Request schema for completing a session."""
|
||||||
|
|
||||||
|
end_time: datetime = Field(..., description="Session end timestamp")
|
||||||
|
user_answers: List[UserAnswerInput] = Field(..., description="List of user answers")
|
||||||
|
|
||||||
|
|
||||||
|
class UserAnswerOutput(BaseModel):
|
||||||
|
"""Output schema for a single user answer."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
item_id: int
|
||||||
|
response: str
|
||||||
|
is_correct: bool
|
||||||
|
time_spent: int
|
||||||
|
bobot_earned: float
|
||||||
|
scoring_mode_used: str
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class SessionCompleteResponse(BaseModel):
|
||||||
|
"""Response schema for completed session with CTT scores."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
session_id: str
|
||||||
|
wp_user_id: str
|
||||||
|
website_id: int
|
||||||
|
tryout_id: str
|
||||||
|
start_time: datetime
|
||||||
|
end_time: Optional[datetime]
|
||||||
|
is_completed: bool
|
||||||
|
scoring_mode_used: str
|
||||||
|
|
||||||
|
# CTT scores
|
||||||
|
total_benar: int = Field(description="Total correct answers")
|
||||||
|
total_bobot_earned: float = Field(description="Total weight earned")
|
||||||
|
NM: Optional[int] = Field(description="Nilai Mentah (raw score) [0, 1000]")
|
||||||
|
NN: Optional[int] = Field(description="Nilai Nasional (normalized score) [0, 1000]")
|
||||||
|
|
||||||
|
# Normalization metadata
|
||||||
|
rataan_used: Optional[float] = Field(description="Mean value used for normalization")
|
||||||
|
sb_used: Optional[float] = Field(description="Standard deviation used for normalization")
|
||||||
|
|
||||||
|
# User answers
|
||||||
|
user_answers: List[UserAnswerOutput]
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class SessionCreateRequest(BaseModel):
|
||||||
|
"""Request schema for creating a new session."""
|
||||||
|
|
||||||
|
session_id: str = Field(..., description="Unique session identifier")
|
||||||
|
wp_user_id: str = Field(..., description="WordPress user ID")
|
||||||
|
website_id: int = Field(..., description="Website identifier")
|
||||||
|
tryout_id: str = Field(..., description="Tryout identifier")
|
||||||
|
scoring_mode: Literal["ctt", "irt", "hybrid"] = Field(
|
||||||
|
default="ctt", description="Scoring mode for this session"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionResponse(BaseModel):
|
||||||
|
"""Response schema for session data."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
session_id: str
|
||||||
|
wp_user_id: str
|
||||||
|
website_id: int
|
||||||
|
tryout_id: str
|
||||||
|
start_time: datetime
|
||||||
|
end_time: Optional[datetime]
|
||||||
|
is_completed: bool
|
||||||
|
scoring_mode_used: str
|
||||||
|
|
||||||
|
# CTT scores (populated after completion)
|
||||||
|
total_benar: int
|
||||||
|
total_bobot_earned: float
|
||||||
|
NM: Optional[int]
|
||||||
|
NN: Optional[int]
|
||||||
|
|
||||||
|
# IRT scores (populated after completion)
|
||||||
|
theta: Optional[float]
|
||||||
|
theta_se: Optional[float]
|
||||||
|
|
||||||
|
# Normalization metadata
|
||||||
|
rataan_used: Optional[float]
|
||||||
|
sb_used: Optional[float]
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
97
app/schemas/tryout.py
Normal file
97
app/schemas/tryout.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"""
|
||||||
|
Pydantic schemas for Tryout API endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Literal, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class TryoutConfigResponse(BaseModel):
|
||||||
|
"""Response schema for tryout configuration."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
website_id: int
|
||||||
|
tryout_id: str
|
||||||
|
name: str
|
||||||
|
description: Optional[str]
|
||||||
|
|
||||||
|
# Scoring configuration
|
||||||
|
scoring_mode: Literal["ctt", "irt", "hybrid"]
|
||||||
|
selection_mode: Literal["fixed", "adaptive", "hybrid"]
|
||||||
|
normalization_mode: Literal["static", "dynamic", "hybrid"]
|
||||||
|
|
||||||
|
# Normalization settings
|
||||||
|
min_sample_for_dynamic: int
|
||||||
|
static_rataan: float
|
||||||
|
static_sb: float
|
||||||
|
|
||||||
|
# AI generation
|
||||||
|
ai_generation_enabled: bool
|
||||||
|
|
||||||
|
# Hybrid mode settings
|
||||||
|
hybrid_transition_slot: Optional[int]
|
||||||
|
|
||||||
|
# IRT settings
|
||||||
|
min_calibration_sample: int
|
||||||
|
theta_estimation_method: Literal["mle", "map", "eap"]
|
||||||
|
fallback_to_ctt_on_error: bool
|
||||||
|
|
||||||
|
# Current stats
|
||||||
|
current_stats: Optional["TryoutStatsResponse"]
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class TryoutStatsResponse(BaseModel):
|
||||||
|
"""Response schema for tryout statistics."""
|
||||||
|
|
||||||
|
participant_count: int
|
||||||
|
rataan: Optional[float]
|
||||||
|
sb: Optional[float]
|
||||||
|
min_nm: Optional[int]
|
||||||
|
max_nm: Optional[int]
|
||||||
|
last_calculated: Optional[datetime]
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class TryoutConfigBrief(BaseModel):
|
||||||
|
"""Brief tryout config for list responses."""
|
||||||
|
|
||||||
|
tryout_id: str
|
||||||
|
name: str
|
||||||
|
scoring_mode: str
|
||||||
|
selection_mode: str
|
||||||
|
normalization_mode: str
|
||||||
|
participant_count: Optional[int] = None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class NormalizationUpdateRequest(BaseModel):
|
||||||
|
"""Request schema for updating normalization settings."""
|
||||||
|
|
||||||
|
normalization_mode: Optional[Literal["static", "dynamic", "hybrid"]] = None
|
||||||
|
static_rataan: Optional[float] = Field(None, ge=0)
|
||||||
|
static_sb: Optional[float] = Field(None, gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
class NormalizationUpdateResponse(BaseModel):
|
||||||
|
"""Response schema for normalization update."""
|
||||||
|
|
||||||
|
tryout_id: str
|
||||||
|
normalization_mode: str
|
||||||
|
static_rataan: float
|
||||||
|
static_sb: float
|
||||||
|
will_switch_to_dynamic_at: int
|
||||||
|
current_participant_count: int
|
||||||
|
|
||||||
|
|
||||||
|
# Update forward reference
|
||||||
|
TryoutConfigResponse.model_rebuild()
|
||||||
86
app/schemas/wordpress.py
Normal file
86
app/schemas/wordpress.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"""
|
||||||
|
Pydantic schemas for WordPress Integration API endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class VerifySessionRequest(BaseModel):
|
||||||
|
"""Request schema for verifying WordPress session."""
|
||||||
|
|
||||||
|
wp_user_id: str = Field(..., description="WordPress user ID")
|
||||||
|
token: str = Field(..., description="WordPress JWT authentication token")
|
||||||
|
website_id: int = Field(..., description="Website identifier")
|
||||||
|
|
||||||
|
|
||||||
|
class WordPressUserResponse(BaseModel):
|
||||||
|
"""Response schema for WordPress user data."""
|
||||||
|
|
||||||
|
id: int = Field(..., description="Local database user ID")
|
||||||
|
wp_user_id: str = Field(..., description="WordPress user ID")
|
||||||
|
website_id: int = Field(..., description="Website identifier")
|
||||||
|
created_at: datetime = Field(..., description="User creation timestamp")
|
||||||
|
updated_at: datetime = Field(..., description="User last update timestamp")
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class VerifySessionResponse(BaseModel):
|
||||||
|
"""Response schema for session verification."""
|
||||||
|
|
||||||
|
valid: bool = Field(..., description="Whether the session is valid")
|
||||||
|
user: Optional[WordPressUserResponse] = Field(
|
||||||
|
default=None, description="User data if session is valid"
|
||||||
|
)
|
||||||
|
error: Optional[str] = Field(
|
||||||
|
default=None, description="Error message if session is invalid"
|
||||||
|
)
|
||||||
|
wp_user_info: Optional[dict[str, Any]] = Field(
|
||||||
|
default=None, description="WordPress user info from API"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SyncUsersRequest(BaseModel):
|
||||||
|
"""Request schema for user synchronization (optional body)."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SyncStatsResponse(BaseModel):
|
||||||
|
"""Response schema for user synchronization statistics."""
|
||||||
|
|
||||||
|
inserted: int = Field(..., description="Number of users inserted")
|
||||||
|
updated: int = Field(..., description="Number of users updated")
|
||||||
|
total: int = Field(..., description="Total users processed")
|
||||||
|
errors: int = Field(default=0, description="Number of errors during sync")
|
||||||
|
|
||||||
|
|
||||||
|
class SyncUsersResponse(BaseModel):
|
||||||
|
"""Response schema for user synchronization."""
|
||||||
|
|
||||||
|
synced: SyncStatsResponse = Field(..., description="Synchronization statistics")
|
||||||
|
website_id: int = Field(..., description="Website identifier")
|
||||||
|
message: str = Field(default="Sync completed", description="Status message")
|
||||||
|
|
||||||
|
|
||||||
|
class UserListResponse(BaseModel):
|
||||||
|
"""Response schema for paginated user list."""
|
||||||
|
|
||||||
|
users: List[WordPressUserResponse] = Field(..., description="List of users")
|
||||||
|
total: int = Field(..., description="Total number of users")
|
||||||
|
page: int = Field(default=1, description="Current page number")
|
||||||
|
page_size: int = Field(default=50, description="Number of users per page")
|
||||||
|
total_pages: int = Field(default=1, description="Total number of pages")
|
||||||
|
|
||||||
|
|
||||||
|
class WordPressErrorDetail(BaseModel):
|
||||||
|
"""Detail schema for WordPress errors."""
|
||||||
|
|
||||||
|
code: str = Field(..., description="Error code")
|
||||||
|
message: str = Field(..., description="Error message")
|
||||||
|
details: Optional[dict[str, Any]] = Field(
|
||||||
|
default=None, description="Additional error details"
|
||||||
|
)
|
||||||
155
app/services/__init__.py
Normal file
155
app/services/__init__.py
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
"""
|
||||||
|
Services module for IRT Bank Soal.
|
||||||
|
|
||||||
|
Contains business logic services for:
|
||||||
|
- IRT calibration
|
||||||
|
- CAT selection
|
||||||
|
- WordPress authentication
|
||||||
|
- AI question generation
|
||||||
|
- Reporting
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.services.irt_calibration import (
|
||||||
|
IRTCalibrationError,
|
||||||
|
calculate_fisher_information,
|
||||||
|
calculate_item_information,
|
||||||
|
calculate_probability,
|
||||||
|
calculate_theta_se,
|
||||||
|
estimate_b_from_ctt_p,
|
||||||
|
estimate_theta_mle,
|
||||||
|
get_session_responses,
|
||||||
|
nn_to_theta,
|
||||||
|
theta_to_nn,
|
||||||
|
update_session_theta,
|
||||||
|
update_theta_after_response,
|
||||||
|
)
|
||||||
|
from app.services.cat_selection import (
|
||||||
|
CATSelectionError,
|
||||||
|
NextItemResult,
|
||||||
|
TerminationCheck,
|
||||||
|
check_user_level_reuse,
|
||||||
|
get_available_levels_for_slot,
|
||||||
|
get_next_item,
|
||||||
|
get_next_item_adaptive,
|
||||||
|
get_next_item_fixed,
|
||||||
|
get_next_item_hybrid,
|
||||||
|
should_terminate,
|
||||||
|
simulate_cat_selection,
|
||||||
|
update_theta,
|
||||||
|
)
|
||||||
|
from app.services.wordpress_auth import (
|
||||||
|
WordPressAPIError,
|
||||||
|
WordPressAuthError,
|
||||||
|
WordPressRateLimitError,
|
||||||
|
WordPressTokenInvalidError,
|
||||||
|
WordPressUserInfo,
|
||||||
|
WebsiteNotFoundError,
|
||||||
|
SyncStats,
|
||||||
|
fetch_wordpress_users,
|
||||||
|
get_or_create_user,
|
||||||
|
get_wordpress_user,
|
||||||
|
sync_wordpress_users,
|
||||||
|
verify_website_exists,
|
||||||
|
verify_wordpress_token,
|
||||||
|
)
|
||||||
|
from app.services.ai_generation import (
|
||||||
|
call_openrouter_api,
|
||||||
|
check_cache_reuse,
|
||||||
|
generate_question,
|
||||||
|
generate_with_cache_check,
|
||||||
|
get_ai_stats,
|
||||||
|
get_prompt_template,
|
||||||
|
parse_ai_response,
|
||||||
|
save_ai_question,
|
||||||
|
validate_ai_model,
|
||||||
|
SUPPORTED_MODELS,
|
||||||
|
)
|
||||||
|
from app.services.reporting import (
|
||||||
|
generate_student_performance_report,
|
||||||
|
generate_item_analysis_report,
|
||||||
|
generate_calibration_status_report,
|
||||||
|
generate_tryout_comparison_report,
|
||||||
|
export_report_to_csv,
|
||||||
|
export_report_to_excel,
|
||||||
|
export_report_to_pdf,
|
||||||
|
schedule_report,
|
||||||
|
get_scheduled_report,
|
||||||
|
list_scheduled_reports,
|
||||||
|
cancel_scheduled_report,
|
||||||
|
StudentPerformanceReport,
|
||||||
|
ItemAnalysisReport,
|
||||||
|
CalibrationStatusReport,
|
||||||
|
TryoutComparisonReport,
|
||||||
|
ReportSchedule,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# IRT Calibration
|
||||||
|
"IRTCalibrationError",
|
||||||
|
"calculate_fisher_information",
|
||||||
|
"calculate_item_information",
|
||||||
|
"calculate_probability",
|
||||||
|
"calculate_theta_se",
|
||||||
|
"estimate_b_from_ctt_p",
|
||||||
|
"estimate_theta_mle",
|
||||||
|
"get_session_responses",
|
||||||
|
"nn_to_theta",
|
||||||
|
"theta_to_nn",
|
||||||
|
"update_session_theta",
|
||||||
|
"update_theta_after_response",
|
||||||
|
# CAT Selection
|
||||||
|
"CATSelectionError",
|
||||||
|
"NextItemResult",
|
||||||
|
"TerminationCheck",
|
||||||
|
"check_user_level_reuse",
|
||||||
|
"get_available_levels_for_slot",
|
||||||
|
"get_next_item",
|
||||||
|
"get_next_item_adaptive",
|
||||||
|
"get_next_item_fixed",
|
||||||
|
"get_next_item_hybrid",
|
||||||
|
"should_terminate",
|
||||||
|
"simulate_cat_selection",
|
||||||
|
"update_theta",
|
||||||
|
# WordPress Auth
|
||||||
|
"WordPressAPIError",
|
||||||
|
"WordPressAuthError",
|
||||||
|
"WordPressRateLimitError",
|
||||||
|
"WordPressTokenInvalidError",
|
||||||
|
"WordPressUserInfo",
|
||||||
|
"WebsiteNotFoundError",
|
||||||
|
"SyncStats",
|
||||||
|
"fetch_wordpress_users",
|
||||||
|
"get_or_create_user",
|
||||||
|
"get_wordpress_user",
|
||||||
|
"sync_wordpress_users",
|
||||||
|
"verify_website_exists",
|
||||||
|
"verify_wordpress_token",
|
||||||
|
# AI Generation
|
||||||
|
"call_openrouter_api",
|
||||||
|
"check_cache_reuse",
|
||||||
|
"generate_question",
|
||||||
|
"generate_with_cache_check",
|
||||||
|
"get_ai_stats",
|
||||||
|
"get_prompt_template",
|
||||||
|
"parse_ai_response",
|
||||||
|
"save_ai_question",
|
||||||
|
"validate_ai_model",
|
||||||
|
"SUPPORTED_MODELS",
|
||||||
|
# Reporting
|
||||||
|
"generate_student_performance_report",
|
||||||
|
"generate_item_analysis_report",
|
||||||
|
"generate_calibration_status_report",
|
||||||
|
"generate_tryout_comparison_report",
|
||||||
|
"export_report_to_csv",
|
||||||
|
"export_report_to_excel",
|
||||||
|
"export_report_to_pdf",
|
||||||
|
"schedule_report",
|
||||||
|
"get_scheduled_report",
|
||||||
|
"list_scheduled_reports",
|
||||||
|
"cancel_scheduled_report",
|
||||||
|
"StudentPerformanceReport",
|
||||||
|
"ItemAnalysisReport",
|
||||||
|
"CalibrationStatusReport",
|
||||||
|
"TryoutComparisonReport",
|
||||||
|
"ReportSchedule",
|
||||||
|
]
|
||||||
595
app/services/ai_generation.py
Normal file
595
app/services/ai_generation.py
Normal file
@@ -0,0 +1,595 @@
|
|||||||
|
"""
|
||||||
|
AI Question Generation Service.
|
||||||
|
|
||||||
|
Handles OpenRouter API integration for generating question variants.
|
||||||
|
Implements caching, user-level reuse checking, and prompt engineering.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Any, Dict, Literal, Optional, Union
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import and_, func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.models.item import Item
|
||||||
|
from app.models.tryout import Tryout
|
||||||
|
from app.models.user_answer import UserAnswer
|
||||||
|
from app.schemas.ai import GeneratedQuestion
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# OpenRouter API configuration
|
||||||
|
OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions"
|
||||||
|
|
||||||
|
# Supported AI models
|
||||||
|
SUPPORTED_MODELS = {
|
||||||
|
"qwen/qwen-2.5-coder-32b-instruct": "Qwen 2.5 Coder 32B",
|
||||||
|
"meta-llama/llama-3.3-70b-instruct": "Llama 3.3 70B",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Level mapping for prompts
|
||||||
|
LEVEL_DESCRIPTIONS = {
|
||||||
|
"mudah": "easier (simpler concepts, more straightforward calculations)",
|
||||||
|
"sedang": "medium difficulty",
|
||||||
|
"sulit": "harder (more complex concepts, multi-step reasoning)",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_prompt_template(
|
||||||
|
basis_stem: str,
|
||||||
|
basis_options: Dict[str, str],
|
||||||
|
basis_correct: str,
|
||||||
|
basis_explanation: Optional[str],
|
||||||
|
target_level: Literal["mudah", "sulit"],
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Generate standardized prompt for AI question generation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
basis_stem: The basis question stem
|
||||||
|
basis_options: The basis question options
|
||||||
|
basis_correct: The basis correct answer
|
||||||
|
basis_explanation: The basis explanation
|
||||||
|
target_level: Target difficulty level
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted prompt string
|
||||||
|
"""
|
||||||
|
level_desc = LEVEL_DESCRIPTIONS.get(target_level, target_level)
|
||||||
|
|
||||||
|
options_text = "\n".join(
|
||||||
|
[f" {key}: {value}" for key, value in basis_options.items()]
|
||||||
|
)
|
||||||
|
|
||||||
|
explanation_text = (
|
||||||
|
f"Explanation: {basis_explanation}"
|
||||||
|
if basis_explanation
|
||||||
|
else "Explanation: (not provided)"
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt = f"""You are an educational content creator specializing in creating assessment questions.
|
||||||
|
|
||||||
|
Given a "Sedang" (medium difficulty) question, generate a new question at a different difficulty level.
|
||||||
|
|
||||||
|
BASIS QUESTION (Sedang level):
|
||||||
|
Question: {basis_stem}
|
||||||
|
Options:
|
||||||
|
{options_text}
|
||||||
|
Correct Answer: {basis_correct}
|
||||||
|
{explanation_text}
|
||||||
|
|
||||||
|
TASK:
|
||||||
|
Generate 1 new question that is {level_desc} than the basis question above.
|
||||||
|
|
||||||
|
REQUIREMENTS:
|
||||||
|
1. Keep the SAME topic/subject matter as the basis question
|
||||||
|
2. Use similar context and terminology
|
||||||
|
3. Create exactly 4 answer options (A, B, C, D)
|
||||||
|
4. Only ONE correct answer
|
||||||
|
5. Include a clear explanation of why the correct answer is correct
|
||||||
|
6. Make the question noticeably {level_desc} - not just a minor variation
|
||||||
|
|
||||||
|
OUTPUT FORMAT:
|
||||||
|
Return ONLY a valid JSON object with this exact structure (no markdown, no code blocks):
|
||||||
|
{{"stem": "Your question text here", "options": {{"A": "Option A text", "B": "Option B text", "C": "Option C text", "D": "Option D text"}}, "correct": "A", "explanation": "Explanation text here"}}
|
||||||
|
|
||||||
|
Remember: The correct field must be exactly "A", "B", "C", or "D"."""
|
||||||
|
|
||||||
|
return prompt
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ai_response(response_text: str) -> Optional[GeneratedQuestion]:
|
||||||
|
"""
|
||||||
|
Parse AI response to extract question data.
|
||||||
|
|
||||||
|
Handles various response formats including JSON code blocks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
response_text: Raw AI response text
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
GeneratedQuestion if parsing successful, None otherwise
|
||||||
|
"""
|
||||||
|
if not response_text:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Clean the response text
|
||||||
|
cleaned = response_text.strip()
|
||||||
|
|
||||||
|
# Try to extract JSON from code blocks if present
|
||||||
|
json_patterns = [
|
||||||
|
r"```json\s*([\s\S]*?)\s*```", # ```json ... ```
|
||||||
|
r"```\s*([\s\S]*?)\s*```", # ``` ... ```
|
||||||
|
r"(\{[\s\S]*\})", # Raw JSON object
|
||||||
|
]
|
||||||
|
|
||||||
|
for pattern in json_patterns:
|
||||||
|
match = re.search(pattern, cleaned)
|
||||||
|
if match:
|
||||||
|
json_str = match.group(1).strip()
|
||||||
|
try:
|
||||||
|
data = json.loads(json_str)
|
||||||
|
return validate_and_create_question(data)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Try parsing the entire response as JSON
|
||||||
|
try:
|
||||||
|
data = json.loads(cleaned)
|
||||||
|
return validate_and_create_question(data)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.warning(f"Failed to parse AI response: {cleaned[:200]}...")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_and_create_question(data: Dict[str, Any]) -> Optional[GeneratedQuestion]:
|
||||||
|
"""
|
||||||
|
Validate parsed data and create GeneratedQuestion.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Parsed JSON data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
GeneratedQuestion if valid, None otherwise
|
||||||
|
"""
|
||||||
|
required_fields = ["stem", "options", "correct"]
|
||||||
|
if not all(field in data for field in required_fields):
|
||||||
|
logger.warning(f"Missing required fields in AI response: {data.keys()}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Validate options
|
||||||
|
options = data.get("options", {})
|
||||||
|
if not isinstance(options, dict):
|
||||||
|
logger.warning("Options is not a dictionary")
|
||||||
|
return None
|
||||||
|
|
||||||
|
required_options = {"A", "B", "C", "D"}
|
||||||
|
if not required_options.issubset(set(options.keys())):
|
||||||
|
logger.warning(f"Missing required options: {required_options - set(options.keys())}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Validate correct answer
|
||||||
|
correct = str(data.get("correct", "")).upper()
|
||||||
|
if correct not in required_options:
|
||||||
|
logger.warning(f"Invalid correct answer: {correct}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return GeneratedQuestion(
|
||||||
|
stem=str(data["stem"]).strip(),
|
||||||
|
options={k: str(v).strip() for k, v in options.items()},
|
||||||
|
correct=correct,
|
||||||
|
explanation=str(data.get("explanation", "")).strip() or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def call_openrouter_api(
|
||||||
|
prompt: str,
|
||||||
|
model: str,
|
||||||
|
max_retries: int = 3,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Call OpenRouter API to generate question.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: The prompt to send
|
||||||
|
model: AI model to use
|
||||||
|
max_retries: Maximum retry attempts
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
API response text or None if failed
|
||||||
|
"""
|
||||||
|
if not settings.OPENROUTER_API_KEY:
|
||||||
|
logger.error("OPENROUTER_API_KEY not configured")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if model not in SUPPORTED_MODELS:
|
||||||
|
logger.error(f"Unsupported AI model: {model}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {settings.OPENROUTER_API_KEY}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"HTTP-Referer": "https://github.com/irt-bank-soal",
|
||||||
|
"X-Title": "IRT Bank Soal",
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": prompt,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"max_tokens": 2000,
|
||||||
|
"temperature": 0.7,
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout = httpx.Timeout(settings.OPENROUTER_TIMEOUT)
|
||||||
|
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
response = await client.post(
|
||||||
|
OPENROUTER_API_URL,
|
||||||
|
headers=headers,
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
choices = data.get("choices", [])
|
||||||
|
if choices:
|
||||||
|
message = choices[0].get("message", {})
|
||||||
|
return message.get("content")
|
||||||
|
logger.warning("No choices in OpenRouter response")
|
||||||
|
return None
|
||||||
|
|
||||||
|
elif response.status_code == 429:
|
||||||
|
# Rate limited - wait and retry
|
||||||
|
logger.warning(f"Rate limited, attempt {attempt + 1}/{max_retries}")
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
import asyncio
|
||||||
|
await asyncio.sleep(2 ** attempt)
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
f"OpenRouter API error: {response.status_code} - {response.text}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
logger.warning(f"OpenRouter timeout, attempt {attempt + 1}/{max_retries}")
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"OpenRouter API call failed: {e}")
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_question(
|
||||||
|
basis_item: Item,
|
||||||
|
target_level: Literal["mudah", "sulit"],
|
||||||
|
ai_model: str = "qwen/qwen-2.5-coder-32b-instruct",
|
||||||
|
) -> Optional[GeneratedQuestion]:
|
||||||
|
"""
|
||||||
|
Generate a new question based on a basis item.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
basis_item: The basis item (must be sedang level)
|
||||||
|
target_level: Target difficulty level
|
||||||
|
ai_model: AI model to use
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
GeneratedQuestion if successful, None otherwise
|
||||||
|
"""
|
||||||
|
# Build prompt
|
||||||
|
prompt = get_prompt_template(
|
||||||
|
basis_stem=basis_item.stem,
|
||||||
|
basis_options=basis_item.options,
|
||||||
|
basis_correct=basis_item.correct_answer,
|
||||||
|
basis_explanation=basis_item.explanation,
|
||||||
|
target_level=target_level,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Call OpenRouter API
|
||||||
|
response_text = await call_openrouter_api(prompt, ai_model)
|
||||||
|
|
||||||
|
if not response_text:
|
||||||
|
logger.error("No response from OpenRouter API")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Parse response
|
||||||
|
generated = parse_ai_response(response_text)
|
||||||
|
|
||||||
|
if not generated:
|
||||||
|
logger.error("Failed to parse AI response")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return generated
|
||||||
|
|
||||||
|
|
||||||
|
async def check_cache_reuse(
|
||||||
|
tryout_id: str,
|
||||||
|
slot: int,
|
||||||
|
level: str,
|
||||||
|
wp_user_id: str,
|
||||||
|
website_id: int,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> Optional[Item]:
|
||||||
|
"""
|
||||||
|
Check if there's a cached item that the user hasn't answered yet.
|
||||||
|
|
||||||
|
Query DB for existing item matching (tryout_id, slot, level).
|
||||||
|
Check if user already answered this item at this difficulty level.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
slot: Question slot
|
||||||
|
level: Difficulty level
|
||||||
|
wp_user_id: WordPress user ID
|
||||||
|
website_id: Website identifier
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Cached item if found and user hasn't answered, None otherwise
|
||||||
|
"""
|
||||||
|
# Find existing items at this slot/level
|
||||||
|
result = await db.execute(
|
||||||
|
select(Item).where(
|
||||||
|
and_(
|
||||||
|
Item.tryout_id == tryout_id,
|
||||||
|
Item.website_id == website_id,
|
||||||
|
Item.slot == slot,
|
||||||
|
Item.level == level,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
existing_items = result.scalars().all()
|
||||||
|
|
||||||
|
if not existing_items:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check each item to find one the user hasn't answered
|
||||||
|
for item in existing_items:
|
||||||
|
# Check if user has answered this item
|
||||||
|
answer_result = await db.execute(
|
||||||
|
select(UserAnswer).where(
|
||||||
|
and_(
|
||||||
|
UserAnswer.item_id == item.id,
|
||||||
|
UserAnswer.wp_user_id == wp_user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
user_answer = answer_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if user_answer is None:
|
||||||
|
# User hasn't answered this item - can reuse
|
||||||
|
logger.info(
|
||||||
|
f"Cache hit for tryout={tryout_id}, slot={slot}, level={level}, "
|
||||||
|
f"item_id={item.id}, user={wp_user_id}"
|
||||||
|
)
|
||||||
|
return item
|
||||||
|
|
||||||
|
# All items have been answered by this user
|
||||||
|
logger.info(
|
||||||
|
f"Cache miss (user answered all) for tryout={tryout_id}, slot={slot}, "
|
||||||
|
f"level={level}, user={wp_user_id}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_with_cache_check(
|
||||||
|
tryout_id: str,
|
||||||
|
slot: int,
|
||||||
|
level: Literal["mudah", "sulit"],
|
||||||
|
wp_user_id: str,
|
||||||
|
website_id: int,
|
||||||
|
db: AsyncSession,
|
||||||
|
ai_model: str = "qwen/qwen-2.5-coder-32b-instruct",
|
||||||
|
) -> tuple[Optional[Union[Item, GeneratedQuestion]], bool]:
|
||||||
|
"""
|
||||||
|
Generate question with cache checking.
|
||||||
|
|
||||||
|
First checks if AI generation is enabled for the tryout.
|
||||||
|
Then checks for cached items the user hasn't answered.
|
||||||
|
If cache miss, generates new question via AI.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
slot: Question slot
|
||||||
|
level: Target difficulty level
|
||||||
|
wp_user_id: WordPress user ID
|
||||||
|
website_id: Website identifier
|
||||||
|
db: Database session
|
||||||
|
ai_model: AI model to use
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (item/question or None, is_cached)
|
||||||
|
"""
|
||||||
|
# Check if AI generation is enabled for this tryout
|
||||||
|
tryout_result = await db.execute(
|
||||||
|
select(Tryout).where(
|
||||||
|
and_(
|
||||||
|
Tryout.tryout_id == tryout_id,
|
||||||
|
Tryout.website_id == website_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tryout = tryout_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if tryout and not tryout.ai_generation_enabled:
|
||||||
|
logger.info(f"AI generation disabled for tryout={tryout_id}")
|
||||||
|
# Still check cache even if AI disabled
|
||||||
|
cached_item = await check_cache_reuse(
|
||||||
|
tryout_id, slot, level, wp_user_id, website_id, db
|
||||||
|
)
|
||||||
|
if cached_item:
|
||||||
|
return cached_item, True
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
# Check cache for reusable item
|
||||||
|
cached_item = await check_cache_reuse(
|
||||||
|
tryout_id, slot, level, wp_user_id, website_id, db
|
||||||
|
)
|
||||||
|
|
||||||
|
if cached_item:
|
||||||
|
return cached_item, True
|
||||||
|
|
||||||
|
# Cache miss - need to generate
|
||||||
|
# Get basis item (sedang level at same slot)
|
||||||
|
basis_result = await db.execute(
|
||||||
|
select(Item).where(
|
||||||
|
and_(
|
||||||
|
Item.tryout_id == tryout_id,
|
||||||
|
Item.website_id == website_id,
|
||||||
|
Item.slot == slot,
|
||||||
|
Item.level == "sedang",
|
||||||
|
)
|
||||||
|
).limit(1)
|
||||||
|
)
|
||||||
|
basis_item = basis_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not basis_item:
|
||||||
|
logger.error(
|
||||||
|
f"No basis item found for tryout={tryout_id}, slot={slot}"
|
||||||
|
)
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
# Generate new question
|
||||||
|
generated = await generate_question(basis_item, level, ai_model)
|
||||||
|
|
||||||
|
if not generated:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to generate question for tryout={tryout_id}, slot={slot}, level={level}"
|
||||||
|
)
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
return generated, False
|
||||||
|
|
||||||
|
|
||||||
|
async def save_ai_question(
|
||||||
|
generated_data: GeneratedQuestion,
|
||||||
|
tryout_id: str,
|
||||||
|
website_id: int,
|
||||||
|
basis_item_id: int,
|
||||||
|
slot: int,
|
||||||
|
level: Literal["mudah", "sedang", "sulit"],
|
||||||
|
ai_model: str,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> Optional[int]:
|
||||||
|
"""
|
||||||
|
Save AI-generated question to database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
generated_data: Generated question data
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
website_id: Website identifier
|
||||||
|
basis_item_id: Basis item ID
|
||||||
|
slot: Question slot
|
||||||
|
level: Difficulty level
|
||||||
|
ai_model: AI model used
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created item ID or None if failed
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
new_item = Item(
|
||||||
|
tryout_id=tryout_id,
|
||||||
|
website_id=website_id,
|
||||||
|
slot=slot,
|
||||||
|
level=level,
|
||||||
|
stem=generated_data.stem,
|
||||||
|
options=generated_data.options,
|
||||||
|
correct_answer=generated_data.correct,
|
||||||
|
explanation=generated_data.explanation,
|
||||||
|
generated_by="ai",
|
||||||
|
ai_model=ai_model,
|
||||||
|
basis_item_id=basis_item_id,
|
||||||
|
calibrated=False,
|
||||||
|
ctt_p=None,
|
||||||
|
ctt_bobot=None,
|
||||||
|
ctt_category=None,
|
||||||
|
irt_b=None,
|
||||||
|
irt_se=None,
|
||||||
|
calibration_sample_size=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(new_item)
|
||||||
|
await db.flush() # Get the ID without committing
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Saved AI-generated item: id={new_item.id}, tryout={tryout_id}, "
|
||||||
|
f"slot={slot}, level={level}, model={ai_model}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return new_item.id
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to save AI-generated question: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_ai_stats(db: AsyncSession) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get AI generation statistics.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Statistics dictionary
|
||||||
|
"""
|
||||||
|
# Total AI-generated items
|
||||||
|
total_result = await db.execute(
|
||||||
|
select(func.count(Item.id)).where(Item.generated_by == "ai")
|
||||||
|
)
|
||||||
|
total_ai_items = total_result.scalar() or 0
|
||||||
|
|
||||||
|
# Items by model
|
||||||
|
model_result = await db.execute(
|
||||||
|
select(Item.ai_model, func.count(Item.id))
|
||||||
|
.where(Item.generated_by == "ai")
|
||||||
|
.where(Item.ai_model.isnot(None))
|
||||||
|
.group_by(Item.ai_model)
|
||||||
|
)
|
||||||
|
items_by_model = {row[0]: row[1] for row in model_result.all()}
|
||||||
|
|
||||||
|
# Note: Cache hit rate would need to be tracked separately
|
||||||
|
# This is a placeholder for now
|
||||||
|
return {
|
||||||
|
"total_ai_items": total_ai_items,
|
||||||
|
"items_by_model": items_by_model,
|
||||||
|
"cache_hit_rate": 0.0,
|
||||||
|
"total_cache_hits": 0,
|
||||||
|
"total_requests": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_ai_model(model: str) -> bool:
|
||||||
|
"""
|
||||||
|
Validate that the AI model is supported.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model: AI model identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if model is supported
|
||||||
|
"""
|
||||||
|
return model in SUPPORTED_MODELS
|
||||||
702
app/services/cat_selection.py
Normal file
702
app/services/cat_selection.py
Normal file
@@ -0,0 +1,702 @@
|
|||||||
|
"""
|
||||||
|
CAT (Computerized Adaptive Testing) Selection Service.
|
||||||
|
|
||||||
|
Implements adaptive item selection algorithms for IRT-based testing.
|
||||||
|
Supports three modes: CTT (fixed), IRT (adaptive), and hybrid.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import and_, not_, or_, select, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.models import Item, Session, Tryout, UserAnswer
|
||||||
|
from app.services.irt_calibration import (
|
||||||
|
calculate_item_information,
|
||||||
|
estimate_b_from_ctt_p,
|
||||||
|
estimate_theta_mle,
|
||||||
|
update_theta_after_response,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CATSelectionError(Exception):
|
||||||
|
"""Exception raised for CAT selection errors."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NextItemResult:
|
||||||
|
"""Result of next item selection."""
|
||||||
|
item: Optional[Item]
|
||||||
|
selection_method: str # 'fixed', 'adaptive', 'hybrid'
|
||||||
|
slot: Optional[int]
|
||||||
|
level: Optional[str]
|
||||||
|
reason: str # Why this item was selected
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TerminationCheck:
|
||||||
|
"""Result of termination condition check."""
|
||||||
|
should_terminate: bool
|
||||||
|
reason: str
|
||||||
|
items_answered: int
|
||||||
|
current_se: Optional[float]
|
||||||
|
max_items: Optional[int]
|
||||||
|
se_threshold_met: bool
|
||||||
|
|
||||||
|
|
||||||
|
# Default SE threshold for termination
|
||||||
|
DEFAULT_SE_THRESHOLD = 0.5
|
||||||
|
# Default max items if not configured
|
||||||
|
DEFAULT_MAX_ITEMS = 50
|
||||||
|
|
||||||
|
|
||||||
|
async def get_next_item_fixed(
|
||||||
|
db: AsyncSession,
|
||||||
|
session_id: str,
|
||||||
|
tryout_id: str,
|
||||||
|
website_id: int,
|
||||||
|
level_filter: Optional[str] = None
|
||||||
|
) -> NextItemResult:
|
||||||
|
"""
|
||||||
|
Get next item in fixed order (CTT mode).
|
||||||
|
|
||||||
|
Returns items in slot order (1, 2, 3, ...).
|
||||||
|
Filters by level if specified.
|
||||||
|
Checks if student already answered this item.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
session_id: Session identifier
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
website_id: Website identifier
|
||||||
|
level_filter: Optional difficulty level filter ('mudah', 'sedang', 'sulit')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
NextItemResult with selected item or None if no more items
|
||||||
|
"""
|
||||||
|
# Get session to find current position and answered items
|
||||||
|
session_query = select(Session).where(Session.session_id == session_id)
|
||||||
|
session_result = await db.execute(session_query)
|
||||||
|
session = session_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not session:
|
||||||
|
raise CATSelectionError(f"Session {session_id} not found")
|
||||||
|
|
||||||
|
# Get all item IDs already answered by this user in this session
|
||||||
|
answered_query = select(UserAnswer.item_id).where(
|
||||||
|
UserAnswer.session_id == session_id
|
||||||
|
)
|
||||||
|
answered_result = await db.execute(answered_query)
|
||||||
|
answered_item_ids = [row[0] for row in answered_result.all()]
|
||||||
|
|
||||||
|
# Build query for available items
|
||||||
|
query = (
|
||||||
|
select(Item)
|
||||||
|
.where(
|
||||||
|
Item.tryout_id == tryout_id,
|
||||||
|
Item.website_id == website_id
|
||||||
|
)
|
||||||
|
.order_by(Item.slot, Item.level)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply level filter if specified
|
||||||
|
if level_filter:
|
||||||
|
query = query.where(Item.level == level_filter)
|
||||||
|
|
||||||
|
# Exclude already answered items
|
||||||
|
if answered_item_ids:
|
||||||
|
query = query.where(not_(Item.id.in_(answered_item_ids)))
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
items = result.scalars().all()
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
return NextItemResult(
|
||||||
|
item=None,
|
||||||
|
selection_method="fixed",
|
||||||
|
slot=None,
|
||||||
|
level=None,
|
||||||
|
reason="No more items available"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return first available item (lowest slot)
|
||||||
|
next_item = items[0]
|
||||||
|
|
||||||
|
return NextItemResult(
|
||||||
|
item=next_item,
|
||||||
|
selection_method="fixed",
|
||||||
|
slot=next_item.slot,
|
||||||
|
level=next_item.level,
|
||||||
|
reason=f"Fixed order selection - slot {next_item.slot}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_next_item_adaptive(
|
||||||
|
db: AsyncSession,
|
||||||
|
session_id: str,
|
||||||
|
tryout_id: str,
|
||||||
|
website_id: int,
|
||||||
|
ai_generation_enabled: bool = False,
|
||||||
|
level_filter: Optional[str] = None
|
||||||
|
) -> NextItemResult:
|
||||||
|
"""
|
||||||
|
Get next item using adaptive selection (IRT mode).
|
||||||
|
|
||||||
|
Finds item where b ≈ current theta.
|
||||||
|
Only uses calibrated items (calibrated=True).
|
||||||
|
Filters: student hasn't answered this item.
|
||||||
|
Filters: AI-generated items only if AI generation is enabled.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
session_id: Session identifier
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
website_id: Website identifier
|
||||||
|
ai_generation_enabled: Whether to include AI-generated items
|
||||||
|
level_filter: Optional difficulty level filter
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
NextItemResult with selected item or None if no suitable items
|
||||||
|
"""
|
||||||
|
# Get session for current theta
|
||||||
|
session_query = select(Session).where(Session.session_id == session_id)
|
||||||
|
session_result = await db.execute(session_query)
|
||||||
|
session = session_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not session:
|
||||||
|
raise CATSelectionError(f"Session {session_id} not found")
|
||||||
|
|
||||||
|
# Get current theta (default to 0.0 for first item)
|
||||||
|
current_theta = session.theta if session.theta is not None else 0.0
|
||||||
|
|
||||||
|
# Get all item IDs already answered by this user in this session
|
||||||
|
answered_query = select(UserAnswer.item_id).where(
|
||||||
|
UserAnswer.session_id == session_id
|
||||||
|
)
|
||||||
|
answered_result = await db.execute(answered_query)
|
||||||
|
answered_item_ids = [row[0] for row in answered_result.all()]
|
||||||
|
|
||||||
|
# Build query for available calibrated items
|
||||||
|
query = (
|
||||||
|
select(Item)
|
||||||
|
.where(
|
||||||
|
Item.tryout_id == tryout_id,
|
||||||
|
Item.website_id == website_id,
|
||||||
|
Item.calibrated == True # Only calibrated items for IRT
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply level filter if specified
|
||||||
|
if level_filter:
|
||||||
|
query = query.where(Item.level == level_filter)
|
||||||
|
|
||||||
|
# Exclude already answered items
|
||||||
|
if answered_item_ids:
|
||||||
|
query = query.where(not_(Item.id.in_(answered_item_ids)))
|
||||||
|
|
||||||
|
# Filter AI-generated items if AI generation is disabled
|
||||||
|
if not ai_generation_enabled:
|
||||||
|
query = query.where(Item.generated_by == 'manual')
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
items = result.scalars().all()
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
return NextItemResult(
|
||||||
|
item=None,
|
||||||
|
selection_method="adaptive",
|
||||||
|
slot=None,
|
||||||
|
level=None,
|
||||||
|
reason="No calibrated items available"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find item with b closest to current theta
|
||||||
|
# Also consider item information (prefer items with higher information at current theta)
|
||||||
|
best_item = None
|
||||||
|
best_score = float('inf')
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
if item.irt_b is None:
|
||||||
|
# Skip items without b parameter (shouldn't happen with calibrated=True)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Calculate distance from theta
|
||||||
|
b_distance = abs(item.irt_b - current_theta)
|
||||||
|
|
||||||
|
# Calculate item information at current theta
|
||||||
|
information = calculate_item_information(current_theta, item.irt_b)
|
||||||
|
|
||||||
|
# Score: minimize distance, maximize information
|
||||||
|
# Use weighted combination: lower score is better
|
||||||
|
# Add small penalty for lower information
|
||||||
|
score = b_distance - (0.1 * information)
|
||||||
|
|
||||||
|
if score < best_score:
|
||||||
|
best_score = score
|
||||||
|
best_item = item
|
||||||
|
|
||||||
|
if not best_item:
|
||||||
|
return NextItemResult(
|
||||||
|
item=None,
|
||||||
|
selection_method="adaptive",
|
||||||
|
slot=None,
|
||||||
|
level=None,
|
||||||
|
reason="No items with valid IRT parameters available"
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextItemResult(
|
||||||
|
item=best_item,
|
||||||
|
selection_method="adaptive",
|
||||||
|
slot=best_item.slot,
|
||||||
|
level=best_item.level,
|
||||||
|
reason=f"Adaptive selection - b={best_item.irt_b:.3f} ≈ θ={current_theta:.3f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_next_item_hybrid(
|
||||||
|
db: AsyncSession,
|
||||||
|
session_id: str,
|
||||||
|
tryout_id: str,
|
||||||
|
website_id: int,
|
||||||
|
hybrid_transition_slot: int = 10,
|
||||||
|
ai_generation_enabled: bool = False,
|
||||||
|
level_filter: Optional[str] = None
|
||||||
|
) -> NextItemResult:
|
||||||
|
"""
|
||||||
|
Get next item using hybrid selection.
|
||||||
|
|
||||||
|
Uses fixed order for first N items, then switches to adaptive.
|
||||||
|
Falls back to CTT if no calibrated items available.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
session_id: Session identifier
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
website_id: Website identifier
|
||||||
|
hybrid_transition_slot: Slot number to transition from fixed to adaptive
|
||||||
|
ai_generation_enabled: Whether to include AI-generated items
|
||||||
|
level_filter: Optional difficulty level filter
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
NextItemResult with selected item or None if no items available
|
||||||
|
"""
|
||||||
|
# Get session to check current position
|
||||||
|
session_query = select(Session).where(Session.session_id == session_id)
|
||||||
|
session_result = await db.execute(session_query)
|
||||||
|
session = session_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not session:
|
||||||
|
raise CATSelectionError(f"Session {session_id} not found")
|
||||||
|
|
||||||
|
# Count answered items to determine current position
|
||||||
|
count_query = select(func.count(UserAnswer.id)).where(
|
||||||
|
UserAnswer.session_id == session_id
|
||||||
|
)
|
||||||
|
count_result = await db.execute(count_query)
|
||||||
|
items_answered = count_result.scalar() or 0
|
||||||
|
|
||||||
|
# Determine current slot (next slot to fill)
|
||||||
|
current_slot = items_answered + 1
|
||||||
|
|
||||||
|
# Check if we're still in fixed phase
|
||||||
|
if current_slot <= hybrid_transition_slot:
|
||||||
|
# Use fixed selection for initial items
|
||||||
|
result = await get_next_item_fixed(
|
||||||
|
db, session_id, tryout_id, website_id, level_filter
|
||||||
|
)
|
||||||
|
result.selection_method = "hybrid_fixed"
|
||||||
|
result.reason = f"Hybrid mode (fixed phase) - slot {current_slot}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Try adaptive selection
|
||||||
|
adaptive_result = await get_next_item_adaptive(
|
||||||
|
db, session_id, tryout_id, website_id, ai_generation_enabled, level_filter
|
||||||
|
)
|
||||||
|
|
||||||
|
if adaptive_result.item is not None:
|
||||||
|
adaptive_result.selection_method = "hybrid_adaptive"
|
||||||
|
adaptive_result.reason = f"Hybrid mode (adaptive phase) - {adaptive_result.reason}"
|
||||||
|
return adaptive_result
|
||||||
|
|
||||||
|
# Fallback to fixed selection if no calibrated items available
|
||||||
|
fixed_result = await get_next_item_fixed(
|
||||||
|
db, session_id, tryout_id, website_id, level_filter
|
||||||
|
)
|
||||||
|
fixed_result.selection_method = "hybrid_fallback"
|
||||||
|
fixed_result.reason = f"Hybrid mode (CTT fallback) - {fixed_result.reason}"
|
||||||
|
return fixed_result
|
||||||
|
|
||||||
|
|
||||||
|
async def update_theta(
|
||||||
|
db: AsyncSession,
|
||||||
|
session_id: str,
|
||||||
|
item_id: int,
|
||||||
|
is_correct: bool
|
||||||
|
) -> tuple[float, float]:
|
||||||
|
"""
|
||||||
|
Update session theta estimate based on response.
|
||||||
|
|
||||||
|
Calls estimate_theta from irt_calibration.py.
|
||||||
|
Updates session.theta and session.theta_se.
|
||||||
|
Handles initial theta (uses 0.0 for first item).
|
||||||
|
Clamps theta to [-3, +3].
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
session_id: Session identifier
|
||||||
|
item_id: Item that was answered
|
||||||
|
is_correct: Whether the answer was correct
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (theta, theta_se)
|
||||||
|
"""
|
||||||
|
return await update_theta_after_response(db, session_id, item_id, is_correct)
|
||||||
|
|
||||||
|
|
||||||
|
async def should_terminate(
|
||||||
|
db: AsyncSession,
|
||||||
|
session_id: str,
|
||||||
|
max_items: Optional[int] = None,
|
||||||
|
se_threshold: float = DEFAULT_SE_THRESHOLD
|
||||||
|
) -> TerminationCheck:
|
||||||
|
"""
|
||||||
|
Check if session should terminate.
|
||||||
|
|
||||||
|
Termination conditions:
|
||||||
|
- Reached max_items
|
||||||
|
- Reached SE threshold (theta_se < se_threshold)
|
||||||
|
- No more items available
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
session_id: Session identifier
|
||||||
|
max_items: Maximum items allowed (None = no limit)
|
||||||
|
se_threshold: SE threshold for termination
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TerminationCheck with termination status and reason
|
||||||
|
"""
|
||||||
|
# Get session
|
||||||
|
session_query = select(Session).where(Session.session_id == session_id)
|
||||||
|
session_result = await db.execute(session_query)
|
||||||
|
session = session_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not session:
|
||||||
|
raise CATSelectionError(f"Session {session_id} not found")
|
||||||
|
|
||||||
|
# Count answered items
|
||||||
|
count_query = select(func.count(UserAnswer.id)).where(
|
||||||
|
UserAnswer.session_id == session_id
|
||||||
|
)
|
||||||
|
count_result = await db.execute(count_query)
|
||||||
|
items_answered = count_result.scalar() or 0
|
||||||
|
|
||||||
|
# Check max items
|
||||||
|
max_items_reached = False
|
||||||
|
if max_items is not None and items_answered >= max_items:
|
||||||
|
max_items_reached = True
|
||||||
|
|
||||||
|
# Check SE threshold
|
||||||
|
current_se = session.theta_se
|
||||||
|
se_threshold_met = False
|
||||||
|
if current_se is not None and current_se < se_threshold:
|
||||||
|
se_threshold_met = True
|
||||||
|
|
||||||
|
# Check if we have enough items for SE threshold (at least 15 items per PRD)
|
||||||
|
min_items_for_se = 15
|
||||||
|
se_threshold_met = se_threshold_met and items_answered >= min_items_for_se
|
||||||
|
|
||||||
|
# Determine termination
|
||||||
|
should_term = max_items_reached or se_threshold_met
|
||||||
|
|
||||||
|
# Build reason
|
||||||
|
reasons = []
|
||||||
|
if max_items_reached:
|
||||||
|
reasons.append(f"max items reached ({items_answered}/{max_items})")
|
||||||
|
if se_threshold_met:
|
||||||
|
reasons.append(f"SE threshold met ({current_se:.3f} < {se_threshold})")
|
||||||
|
|
||||||
|
if not reasons:
|
||||||
|
reasons.append("continuing")
|
||||||
|
|
||||||
|
return TerminationCheck(
|
||||||
|
should_terminate=should_term,
|
||||||
|
reason="; ".join(reasons),
|
||||||
|
items_answered=items_answered,
|
||||||
|
current_se=current_se,
|
||||||
|
max_items=max_items,
|
||||||
|
se_threshold_met=se_threshold_met
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_next_item(
|
||||||
|
db: AsyncSession,
|
||||||
|
session_id: str,
|
||||||
|
selection_mode: Literal["fixed", "adaptive", "hybrid"] = "fixed",
|
||||||
|
hybrid_transition_slot: int = 10,
|
||||||
|
ai_generation_enabled: bool = False,
|
||||||
|
level_filter: Optional[str] = None
|
||||||
|
) -> NextItemResult:
|
||||||
|
"""
|
||||||
|
Get next item based on selection mode.
|
||||||
|
|
||||||
|
Main entry point for item selection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
session_id: Session identifier
|
||||||
|
selection_mode: Selection mode ('fixed', 'adaptive', 'hybrid')
|
||||||
|
hybrid_transition_slot: Slot to transition in hybrid mode
|
||||||
|
ai_generation_enabled: Whether AI generation is enabled
|
||||||
|
level_filter: Optional difficulty level filter
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
NextItemResult with selected item
|
||||||
|
"""
|
||||||
|
# Get session for tryout info
|
||||||
|
session_query = select(Session).where(Session.session_id == session_id)
|
||||||
|
session_result = await db.execute(session_query)
|
||||||
|
session = session_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not session:
|
||||||
|
raise CATSelectionError(f"Session {session_id} not found")
|
||||||
|
|
||||||
|
tryout_id = session.tryout_id
|
||||||
|
website_id = session.website_id
|
||||||
|
|
||||||
|
if selection_mode == "fixed":
|
||||||
|
return await get_next_item_fixed(
|
||||||
|
db, session_id, tryout_id, website_id, level_filter
|
||||||
|
)
|
||||||
|
elif selection_mode == "adaptive":
|
||||||
|
return await get_next_item_adaptive(
|
||||||
|
db, session_id, tryout_id, website_id, ai_generation_enabled, level_filter
|
||||||
|
)
|
||||||
|
elif selection_mode == "hybrid":
|
||||||
|
return await get_next_item_hybrid(
|
||||||
|
db, session_id, tryout_id, website_id,
|
||||||
|
hybrid_transition_slot, ai_generation_enabled, level_filter
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise CATSelectionError(f"Unknown selection mode: {selection_mode}")
|
||||||
|
|
||||||
|
|
||||||
|
async def check_user_level_reuse(
|
||||||
|
db: AsyncSession,
|
||||||
|
wp_user_id: str,
|
||||||
|
website_id: int,
|
||||||
|
tryout_id: str,
|
||||||
|
slot: int,
|
||||||
|
level: str
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Check if user has already answered a question at this difficulty level.
|
||||||
|
|
||||||
|
Per PRD FR-5.3: Check if student user_id already answered question
|
||||||
|
at specific difficulty level.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
wp_user_id: WordPress user ID
|
||||||
|
website_id: Website identifier
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
slot: Question slot
|
||||||
|
level: Difficulty level
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if user has answered at this level, False otherwise
|
||||||
|
"""
|
||||||
|
# Check if user has answered any item at this slot/level combination
|
||||||
|
query = (
|
||||||
|
select(func.count(UserAnswer.id))
|
||||||
|
.join(Item, UserAnswer.item_id == Item.id)
|
||||||
|
.where(
|
||||||
|
UserAnswer.wp_user_id == wp_user_id,
|
||||||
|
UserAnswer.website_id == website_id,
|
||||||
|
UserAnswer.tryout_id == tryout_id,
|
||||||
|
Item.slot == slot,
|
||||||
|
Item.level == level
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
count = result.scalar() or 0
|
||||||
|
|
||||||
|
return count > 0
|
||||||
|
|
||||||
|
|
||||||
|
async def get_available_levels_for_slot(
|
||||||
|
db: AsyncSession,
|
||||||
|
tryout_id: str,
|
||||||
|
website_id: int,
|
||||||
|
slot: int
|
||||||
|
) -> list[str]:
|
||||||
|
"""
|
||||||
|
Get available difficulty levels for a specific slot.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
website_id: Website identifier
|
||||||
|
slot: Question slot
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of available levels
|
||||||
|
"""
|
||||||
|
query = (
|
||||||
|
select(Item.level)
|
||||||
|
.where(
|
||||||
|
Item.tryout_id == tryout_id,
|
||||||
|
Item.website_id == website_id,
|
||||||
|
Item.slot == slot
|
||||||
|
)
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
levels = [row[0] for row in result.all()]
|
||||||
|
|
||||||
|
return levels
|
||||||
|
|
||||||
|
|
||||||
|
# Admin playground functions for testing CAT behavior
|
||||||
|
|
||||||
|
async def simulate_cat_selection(
|
||||||
|
db: AsyncSession,
|
||||||
|
tryout_id: str,
|
||||||
|
website_id: int,
|
||||||
|
initial_theta: float = 0.0,
|
||||||
|
selection_mode: Literal["fixed", "adaptive", "hybrid"] = "adaptive",
|
||||||
|
max_items: int = 15,
|
||||||
|
se_threshold: float = DEFAULT_SE_THRESHOLD,
|
||||||
|
hybrid_transition_slot: int = 10
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Simulate CAT selection for admin testing.
|
||||||
|
|
||||||
|
Returns sequence of selected items with b values and theta progression.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
website_id: Website identifier
|
||||||
|
initial_theta: Starting theta value
|
||||||
|
selection_mode: Selection mode to use
|
||||||
|
max_items: Maximum items to simulate
|
||||||
|
se_threshold: SE threshold for termination
|
||||||
|
hybrid_transition_slot: Slot to transition in hybrid mode
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with simulation results
|
||||||
|
"""
|
||||||
|
# Get all items for this tryout
|
||||||
|
items_query = (
|
||||||
|
select(Item)
|
||||||
|
.where(
|
||||||
|
Item.tryout_id == tryout_id,
|
||||||
|
Item.website_id == website_id
|
||||||
|
)
|
||||||
|
.order_by(Item.slot)
|
||||||
|
)
|
||||||
|
|
||||||
|
items_result = await db.execute(items_query)
|
||||||
|
all_items = list(items_result.scalars().all())
|
||||||
|
|
||||||
|
if not all_items:
|
||||||
|
return {
|
||||||
|
"error": "No items found for this tryout",
|
||||||
|
"tryout_id": tryout_id,
|
||||||
|
"website_id": website_id
|
||||||
|
}
|
||||||
|
|
||||||
|
# Simulate selection
|
||||||
|
selected_items = []
|
||||||
|
current_theta = initial_theta
|
||||||
|
current_se = 3.0 # Start with high uncertainty
|
||||||
|
used_item_ids = set()
|
||||||
|
|
||||||
|
for i in range(max_items):
|
||||||
|
# Get available items
|
||||||
|
available_items = [item for item in all_items if item.id not in used_item_ids]
|
||||||
|
|
||||||
|
if not available_items:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Select based on mode
|
||||||
|
if selection_mode == "adaptive":
|
||||||
|
# Filter to calibrated items only
|
||||||
|
calibrated_items = [item for item in available_items if item.calibrated and item.irt_b is not None]
|
||||||
|
|
||||||
|
if not calibrated_items:
|
||||||
|
# Fallback to any available item
|
||||||
|
calibrated_items = available_items
|
||||||
|
|
||||||
|
# Find item closest to current theta
|
||||||
|
best_item = min(
|
||||||
|
calibrated_items,
|
||||||
|
key=lambda item: abs((item.irt_b or 0) - current_theta)
|
||||||
|
)
|
||||||
|
elif selection_mode == "fixed":
|
||||||
|
# Select in slot order
|
||||||
|
best_item = min(available_items, key=lambda item: item.slot)
|
||||||
|
else: # hybrid
|
||||||
|
if i < hybrid_transition_slot:
|
||||||
|
best_item = min(available_items, key=lambda item: item.slot)
|
||||||
|
else:
|
||||||
|
calibrated_items = [item for item in available_items if item.calibrated and item.irt_b is not None]
|
||||||
|
if calibrated_items:
|
||||||
|
best_item = min(
|
||||||
|
calibrated_items,
|
||||||
|
key=lambda item: abs((item.irt_b or 0) - current_theta)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
best_item = min(available_items, key=lambda item: item.slot)
|
||||||
|
|
||||||
|
used_item_ids.add(best_item.id)
|
||||||
|
|
||||||
|
# Simulate response (random based on probability)
|
||||||
|
import random
|
||||||
|
b = best_item.irt_b or estimate_b_from_ctt_p(best_item.ctt_p) if best_item.ctt_p else 0.0
|
||||||
|
p_correct = 1.0 / (1.0 + math.exp(-(current_theta - b)))
|
||||||
|
is_correct = random.random() < p_correct
|
||||||
|
|
||||||
|
# Update theta (simplified)
|
||||||
|
responses = [1 if item.get('is_correct', True) else 0 for item in selected_items]
|
||||||
|
responses.append(1 if is_correct else 0)
|
||||||
|
b_params = [item['b'] for item in selected_items]
|
||||||
|
b_params.append(b)
|
||||||
|
|
||||||
|
new_theta, new_se = estimate_theta_mle(responses, b_params, current_theta)
|
||||||
|
current_theta = new_theta
|
||||||
|
current_se = new_se
|
||||||
|
|
||||||
|
selected_items.append({
|
||||||
|
"slot": best_item.slot,
|
||||||
|
"level": best_item.level,
|
||||||
|
"b": b,
|
||||||
|
"is_correct": is_correct,
|
||||||
|
"theta_after": current_theta,
|
||||||
|
"se_after": current_se,
|
||||||
|
"calibrated": best_item.calibrated
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check SE threshold
|
||||||
|
if current_se < se_threshold and i >= 14: # At least 15 items
|
||||||
|
break
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tryout_id": tryout_id,
|
||||||
|
"website_id": website_id,
|
||||||
|
"initial_theta": initial_theta,
|
||||||
|
"selection_mode": selection_mode,
|
||||||
|
"total_items": len(selected_items),
|
||||||
|
"final_theta": current_theta,
|
||||||
|
"final_se": current_se,
|
||||||
|
"se_threshold_met": current_se < se_threshold,
|
||||||
|
"items": selected_items
|
||||||
|
}
|
||||||
431
app/services/config_management.py
Normal file
431
app/services/config_management.py
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
"""
|
||||||
|
Configuration Management Service.
|
||||||
|
|
||||||
|
Provides functions to retrieve and update tryout configurations.
|
||||||
|
Handles configuration changes for scoring, selection, and normalization modes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, Literal, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.tryout import Tryout
|
||||||
|
from app.models.tryout_stats import TryoutStats
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_config(
|
||||||
|
db: AsyncSession,
|
||||||
|
website_id: int,
|
||||||
|
tryout_id: str,
|
||||||
|
) -> Tryout:
|
||||||
|
"""
|
||||||
|
Fetch tryout configuration for a specific tryout.
|
||||||
|
|
||||||
|
Returns all configuration fields including scoring_mode, selection_mode,
|
||||||
|
normalization_mode, and other settings.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Async database session
|
||||||
|
website_id: Website identifier
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tryout model with all configuration fields
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If tryout not found
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Tryout).where(
|
||||||
|
Tryout.website_id == website_id,
|
||||||
|
Tryout.tryout_id == tryout_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tryout = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if tryout is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"Tryout {tryout_id} not found for website {website_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return tryout
|
||||||
|
|
||||||
|
|
||||||
|
async def update_config(
|
||||||
|
db: AsyncSession,
|
||||||
|
website_id: int,
|
||||||
|
tryout_id: str,
|
||||||
|
config_updates: Dict[str, Any],
|
||||||
|
) -> Tryout:
|
||||||
|
"""
|
||||||
|
Update tryout configuration with provided fields.
|
||||||
|
|
||||||
|
Accepts a dictionary of configuration updates and applies them to the
|
||||||
|
tryout configuration. Only provided fields are updated.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Async database session
|
||||||
|
website_id: Website identifier
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
config_updates: Dictionary of configuration fields to update
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated Tryout model
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If tryout not found or invalid field provided
|
||||||
|
"""
|
||||||
|
# Fetch tryout
|
||||||
|
result = await db.execute(
|
||||||
|
select(Tryout).where(
|
||||||
|
Tryout.website_id == website_id,
|
||||||
|
Tryout.tryout_id == tryout_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tryout = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if tryout is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"Tryout {tryout_id} not found for website {website_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Valid configuration fields
|
||||||
|
valid_fields = {
|
||||||
|
"name", "description",
|
||||||
|
"scoring_mode", "selection_mode", "normalization_mode",
|
||||||
|
"min_sample_for_dynamic", "static_rataan", "static_sb",
|
||||||
|
"ai_generation_enabled",
|
||||||
|
"hybrid_transition_slot",
|
||||||
|
"min_calibration_sample", "theta_estimation_method", "fallback_to_ctt_on_error",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update only valid fields
|
||||||
|
updated_fields = []
|
||||||
|
for field, value in config_updates.items():
|
||||||
|
if field not in valid_fields:
|
||||||
|
logger.warning(f"Skipping invalid config field: {field}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
setattr(tryout, field, value)
|
||||||
|
updated_fields.append(field)
|
||||||
|
|
||||||
|
if not updated_fields:
|
||||||
|
logger.warning(f"No valid config fields to update for tryout {tryout_id}")
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Updated config for tryout {tryout_id}, website {website_id}: "
|
||||||
|
f"{', '.join(updated_fields)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return tryout
|
||||||
|
|
||||||
|
|
||||||
|
async def toggle_normalization_mode(
|
||||||
|
db: AsyncSession,
|
||||||
|
website_id: int,
|
||||||
|
tryout_id: str,
|
||||||
|
new_mode: Literal["static", "dynamic", "hybrid"],
|
||||||
|
) -> Tryout:
|
||||||
|
"""
|
||||||
|
Toggle normalization mode for a tryout.
|
||||||
|
|
||||||
|
Updates the normalization_mode field. If switching to "auto" (dynamic mode),
|
||||||
|
checks if threshold is met and logs appropriate warnings.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Async database session
|
||||||
|
website_id: Website identifier
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
new_mode: New normalization mode ("static", "dynamic", "hybrid")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated Tryout model
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If tryout not found or invalid mode provided
|
||||||
|
"""
|
||||||
|
if new_mode not in ["static", "dynamic", "hybrid"]:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid normalization_mode: {new_mode}. "
|
||||||
|
"Must be 'static', 'dynamic', or 'hybrid'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch tryout with stats
|
||||||
|
result = await db.execute(
|
||||||
|
select(Tryout).where(
|
||||||
|
Tryout.website_id == website_id,
|
||||||
|
Tryout.tryout_id == tryout_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tryout = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if tryout is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"Tryout {tryout_id} not found for website {website_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
old_mode = tryout.normalization_mode
|
||||||
|
tryout.normalization_mode = new_mode
|
||||||
|
|
||||||
|
# Fetch stats for participant count
|
||||||
|
stats_result = await db.execute(
|
||||||
|
select(TryoutStats).where(
|
||||||
|
TryoutStats.website_id == website_id,
|
||||||
|
TryoutStats.tryout_id == tryout_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stats = stats_result.scalar_one_or_none()
|
||||||
|
participant_count = stats.participant_count if stats else 0
|
||||||
|
min_sample = tryout.min_sample_for_dynamic
|
||||||
|
|
||||||
|
# Log warnings and suggestions based on mode change
|
||||||
|
if new_mode == "dynamic":
|
||||||
|
if participant_count < min_sample:
|
||||||
|
logger.warning(
|
||||||
|
f"Switching to dynamic normalization with only {participant_count} "
|
||||||
|
f"participants (threshold: {min_sample}). "
|
||||||
|
"Dynamic normalization may produce unreliable results."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
f"Switching to dynamic normalization with {participant_count} "
|
||||||
|
f"participants (threshold: {min_sample}). "
|
||||||
|
"Ready for dynamic normalization."
|
||||||
|
)
|
||||||
|
|
||||||
|
elif new_mode == "hybrid":
|
||||||
|
if participant_count >= min_sample:
|
||||||
|
logger.info(
|
||||||
|
f"Switching to hybrid normalization with {participant_count} "
|
||||||
|
f"participants (threshold: {min_sample}). "
|
||||||
|
"Will use dynamic normalization immediately."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
f"Switching to hybrid normalization with {participant_count} "
|
||||||
|
f"participants (threshold: {min_sample}). "
|
||||||
|
f"Will use static normalization until {min_sample} participants reached."
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Toggled normalization mode for tryout {tryout_id}, "
|
||||||
|
f"website {website_id}: {old_mode} -> {new_mode}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return tryout
|
||||||
|
|
||||||
|
|
||||||
|
async def get_normalization_config(
|
||||||
|
db: AsyncSession,
|
||||||
|
website_id: int,
|
||||||
|
tryout_id: str,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get normalization configuration summary.
|
||||||
|
|
||||||
|
Returns current normalization mode, static values, dynamic values,
|
||||||
|
participant count, and threshold status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Async database session
|
||||||
|
website_id: Website identifier
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with normalization configuration summary
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If tryout not found
|
||||||
|
"""
|
||||||
|
# Fetch tryout config
|
||||||
|
tryout = await get_config(db, website_id, tryout_id)
|
||||||
|
|
||||||
|
# Fetch stats
|
||||||
|
stats_result = await db.execute(
|
||||||
|
select(TryoutStats).where(
|
||||||
|
TryoutStats.website_id == website_id,
|
||||||
|
TryoutStats.tryout_id == tryout_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stats = stats_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
# Determine threshold status
|
||||||
|
participant_count = stats.participant_count if stats else 0
|
||||||
|
min_sample = tryout.min_sample_for_dynamic
|
||||||
|
threshold_ready = participant_count >= min_sample
|
||||||
|
participants_needed = max(0, min_sample - participant_count)
|
||||||
|
|
||||||
|
# Determine current effective mode
|
||||||
|
current_mode = tryout.normalization_mode
|
||||||
|
if current_mode == "hybrid":
|
||||||
|
effective_mode = "dynamic" if threshold_ready else "static"
|
||||||
|
else:
|
||||||
|
effective_mode = current_mode
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tryout_id": tryout_id,
|
||||||
|
"normalization_mode": current_mode,
|
||||||
|
"effective_mode": effective_mode,
|
||||||
|
"static_rataan": tryout.static_rataan,
|
||||||
|
"static_sb": tryout.static_sb,
|
||||||
|
"dynamic_rataan": stats.rataan if stats else None,
|
||||||
|
"dynamic_sb": stats.sb if stats else None,
|
||||||
|
"participant_count": participant_count,
|
||||||
|
"min_sample_for_dynamic": min_sample,
|
||||||
|
"threshold_ready": threshold_ready,
|
||||||
|
"participants_needed": participants_needed,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def reset_normalization_stats(
|
||||||
|
db: AsyncSession,
|
||||||
|
website_id: int,
|
||||||
|
tryout_id: str,
|
||||||
|
) -> TryoutStats:
|
||||||
|
"""
|
||||||
|
Reset TryoutStats to initial values.
|
||||||
|
|
||||||
|
Resets participant_count to 0 and clears running sums.
|
||||||
|
Switches normalization_mode to "static" temporarily.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Async database session
|
||||||
|
website_id: Website identifier
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Reset TryoutStats record
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If tryout not found
|
||||||
|
"""
|
||||||
|
# Fetch tryout
|
||||||
|
tryout_result = await db.execute(
|
||||||
|
select(Tryout).where(
|
||||||
|
Tryout.website_id == website_id,
|
||||||
|
Tryout.tryout_id == tryout_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tryout = tryout_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if tryout is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"Tryout {tryout_id} not found for website {website_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Switch to static mode temporarily
|
||||||
|
tryout.normalization_mode = "static"
|
||||||
|
|
||||||
|
# Fetch or create stats
|
||||||
|
stats_result = await db.execute(
|
||||||
|
select(TryoutStats).where(
|
||||||
|
TryoutStats.website_id == website_id,
|
||||||
|
TryoutStats.tryout_id == tryout_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stats = stats_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if stats is None:
|
||||||
|
# Create new empty stats record
|
||||||
|
stats = TryoutStats(
|
||||||
|
website_id=website_id,
|
||||||
|
tryout_id=tryout_id,
|
||||||
|
participant_count=0,
|
||||||
|
total_nm_sum=0.0,
|
||||||
|
total_nm_sq_sum=0.0,
|
||||||
|
rataan=None,
|
||||||
|
sb=None,
|
||||||
|
min_nm=None,
|
||||||
|
max_nm=None,
|
||||||
|
)
|
||||||
|
db.add(stats)
|
||||||
|
else:
|
||||||
|
# Reset existing stats
|
||||||
|
stats.participant_count = 0
|
||||||
|
stats.total_nm_sum = 0.0
|
||||||
|
stats.total_nm_sq_sum = 0.0
|
||||||
|
stats.rataan = None
|
||||||
|
stats.sb = None
|
||||||
|
stats.min_nm = None
|
||||||
|
stats.max_nm = None
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Reset normalization stats for tryout {tryout_id}, "
|
||||||
|
f"website {website_id}. Normalization mode switched to static."
|
||||||
|
)
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
async def get_full_config(
|
||||||
|
db: AsyncSession,
|
||||||
|
website_id: int,
|
||||||
|
tryout_id: str,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get full tryout configuration including stats.
|
||||||
|
|
||||||
|
Returns all configuration fields plus current statistics.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Async database session
|
||||||
|
website_id: Website identifier
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with full configuration and stats
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If tryout not found
|
||||||
|
"""
|
||||||
|
# Fetch tryout config
|
||||||
|
tryout = await get_config(db, website_id, tryout_id)
|
||||||
|
|
||||||
|
# Fetch stats
|
||||||
|
stats_result = await db.execute(
|
||||||
|
select(TryoutStats).where(
|
||||||
|
TryoutStats.website_id == website_id,
|
||||||
|
TryoutStats.tryout_id == tryout_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stats = stats_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
# Build config dictionary
|
||||||
|
config = {
|
||||||
|
"tryout_id": tryout.tryout_id,
|
||||||
|
"name": tryout.name,
|
||||||
|
"description": tryout.description,
|
||||||
|
"scoring_mode": tryout.scoring_mode,
|
||||||
|
"selection_mode": tryout.selection_mode,
|
||||||
|
"normalization_mode": tryout.normalization_mode,
|
||||||
|
"min_sample_for_dynamic": tryout.min_sample_for_dynamic,
|
||||||
|
"static_rataan": tryout.static_rataan,
|
||||||
|
"static_sb": tryout.static_sb,
|
||||||
|
"ai_generation_enabled": tryout.ai_generation_enabled,
|
||||||
|
"hybrid_transition_slot": tryout.hybrid_transition_slot,
|
||||||
|
"min_calibration_sample": tryout.min_calibration_sample,
|
||||||
|
"theta_estimation_method": tryout.theta_estimation_method,
|
||||||
|
"fallback_to_ctt_on_error": tryout.fallback_to_ctt_on_error,
|
||||||
|
"stats": {
|
||||||
|
"participant_count": stats.participant_count if stats else 0,
|
||||||
|
"rataan": stats.rataan if stats else None,
|
||||||
|
"sb": stats.sb if stats else None,
|
||||||
|
"min_nm": stats.min_nm if stats else None,
|
||||||
|
"max_nm": stats.max_nm if stats else None,
|
||||||
|
"last_calculated": stats.last_calculated if stats else None,
|
||||||
|
},
|
||||||
|
"created_at": tryout.created_at,
|
||||||
|
"updated_at": tryout.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
385
app/services/ctt_scoring.py
Normal file
385
app/services/ctt_scoring.py
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
"""
|
||||||
|
CTT (Classical Test Theory) Scoring Engine.
|
||||||
|
|
||||||
|
Implements exact Excel formulas for:
|
||||||
|
- p-value (Tingkat Kesukaran): p = Σ Benar / Total Peserta
|
||||||
|
- Bobot (Weight): Bobot = 1 - p
|
||||||
|
- NM (Nilai Mentah): NM = (Total_Bobot_Siswa / Total_Bobot_Max) × 1000
|
||||||
|
- NN (Nilai Nasional): NN = 500 + 100 × ((NM - Rataan) / SB)
|
||||||
|
|
||||||
|
All formulas match PRD Section 13.1 exactly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.item import Item
|
||||||
|
from app.models.tryout_stats import TryoutStats
|
||||||
|
from app.models.user_answer import UserAnswer
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_ctt_p(total_correct: int, total_participants: int) -> float:
|
||||||
|
"""
|
||||||
|
Calculate CTT p-value (Tingkat Kesukaran / Difficulty).
|
||||||
|
|
||||||
|
Formula: p = Σ Benar / Total Peserta
|
||||||
|
|
||||||
|
Args:
|
||||||
|
total_correct: Number of correct answers (Σ Benar)
|
||||||
|
total_participants: Total number of participants (Total Peserta)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
p-value in range [0.0, 1.0]
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If total_participants is 0 or values are invalid
|
||||||
|
"""
|
||||||
|
if total_participants <= 0:
|
||||||
|
raise ValueError("total_participants must be greater than 0")
|
||||||
|
if total_correct < 0:
|
||||||
|
raise ValueError("total_correct cannot be negative")
|
||||||
|
if total_correct > total_participants:
|
||||||
|
raise ValueError("total_correct cannot exceed total_participants")
|
||||||
|
|
||||||
|
p = total_correct / total_participants
|
||||||
|
|
||||||
|
# Clamp to valid range [0, 1]
|
||||||
|
return max(0.0, min(1.0, p))
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_ctt_bobot(p_value: float) -> float:
|
||||||
|
"""
|
||||||
|
Calculate CTT bobot (weight) from p-value.
|
||||||
|
|
||||||
|
Formula: Bobot = 1 - p
|
||||||
|
|
||||||
|
Interpretation:
|
||||||
|
- Easy questions (p > 0.70) have low bobot (< 0.30)
|
||||||
|
- Difficult questions (p < 0.30) have high bobot (> 0.70)
|
||||||
|
- Medium questions (0.30 ≤ p ≤ 0.70) have moderate bobot
|
||||||
|
|
||||||
|
Args:
|
||||||
|
p_value: CTT p-value in range [0.0, 1.0]
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bobot (weight) in range [0.0, 1.0]
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If p_value is outside [0, 1] range
|
||||||
|
"""
|
||||||
|
if not 0.0 <= p_value <= 1.0:
|
||||||
|
raise ValueError(f"p_value must be in range [0, 1], got {p_value}")
|
||||||
|
|
||||||
|
bobot = 1.0 - p_value
|
||||||
|
|
||||||
|
# Clamp to valid range [0, 1]
|
||||||
|
return max(0.0, min(1.0, bobot))
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_ctt_nm(total_bobot_siswa: float, total_bobot_max: float) -> int:
|
||||||
|
"""
|
||||||
|
Calculate CTT NM (Nilai Mentah / Raw Score).
|
||||||
|
|
||||||
|
Formula: NM = (Total_Bobot_Siswa / Total_Bobot_Max) × 1000
|
||||||
|
|
||||||
|
This is equivalent to Excel's SUMPRODUCT calculation where:
|
||||||
|
- Total_Bobot_Siswa = Σ(bobot_earned for each correct answer)
|
||||||
|
- Total_Bobot_Max = Σ(bobot for all questions)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
total_bobot_siswa: Total weight earned by student
|
||||||
|
total_bobot_max: Maximum possible weight (sum of all item bobots)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
NM (raw score) in range [0, 1000]
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If total_bobot_max is 0 or values are invalid
|
||||||
|
"""
|
||||||
|
if total_bobot_max <= 0:
|
||||||
|
raise ValueError("total_bobot_max must be greater than 0")
|
||||||
|
if total_bobot_siswa < 0:
|
||||||
|
raise ValueError("total_bobot_siswa cannot be negative")
|
||||||
|
|
||||||
|
nm = (total_bobot_siswa / total_bobot_max) * 1000
|
||||||
|
|
||||||
|
# Round to integer and clamp to valid range [0, 1000]
|
||||||
|
nm_int = round(nm)
|
||||||
|
return max(0, min(1000, nm_int))
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_ctt_nn(nm: int, rataan: float, sb: float) -> int:
|
||||||
|
"""
|
||||||
|
Calculate CTT NN (Nilai Nasional / Normalized Score).
|
||||||
|
|
||||||
|
Formula: NN = 500 + 100 × ((NM - Rataan) / SB)
|
||||||
|
|
||||||
|
Normalizes scores to mean=500, SD=100 distribution.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
nm: Nilai Mentah (raw score) in range [0, 1000]
|
||||||
|
rataan: Mean of NM scores
|
||||||
|
sb: Standard deviation of NM scores (Simpangan Baku)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
NN (normalized score) in range [0, 1000]
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If nm is out of range or sb is invalid
|
||||||
|
"""
|
||||||
|
if not 0 <= nm <= 1000:
|
||||||
|
raise ValueError(f"nm must be in range [0, 1000], got {nm}")
|
||||||
|
if sb <= 0:
|
||||||
|
# If SD is 0 or negative, return default normalized score
|
||||||
|
# This handles edge case where all scores are identical
|
||||||
|
return 500
|
||||||
|
|
||||||
|
# Calculate normalized score
|
||||||
|
z_score = (nm - rataan) / sb
|
||||||
|
nn = 500 + 100 * z_score
|
||||||
|
|
||||||
|
# Round to integer and clamp to valid range [0, 1000]
|
||||||
|
nn_int = round(nn)
|
||||||
|
return max(0, min(1000, nn_int))
|
||||||
|
|
||||||
|
|
||||||
|
def categorize_difficulty(p_value: float) -> str:
|
||||||
|
"""
|
||||||
|
Categorize question difficulty based on CTT p-value.
|
||||||
|
|
||||||
|
Categories per CTT standards (PRD Section 13.2):
|
||||||
|
- p < 0.30 → Sukar (Sulit)
|
||||||
|
- 0.30 ≤ p ≤ 0.70 → Sedang
|
||||||
|
- p > 0.70 → Mudah
|
||||||
|
|
||||||
|
Args:
|
||||||
|
p_value: CTT p-value in range [0.0, 1.0]
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Difficulty category: "mudah", "sedang", or "sulit"
|
||||||
|
"""
|
||||||
|
if p_value > 0.70:
|
||||||
|
return "mudah"
|
||||||
|
elif p_value >= 0.30:
|
||||||
|
return "sedang"
|
||||||
|
else:
|
||||||
|
return "sulit"
|
||||||
|
|
||||||
|
|
||||||
|
async def calculate_ctt_p_for_item(
|
||||||
|
db: AsyncSession, item_id: int
|
||||||
|
) -> Optional[float]:
|
||||||
|
"""
|
||||||
|
Calculate CTT p-value for a specific item from existing responses.
|
||||||
|
|
||||||
|
Queries all UserAnswer records for the item to calculate:
|
||||||
|
p = Σ Benar / Total Peserta
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Async database session
|
||||||
|
item_id: Item ID to calculate p-value for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
p-value in range [0.0, 1.0], or None if no responses exist
|
||||||
|
"""
|
||||||
|
# Count total responses and correct responses
|
||||||
|
result = await db.execute(
|
||||||
|
select(
|
||||||
|
func.count().label("total"),
|
||||||
|
func.sum(func.cast(UserAnswer.is_correct, type_=func.INTEGER)).label("correct"),
|
||||||
|
).where(UserAnswer.item_id == item_id)
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
|
||||||
|
if row is None or row.total == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return calculate_ctt_p(row.correct or 0, row.total)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_tryout_stats(
|
||||||
|
db: AsyncSession,
|
||||||
|
website_id: int,
|
||||||
|
tryout_id: str,
|
||||||
|
nm: int,
|
||||||
|
) -> TryoutStats:
|
||||||
|
"""
|
||||||
|
Incrementally update TryoutStats with new NM score.
|
||||||
|
|
||||||
|
Updates:
|
||||||
|
- participant_count += 1
|
||||||
|
- total_nm_sum += nm
|
||||||
|
- total_nm_sq_sum += nm²
|
||||||
|
- Recalculates rataan (mean) and sb (standard deviation)
|
||||||
|
- Updates min_nm and max_nm if applicable
|
||||||
|
|
||||||
|
Uses Welford's online algorithm for numerically stable variance calculation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Async database session
|
||||||
|
website_id: Website identifier
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
nm: New NM score to add
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated TryoutStats record
|
||||||
|
"""
|
||||||
|
# Get or create TryoutStats
|
||||||
|
result = await db.execute(
|
||||||
|
select(TryoutStats).where(
|
||||||
|
TryoutStats.website_id == website_id,
|
||||||
|
TryoutStats.tryout_id == tryout_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stats = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if stats is None:
|
||||||
|
# Create new stats record
|
||||||
|
stats = TryoutStats(
|
||||||
|
website_id=website_id,
|
||||||
|
tryout_id=tryout_id,
|
||||||
|
participant_count=1,
|
||||||
|
total_nm_sum=float(nm),
|
||||||
|
total_nm_sq_sum=float(nm * nm),
|
||||||
|
rataan=float(nm),
|
||||||
|
sb=0.0, # SD is 0 for single data point
|
||||||
|
min_nm=nm,
|
||||||
|
max_nm=nm,
|
||||||
|
last_calculated=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
db.add(stats)
|
||||||
|
else:
|
||||||
|
# Incrementally update existing stats
|
||||||
|
stats.participant_count += 1
|
||||||
|
stats.total_nm_sum += nm
|
||||||
|
stats.total_nm_sq_sum += nm * nm
|
||||||
|
|
||||||
|
# Update min/max
|
||||||
|
if stats.min_nm is None or nm < stats.min_nm:
|
||||||
|
stats.min_nm = nm
|
||||||
|
if stats.max_nm is None or nm > stats.max_nm:
|
||||||
|
stats.max_nm = nm
|
||||||
|
|
||||||
|
# Recalculate mean and SD
|
||||||
|
n = stats.participant_count
|
||||||
|
sum_nm = stats.total_nm_sum
|
||||||
|
sum_nm_sq = stats.total_nm_sq_sum
|
||||||
|
|
||||||
|
# Mean = Σ NM / n
|
||||||
|
stats.rataan = sum_nm / n
|
||||||
|
|
||||||
|
# Variance = (Σ NM² / n) - (mean)²
|
||||||
|
# Using population standard deviation
|
||||||
|
if n > 1:
|
||||||
|
variance = (sum_nm_sq / n) - (stats.rataan ** 2)
|
||||||
|
# Clamp variance to non-negative (handles floating point errors)
|
||||||
|
variance = max(0.0, variance)
|
||||||
|
stats.sb = math.sqrt(variance)
|
||||||
|
else:
|
||||||
|
stats.sb = 0.0
|
||||||
|
|
||||||
|
stats.last_calculated = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
async def get_total_bobot_max(
|
||||||
|
db: AsyncSession,
|
||||||
|
website_id: int,
|
||||||
|
tryout_id: str,
|
||||||
|
level: str = "sedang",
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
Calculate total maximum bobot for a tryout.
|
||||||
|
|
||||||
|
Total_Bobot_Max = Σ bobot for all questions in the tryout
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Async database session
|
||||||
|
website_id: Website identifier
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
level: Difficulty level to filter by (default: "sedang")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sum of all item bobots
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If no items found or items have no bobot values
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(func.sum(Item.ctt_bobot)).where(
|
||||||
|
Item.website_id == website_id,
|
||||||
|
Item.tryout_id == tryout_id,
|
||||||
|
Item.level == level,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
total_bobot = result.scalar()
|
||||||
|
|
||||||
|
if total_bobot is None or total_bobot == 0:
|
||||||
|
raise ValueError(
|
||||||
|
f"No items with bobot found for tryout {tryout_id}, level {level}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return float(total_bobot)
|
||||||
|
|
||||||
|
|
||||||
|
def convert_ctt_p_to_irt_b(p_value: float) -> float:
|
||||||
|
"""
|
||||||
|
Convert CTT p-value to IRT difficulty parameter (b).
|
||||||
|
|
||||||
|
Formula: b ≈ -ln((1-p)/p)
|
||||||
|
|
||||||
|
This provides an initial estimate for IRT calibration.
|
||||||
|
Maps p ∈ (0, 1) to b ∈ (-∞, +∞), typically [-3, +3].
|
||||||
|
|
||||||
|
Args:
|
||||||
|
p_value: CTT p-value in range (0.0, 1.0)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
IRT b-parameter estimate
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If p_value is at boundaries (0 or 1)
|
||||||
|
"""
|
||||||
|
if p_value <= 0.0 or p_value >= 1.0:
|
||||||
|
# Handle edge cases by clamping
|
||||||
|
if p_value <= 0.0:
|
||||||
|
return 3.0 # Very difficult
|
||||||
|
else:
|
||||||
|
return -3.0 # Very easy
|
||||||
|
|
||||||
|
# b ≈ -ln((1-p)/p)
|
||||||
|
odds_ratio = (1 - p_value) / p_value
|
||||||
|
b = -math.log(odds_ratio)
|
||||||
|
|
||||||
|
# Clamp to valid IRT range [-3, +3]
|
||||||
|
return max(-3.0, min(3.0, b))
|
||||||
|
|
||||||
|
|
||||||
|
def map_theta_to_nn(theta: float) -> int:
|
||||||
|
"""
|
||||||
|
Map IRT theta (ability) to NN score for comparison.
|
||||||
|
|
||||||
|
Formula: NN = 500 + (θ / 3) × 500
|
||||||
|
|
||||||
|
Maps θ ∈ [-3, +3] to NN ∈ [0, 1000].
|
||||||
|
|
||||||
|
Args:
|
||||||
|
theta: IRT ability estimate in range [-3.0, +3.0]
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
NN score in range [0, 1000]
|
||||||
|
"""
|
||||||
|
# Clamp theta to valid range
|
||||||
|
theta_clamped = max(-3.0, min(3.0, theta))
|
||||||
|
|
||||||
|
# Map to NN
|
||||||
|
nn = 500 + (theta_clamped / 3) * 500
|
||||||
|
|
||||||
|
# Round and clamp to valid range
|
||||||
|
return max(0, min(1000, round(nn)))
|
||||||
521
app/services/excel_import.py
Normal file
521
app/services/excel_import.py
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
"""
|
||||||
|
Excel Import/Export Service for Question Migration.
|
||||||
|
|
||||||
|
Handles import from standardized Excel format with:
|
||||||
|
- Row 2: KUNCI (answer key)
|
||||||
|
- Row 4: TK (tingkat kesukaran p-value)
|
||||||
|
- Row 5: BOBOT (weight 1-p)
|
||||||
|
- Rows 6+: Individual question data
|
||||||
|
|
||||||
|
Ensures 100% data integrity with comprehensive validation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import openpyxl
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.item import Item
|
||||||
|
from app.services.ctt_scoring import (
|
||||||
|
convert_ctt_p_to_irt_b,
|
||||||
|
categorize_difficulty,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_excel_structure(file_path: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate Excel file structure against required format.
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
- File exists and is valid Excel (.xlsx)
|
||||||
|
- Sheet "CONTOH" exists
|
||||||
|
- Required rows exist (Row 2 KUNCI, Row 4 TK, Row 5 BOBOT)
|
||||||
|
- Question data rows have required columns
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to Excel file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with:
|
||||||
|
- valid: bool - Whether structure is valid
|
||||||
|
- errors: List[str] - Validation errors if any
|
||||||
|
"""
|
||||||
|
errors: List[str] = []
|
||||||
|
|
||||||
|
# Check file exists
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
return {"valid": False, "errors": [f"File not found: {file_path}"]}
|
||||||
|
|
||||||
|
# Check file extension
|
||||||
|
if not file_path.lower().endswith('.xlsx'):
|
||||||
|
return {"valid": False, "errors": ["File must be .xlsx format"]}
|
||||||
|
|
||||||
|
try:
|
||||||
|
wb = openpyxl.load_workbook(file_path, data_only=False)
|
||||||
|
except Exception as e:
|
||||||
|
return {"valid": False, "errors": [f"Failed to load Excel file: {str(e)}"]}
|
||||||
|
|
||||||
|
# Check sheet "CONTOH" exists
|
||||||
|
if "CONTOH" not in wb.sheetnames:
|
||||||
|
return {
|
||||||
|
"valid": False,
|
||||||
|
"errors": ['Sheet "CONTOH" not found. Available sheets: ' + ", ".join(wb.sheetnames)]
|
||||||
|
}
|
||||||
|
|
||||||
|
ws = wb["CONTOH"]
|
||||||
|
|
||||||
|
# Check minimum rows exist
|
||||||
|
if ws.max_row < 6:
|
||||||
|
errors.append(f"Excel file must have at least 6 rows (found {ws.max_row})")
|
||||||
|
|
||||||
|
# Check Row 2 exists (KUNCI)
|
||||||
|
if ws.max_row < 2:
|
||||||
|
errors.append("Row 2 (KUNCI - answer key) is required")
|
||||||
|
|
||||||
|
# Check Row 4 exists (TK - p-values)
|
||||||
|
if ws.max_row < 4:
|
||||||
|
errors.append("Row 4 (TK - p-values) is required")
|
||||||
|
|
||||||
|
# Check Row 5 exists (BOBOT - weights)
|
||||||
|
if ws.max_row < 5:
|
||||||
|
errors.append("Row 5 (BOBOT - weights) is required")
|
||||||
|
|
||||||
|
# Check question data rows exist (6+)
|
||||||
|
if ws.max_row < 6:
|
||||||
|
errors.append("Question data rows (6+) are required")
|
||||||
|
|
||||||
|
# Check minimum columns (at least slot, level, soal_text, options, correct_answer)
|
||||||
|
if ws.max_column < 8:
|
||||||
|
errors.append(
|
||||||
|
f"Excel file must have at least 8 columns (found {ws.max_column}). "
|
||||||
|
"Expected: slot, level, soal_text, options_A, options_B, options_C, options_D, correct_answer"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check KUNCI row has values
|
||||||
|
if ws.max_row >= 2:
|
||||||
|
kunce_row_values = [ws.cell(2, col).value for col in range(4, ws.max_column + 1)]
|
||||||
|
if not any(v for v in kunce_row_values if v and v != "KUNCI"):
|
||||||
|
errors.append("Row 2 (KUNCI) must contain answer key values")
|
||||||
|
|
||||||
|
# Check TK row has numeric values
|
||||||
|
if ws.max_row >= 4:
|
||||||
|
wb_data = openpyxl.load_workbook(file_path, data_only=True)
|
||||||
|
ws_data = wb_data["CONTOH"]
|
||||||
|
tk_row_values = [ws_data.cell(4, col).value for col in range(4, ws.max_column + 1)]
|
||||||
|
if not any(v for v in tk_row_values if isinstance(v, (int, float))):
|
||||||
|
errors.append("Row 4 (TK) must contain numeric p-values")
|
||||||
|
|
||||||
|
# Check BOBOT row has numeric values
|
||||||
|
if ws.max_row >= 5:
|
||||||
|
wb_data = openpyxl.load_workbook(file_path, data_only=True)
|
||||||
|
ws_data = wb_data["CONTOH"]
|
||||||
|
bobot_row_values = [ws_data.cell(5, col).value for col in range(4, ws.max_column + 1)]
|
||||||
|
if not any(v for v in bobot_row_values if isinstance(v, (int, float))):
|
||||||
|
errors.append("Row 5 (BOBOT) must contain numeric weight values")
|
||||||
|
|
||||||
|
return {"valid": len(errors) == 0, "errors": errors}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_excel_import(
|
||||||
|
file_path: str,
|
||||||
|
website_id: int,
|
||||||
|
tryout_id: str
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Parse Excel file and extract items with full validation.
|
||||||
|
|
||||||
|
Excel structure:
|
||||||
|
- Sheet name: "CONTOH"
|
||||||
|
- Row 2: KUNCI (answer key) - extract correct answers per slot
|
||||||
|
- Row 4: TK (tingkat kesukaran p-value) - extract p-values per slot
|
||||||
|
- Row 5: BOBOT (weight 1-p) - extract bobot per slot
|
||||||
|
- Rows 6+: Individual question data
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to Excel file
|
||||||
|
website_id: Website identifier
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with:
|
||||||
|
- items: List[Dict] - Parsed items ready for database
|
||||||
|
- validation_errors: List[str] - Any validation errors
|
||||||
|
- items_count: int - Number of items parsed
|
||||||
|
"""
|
||||||
|
# First validate structure
|
||||||
|
validation = validate_excel_structure(file_path)
|
||||||
|
if not validation["valid"]:
|
||||||
|
return {
|
||||||
|
"items": [],
|
||||||
|
"validation_errors": validation["errors"],
|
||||||
|
"items_count": 0
|
||||||
|
}
|
||||||
|
|
||||||
|
items: List[Dict[str, Any]] = []
|
||||||
|
errors: List[str] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Load workbook twice: once with formulas, once with data_only
|
||||||
|
wb = openpyxl.load_workbook(file_path, data_only=False)
|
||||||
|
ws = wb["CONTOH"]
|
||||||
|
|
||||||
|
wb_data = openpyxl.load_workbook(file_path, data_only=True)
|
||||||
|
ws_data = wb_data["CONTOH"]
|
||||||
|
|
||||||
|
# Extract answer key from Row 2
|
||||||
|
answer_key: Dict[int, str] = {}
|
||||||
|
for col in range(4, ws.max_column + 1):
|
||||||
|
key_cell = ws.cell(2, col).value
|
||||||
|
if key_cell and key_cell != "KUNCI":
|
||||||
|
slot_num = col - 3 # Column 4 -> slot 1
|
||||||
|
answer_key[slot_num] = str(key_cell).strip().upper()
|
||||||
|
|
||||||
|
# Extract p-values from Row 4
|
||||||
|
p_values: Dict[int, float] = {}
|
||||||
|
for col in range(4, ws.max_column + 1):
|
||||||
|
slot_num = col - 3
|
||||||
|
if slot_num in answer_key:
|
||||||
|
p_cell = ws_data.cell(4, col).value
|
||||||
|
if p_cell and isinstance(p_cell, (int, float)):
|
||||||
|
p_values[slot_num] = float(p_cell)
|
||||||
|
|
||||||
|
# Extract bobot from Row 5
|
||||||
|
bobot_values: Dict[int, float] = {}
|
||||||
|
for col in range(4, ws.max_column + 1):
|
||||||
|
slot_num = col - 3
|
||||||
|
if slot_num in answer_key:
|
||||||
|
bobot_cell = ws_data.cell(5, col).value
|
||||||
|
if bobot_cell and isinstance(bobot_cell, (int, float)):
|
||||||
|
bobot_values[slot_num] = float(bobot_cell)
|
||||||
|
|
||||||
|
# Parse question data rows (6+)
|
||||||
|
for row_idx in range(6, ws.max_row + 1):
|
||||||
|
# Column mapping (based on project-brief):
|
||||||
|
# Column 1 (A): slot (question number)
|
||||||
|
# Column 2 (B): level (mudah/sedang/sulit)
|
||||||
|
# Column 3 (C): soal_text (question stem)
|
||||||
|
# Column 4 (D): options_A
|
||||||
|
# Column 5 (E): options_B
|
||||||
|
# Column 6 (F): options_C
|
||||||
|
# Column 7 (G): options_D
|
||||||
|
# Column 8 (H): correct_answer
|
||||||
|
|
||||||
|
slot_cell = ws.cell(row_idx, 1).value
|
||||||
|
level_cell = ws.cell(row_idx, 2).value
|
||||||
|
soal_text_cell = ws.cell(row_idx, 3).value
|
||||||
|
option_a = ws.cell(row_idx, 4).value
|
||||||
|
option_b = ws.cell(row_idx, 5).value
|
||||||
|
option_c = ws.cell(row_idx, 6).value
|
||||||
|
option_d = ws.cell(row_idx, 7).value
|
||||||
|
correct_cell = ws.cell(row_idx, 8).value
|
||||||
|
|
||||||
|
# Skip empty rows
|
||||||
|
if not slot_cell and not soal_text_cell:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
if not slot_cell:
|
||||||
|
errors.append(f"Row {row_idx}: Missing slot value")
|
||||||
|
continue
|
||||||
|
|
||||||
|
slot_num = int(slot_cell) if isinstance(slot_cell, (int, float)) else None
|
||||||
|
if slot_num is None:
|
||||||
|
try:
|
||||||
|
slot_num = int(str(slot_cell).strip())
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
errors.append(f"Row {row_idx}: Invalid slot value: {slot_cell}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get or infer level
|
||||||
|
if not level_cell:
|
||||||
|
# Use p-value from Row 4 to determine level
|
||||||
|
p_val = p_values.get(slot_num, 0.5)
|
||||||
|
level_val = categorize_difficulty(p_val)
|
||||||
|
else:
|
||||||
|
level_val = str(level_cell).strip().lower()
|
||||||
|
if level_val not in ["mudah", "sedang", "sulit"]:
|
||||||
|
errors.append(
|
||||||
|
f"Row {row_idx}: Invalid level '{level_cell}'. Must be 'mudah', 'sedang', or 'sulit'"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Validate soal_text
|
||||||
|
if not soal_text_cell:
|
||||||
|
errors.append(f"Row {row_idx} (slot {slot_num}): Missing soal_text (question stem)")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Build options JSON
|
||||||
|
options: Dict[str, str] = {}
|
||||||
|
if option_a:
|
||||||
|
options["A"] = str(option_a).strip()
|
||||||
|
if option_b:
|
||||||
|
options["B"] = str(option_b).strip()
|
||||||
|
if option_c:
|
||||||
|
options["C"] = str(option_c).strip()
|
||||||
|
if option_d:
|
||||||
|
options["D"] = str(option_d).strip()
|
||||||
|
|
||||||
|
if len(options) < 4:
|
||||||
|
errors.append(
|
||||||
|
f"Row {row_idx} (slot {slot_num}): Missing options. Expected 4 options (A, B, C, D)"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get correct answer
|
||||||
|
if not correct_cell:
|
||||||
|
# Fall back to answer key from Row 2
|
||||||
|
correct_ans = answer_key.get(slot_num)
|
||||||
|
if not correct_ans:
|
||||||
|
errors.append(
|
||||||
|
f"Row {row_idx} (slot {slot_num}): Missing correct_answer and no answer key found"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
correct_ans = str(correct_cell).strip().upper()
|
||||||
|
|
||||||
|
if correct_ans not in ["A", "B", "C", "D"]:
|
||||||
|
errors.append(
|
||||||
|
f"Row {row_idx} (slot {slot_num}): Invalid correct_answer '{correct_ans}'. Must be A, B, C, or D"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get CTT parameters
|
||||||
|
p_val = p_values.get(slot_num, 0.5)
|
||||||
|
bobot_val = bobot_values.get(slot_num, 1.0 - p_val)
|
||||||
|
|
||||||
|
# Validate p-value range
|
||||||
|
if p_val < 0 or p_val > 1:
|
||||||
|
errors.append(
|
||||||
|
f"Slot {slot_num}: Invalid p-value {p_val}. Must be in range [0, 1]"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Validate bobot range
|
||||||
|
if bobot_val < 0 or bobot_val > 1:
|
||||||
|
errors.append(
|
||||||
|
f"Slot {slot_num}: Invalid bobot {bobot_val}. Must be in range [0, 1]"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Calculate CTT category and IRT b parameter
|
||||||
|
ctt_cat = categorize_difficulty(p_val)
|
||||||
|
irt_b = convert_ctt_p_to_irt_b(p_val)
|
||||||
|
|
||||||
|
# Build item dict
|
||||||
|
item = {
|
||||||
|
"tryout_id": tryout_id,
|
||||||
|
"website_id": website_id,
|
||||||
|
"slot": slot_num,
|
||||||
|
"level": level_val,
|
||||||
|
"stem": str(soal_text_cell).strip(),
|
||||||
|
"options": options,
|
||||||
|
"correct_answer": correct_ans,
|
||||||
|
"explanation": None,
|
||||||
|
"ctt_p": p_val,
|
||||||
|
"ctt_bobot": bobot_val,
|
||||||
|
"ctt_category": ctt_cat,
|
||||||
|
"irt_b": irt_b,
|
||||||
|
"irt_se": None,
|
||||||
|
"calibrated": False,
|
||||||
|
"calibration_sample_size": 0,
|
||||||
|
"generated_by": "manual",
|
||||||
|
"ai_model": None,
|
||||||
|
"basis_item_id": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
items.append(item)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": items,
|
||||||
|
"validation_errors": errors,
|
||||||
|
"items_count": len(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"items": [],
|
||||||
|
"validation_errors": [f"Parsing error: {str(e)}"],
|
||||||
|
"items_count": 0
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def bulk_insert_items(
|
||||||
|
items_list: List[Dict[str, Any]],
|
||||||
|
db: AsyncSession
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Bulk insert items with duplicate detection.
|
||||||
|
|
||||||
|
Skips duplicates based on (tryout_id, website_id, slot).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
items_list: List of item dictionaries to insert
|
||||||
|
db: Async SQLAlchemy database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with:
|
||||||
|
- inserted_count: int - Number of items inserted
|
||||||
|
- duplicate_count: int - Number of duplicates skipped
|
||||||
|
- errors: List[str] - Any errors during insertion
|
||||||
|
"""
|
||||||
|
inserted_count = 0
|
||||||
|
duplicate_count = 0
|
||||||
|
errors: List[str] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
for item_data in items_list:
|
||||||
|
# Check for duplicate
|
||||||
|
result = await db.execute(
|
||||||
|
select(Item).where(
|
||||||
|
Item.tryout_id == item_data["tryout_id"],
|
||||||
|
Item.website_id == item_data["website_id"],
|
||||||
|
Item.slot == item_data["slot"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
existing = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
duplicate_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Create new item
|
||||||
|
item = Item(**item_data)
|
||||||
|
db.add(item)
|
||||||
|
inserted_count += 1
|
||||||
|
|
||||||
|
# Commit all inserts
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"inserted_count": inserted_count,
|
||||||
|
"duplicate_count": duplicate_count,
|
||||||
|
"errors": errors
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
return {
|
||||||
|
"inserted_count": 0,
|
||||||
|
"duplicate_count": duplicate_count,
|
||||||
|
"errors": [f"Insertion failed: {str(e)}"]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def export_questions_to_excel(
|
||||||
|
tryout_id: str,
|
||||||
|
website_id: int,
|
||||||
|
db: AsyncSession,
|
||||||
|
output_path: Optional[str] = None
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Export questions to Excel in standardized format.
|
||||||
|
|
||||||
|
Creates Excel workbook with:
|
||||||
|
- Sheet "CONTOH"
|
||||||
|
- Row 2: KUNCI (answer key)
|
||||||
|
- Row 4: TK (p-values)
|
||||||
|
- Row 5: BOBOT (weights)
|
||||||
|
- Rows 6+: Question data
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
website_id: Website identifier
|
||||||
|
db: Async SQLAlchemy database session
|
||||||
|
output_path: Optional output file path. If not provided, generates temp file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to exported Excel file
|
||||||
|
"""
|
||||||
|
# Fetch all items for this tryout
|
||||||
|
result = await db.execute(
|
||||||
|
select(Item).filter(
|
||||||
|
Item.tryout_id == tryout_id,
|
||||||
|
Item.website_id == website_id
|
||||||
|
).order_by(Item.slot)
|
||||||
|
)
|
||||||
|
items = result.scalars().all()
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
raise ValueError(f"No items found for tryout_id={tryout_id}, website_id={website_id}")
|
||||||
|
|
||||||
|
# Create workbook
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "CONTOH"
|
||||||
|
|
||||||
|
# Determine max slot for column sizing
|
||||||
|
max_slot = max(item.slot for item in items)
|
||||||
|
|
||||||
|
# Row 1: Header
|
||||||
|
ws.cell(1, 1, "No")
|
||||||
|
ws.cell(1, 2, "Level")
|
||||||
|
ws.cell(1, 3, "Soal")
|
||||||
|
for slot_idx in range(max_slot):
|
||||||
|
col = slot_idx + 4
|
||||||
|
ws.cell(1, col, f"Soal {slot_idx + 1}")
|
||||||
|
|
||||||
|
# Row 2: KUNCI (answer key)
|
||||||
|
ws.cell(2, 1, "")
|
||||||
|
ws.cell(2, 2, "")
|
||||||
|
ws.cell(2, 3, "KUNCI")
|
||||||
|
for item in items:
|
||||||
|
col = item.slot + 3
|
||||||
|
ws.cell(2, col, item.correct_answer)
|
||||||
|
|
||||||
|
# Row 3: Empty
|
||||||
|
ws.cell(3, 1, "")
|
||||||
|
ws.cell(3, 2, "")
|
||||||
|
ws.cell(3, 3, "")
|
||||||
|
|
||||||
|
# Row 4: TK (p-values)
|
||||||
|
ws.cell(4, 1, "")
|
||||||
|
ws.cell(4, 2, "")
|
||||||
|
ws.cell(4, 3, "TK")
|
||||||
|
for item in items:
|
||||||
|
col = item.slot + 3
|
||||||
|
ws.cell(4, col, item.ctt_p or 0.5)
|
||||||
|
|
||||||
|
# Row 5: BOBOT (weights)
|
||||||
|
ws.cell(5, 1, "")
|
||||||
|
ws.cell(5, 2, "")
|
||||||
|
ws.cell(5, 3, "BOBOT")
|
||||||
|
for item in items:
|
||||||
|
col = item.slot + 3
|
||||||
|
ws.cell(5, col, item.ctt_bobot or (1.0 - (item.ctt_p or 0.5)))
|
||||||
|
|
||||||
|
# Rows 6+: Question data
|
||||||
|
row_idx = 6
|
||||||
|
for item in items:
|
||||||
|
# Column 1: Slot number
|
||||||
|
ws.cell(row_idx, 1, item.slot)
|
||||||
|
|
||||||
|
# Column 2: Level
|
||||||
|
ws.cell(row_idx, 2, item.level)
|
||||||
|
|
||||||
|
# Column 3: Soal text (stem)
|
||||||
|
ws.cell(row_idx, 3, item.stem)
|
||||||
|
|
||||||
|
# Columns 4+: Options
|
||||||
|
options = item.options or {}
|
||||||
|
ws.cell(row_idx, 4, options.get("A", ""))
|
||||||
|
ws.cell(row_idx, 5, options.get("B", ""))
|
||||||
|
ws.cell(row_idx, 6, options.get("C", ""))
|
||||||
|
ws.cell(row_idx, 7, options.get("D", ""))
|
||||||
|
|
||||||
|
# Column 8: Correct answer
|
||||||
|
ws.cell(row_idx, 8, item.correct_answer)
|
||||||
|
|
||||||
|
row_idx += 1
|
||||||
|
|
||||||
|
# Generate output path if not provided
|
||||||
|
if output_path is None:
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
output_path = f"/tmp/tryout_{tryout_id}_export_{timestamp}.xlsx"
|
||||||
|
|
||||||
|
# Save workbook
|
||||||
|
wb.save(output_path)
|
||||||
|
|
||||||
|
return output_path
|
||||||
1124
app/services/irt_calibration.py
Normal file
1124
app/services/irt_calibration.py
Normal file
File diff suppressed because it is too large
Load Diff
538
app/services/normalization.py
Normal file
538
app/services/normalization.py
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
"""
|
||||||
|
Dynamic Normalization Service.
|
||||||
|
|
||||||
|
Implements dynamic normalization with real-time calculation of rataan and SB
|
||||||
|
for each tryout. Supports multiple normalization modes:
|
||||||
|
- Static: Use hardcoded rataan/SB from config
|
||||||
|
- Dynamic: Calculate rataan/SB from participant NM scores in real-time
|
||||||
|
- Hybrid: Use static until threshold reached, then switch to dynamic
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Literal, Optional, Tuple
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.tryout import Tryout
|
||||||
|
from app.models.tryout_stats import TryoutStats
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def calculate_dynamic_stats(
|
||||||
|
db: AsyncSession,
|
||||||
|
website_id: int,
|
||||||
|
tryout_id: str,
|
||||||
|
) -> Tuple[Optional[float], Optional[float]]:
|
||||||
|
"""
|
||||||
|
Calculate current dynamic stats (rataan and SB) from TryoutStats.
|
||||||
|
|
||||||
|
Fetches current TryoutStats for this (tryout_id, website_id) pair
|
||||||
|
and returns the calculated rataan and SB values.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Async database session
|
||||||
|
website_id: Website identifier
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (rataan, sb), both None if no stats exist
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(TryoutStats).where(
|
||||||
|
TryoutStats.website_id == website_id,
|
||||||
|
TryoutStats.tryout_id == tryout_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stats = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if stats is None:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
return stats.rataan, stats.sb
|
||||||
|
|
||||||
|
|
||||||
|
async def update_dynamic_normalization(
|
||||||
|
db: AsyncSession,
|
||||||
|
website_id: int,
|
||||||
|
tryout_id: str,
|
||||||
|
nm: int,
|
||||||
|
) -> Tuple[float, float]:
|
||||||
|
"""
|
||||||
|
Update dynamic normalization with new NM score.
|
||||||
|
|
||||||
|
Fetches current TryoutStats and incrementally updates it with the new NM:
|
||||||
|
- Increments participant_count by 1
|
||||||
|
- Adds NM to total_nm_sum
|
||||||
|
- Adds NM² to total_nm_sq_sum
|
||||||
|
- Recalculates rataan and sb
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Async database session
|
||||||
|
website_id: Website identifier
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
nm: Nilai Mentah (raw score) to add
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of updated (rataan, sb)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If nm is out of valid range [0, 1000]
|
||||||
|
"""
|
||||||
|
if not 0 <= nm <= 1000:
|
||||||
|
raise ValueError(f"nm must be in range [0, 1000], got {nm}")
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(TryoutStats).where(
|
||||||
|
TryoutStats.website_id == website_id,
|
||||||
|
TryoutStats.tryout_id == tryout_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stats = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if stats is None:
|
||||||
|
# Initialize new stats record
|
||||||
|
stats = TryoutStats(
|
||||||
|
website_id=website_id,
|
||||||
|
tryout_id=tryout_id,
|
||||||
|
participant_count=1,
|
||||||
|
total_nm_sum=float(nm),
|
||||||
|
total_nm_sq_sum=float(nm * nm),
|
||||||
|
rataan=float(nm),
|
||||||
|
sb=0.0, # SD is 0 for single data point
|
||||||
|
min_nm=nm,
|
||||||
|
max_nm=nm,
|
||||||
|
last_calculated=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
db.add(stats)
|
||||||
|
else:
|
||||||
|
# Incrementally update existing stats
|
||||||
|
stats.participant_count += 1
|
||||||
|
stats.total_nm_sum += nm
|
||||||
|
stats.total_nm_sq_sum += nm * nm
|
||||||
|
|
||||||
|
# Update min/max
|
||||||
|
if stats.min_nm is None or nm < stats.min_nm:
|
||||||
|
stats.min_nm = nm
|
||||||
|
if stats.max_nm is None or nm > stats.max_nm:
|
||||||
|
stats.max_nm = nm
|
||||||
|
|
||||||
|
# Recalculate mean and SD
|
||||||
|
n = stats.participant_count
|
||||||
|
sum_nm = stats.total_nm_sum
|
||||||
|
sum_nm_sq = stats.total_nm_sq_sum
|
||||||
|
|
||||||
|
# Mean = Σ NM / n
|
||||||
|
mean = sum_nm / n
|
||||||
|
stats.rataan = mean
|
||||||
|
|
||||||
|
# Variance = (Σ NM² / n) - (mean)²
|
||||||
|
# Using population standard deviation
|
||||||
|
if n > 1:
|
||||||
|
variance = (sum_nm_sq / n) - (mean ** 2)
|
||||||
|
# Clamp variance to non-negative (handles floating point errors)
|
||||||
|
variance = max(0.0, variance)
|
||||||
|
stats.sb = math.sqrt(variance)
|
||||||
|
else:
|
||||||
|
stats.sb = 0.0
|
||||||
|
|
||||||
|
stats.last_calculated = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Updated dynamic normalization for tryout {tryout_id}, "
|
||||||
|
f"website {website_id}: participant_count={stats.participant_count}, "
|
||||||
|
f"rataan={stats.rataan:.2f}, sb={stats.sb:.2f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# rataan and sb are always set by this function
|
||||||
|
assert stats.rataan is not None
|
||||||
|
assert stats.sb is not None
|
||||||
|
return stats.rataan, stats.sb
|
||||||
|
|
||||||
|
|
||||||
|
def apply_normalization(
|
||||||
|
nm: int,
|
||||||
|
rataan: float,
|
||||||
|
sb: float,
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Apply normalization to NM to get NN (Nilai Nasional).
|
||||||
|
|
||||||
|
Formula: NN = 500 + 100 × ((NM - Rataan) / SB)
|
||||||
|
|
||||||
|
Normalizes scores to mean=500, SD=100 distribution.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
nm: Nilai Mentah (raw score) in range [0, 1000]
|
||||||
|
rataan: Mean of NM scores
|
||||||
|
sb: Standard deviation of NM scores
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
NN (normalized score) in range [0, 1000]
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If nm is out of range or sb is invalid
|
||||||
|
"""
|
||||||
|
if not 0 <= nm <= 1000:
|
||||||
|
raise ValueError(f"nm must be in range [0, 1000], got {nm}")
|
||||||
|
if sb <= 0:
|
||||||
|
# If SD is 0 or negative, return default normalized score
|
||||||
|
# This handles edge case where all scores are identical
|
||||||
|
return 500
|
||||||
|
|
||||||
|
# Calculate normalized score
|
||||||
|
z_score = (nm - rataan) / sb
|
||||||
|
nn = 500 + 100 * z_score
|
||||||
|
|
||||||
|
# Round to integer and clamp to valid range [0, 1000]
|
||||||
|
nn_int = round(nn)
|
||||||
|
return max(0, min(1000, nn_int))
|
||||||
|
|
||||||
|
|
||||||
|
async def get_normalization_mode(
|
||||||
|
db: AsyncSession,
|
||||||
|
website_id: int,
|
||||||
|
tryout_id: str,
|
||||||
|
) -> Literal["static", "dynamic", "hybrid"]:
|
||||||
|
"""
|
||||||
|
Get the current normalization mode for a tryout.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Async database session
|
||||||
|
website_id: Website identifier
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Normalization mode: "static", "dynamic", or "hybrid"
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If tryout not found
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Tryout).where(
|
||||||
|
Tryout.website_id == website_id,
|
||||||
|
Tryout.tryout_id == tryout_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tryout = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if tryout is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"Tryout {tryout_id} not found for website {website_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return tryout.normalization_mode
|
||||||
|
|
||||||
|
|
||||||
|
async def check_threshold_for_dynamic(
|
||||||
|
db: AsyncSession,
|
||||||
|
website_id: int,
|
||||||
|
tryout_id: str,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Check if participant count meets threshold for dynamic normalization.
|
||||||
|
|
||||||
|
Compares current participant_count with min_sample_for_dynamic from config.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Async database session
|
||||||
|
website_id: Website identifier
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if participant_count >= min_sample_for_dynamic, else False
|
||||||
|
"""
|
||||||
|
# Fetch current TryoutStats
|
||||||
|
stats_result = await db.execute(
|
||||||
|
select(TryoutStats).where(
|
||||||
|
TryoutStats.website_id == website_id,
|
||||||
|
TryoutStats.tryout_id == tryout_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stats = stats_result.scalar_one_or_none()
|
||||||
|
current_participant_count = stats.participant_count if stats else 0
|
||||||
|
|
||||||
|
# Fetch min_sample_for_dynamic from config
|
||||||
|
tryout_result = await db.execute(
|
||||||
|
select(Tryout.min_sample_for_dynamic).where(
|
||||||
|
Tryout.website_id == website_id,
|
||||||
|
Tryout.tryout_id == tryout_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
min_sample = tryout_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if min_sample is None:
|
||||||
|
# Default to 100 if not configured
|
||||||
|
min_sample = 100
|
||||||
|
|
||||||
|
return current_participant_count >= min_sample
|
||||||
|
|
||||||
|
|
||||||
|
async def get_normalization_params(
|
||||||
|
db: AsyncSession,
|
||||||
|
website_id: int,
|
||||||
|
tryout_id: str,
|
||||||
|
) -> Tuple[float, float, Literal["static", "dynamic"]]:
|
||||||
|
"""
|
||||||
|
Get normalization parameters (rataan, sb) based on current mode.
|
||||||
|
|
||||||
|
Determines which normalization parameters to use:
|
||||||
|
- Static mode: Use config.static_rataan and config.static_sb
|
||||||
|
- Dynamic mode: Use calculated rataan and sb from TryoutStats
|
||||||
|
- Hybrid mode: Use static until threshold reached, then dynamic
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Async database session
|
||||||
|
website_id: Website identifier
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (rataan, sb, mode_used)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If tryout not found or dynamic stats unavailable
|
||||||
|
"""
|
||||||
|
# Get normalization mode
|
||||||
|
mode = await get_normalization_mode(db, website_id, tryout_id)
|
||||||
|
|
||||||
|
if mode == "static":
|
||||||
|
# Use static values from config
|
||||||
|
result = await db.execute(
|
||||||
|
select(Tryout.static_rataan, Tryout.static_sb).where(
|
||||||
|
Tryout.website_id == website_id,
|
||||||
|
Tryout.tryout_id == tryout_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"Tryout {tryout_id} not found for website {website_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
rataan, sb = row
|
||||||
|
return rataan, sb, "static"
|
||||||
|
|
||||||
|
elif mode == "dynamic":
|
||||||
|
# Use dynamic values from stats
|
||||||
|
rataan, sb = await calculate_dynamic_stats(db, website_id, tryout_id)
|
||||||
|
|
||||||
|
if rataan is None or sb is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"Dynamic normalization not available for tryout {tryout_id}. "
|
||||||
|
"No stats have been calculated yet."
|
||||||
|
)
|
||||||
|
|
||||||
|
if sb == 0:
|
||||||
|
logger.warning(
|
||||||
|
f"Standard deviation is 0 for tryout {tryout_id}. "
|
||||||
|
"All NM scores are identical."
|
||||||
|
)
|
||||||
|
|
||||||
|
return rataan, sb, "dynamic"
|
||||||
|
|
||||||
|
else: # hybrid
|
||||||
|
# Check threshold
|
||||||
|
threshold_met = await check_threshold_for_dynamic(db, website_id, tryout_id)
|
||||||
|
|
||||||
|
if threshold_met:
|
||||||
|
# Use dynamic values
|
||||||
|
rataan, sb = await calculate_dynamic_stats(db, website_id, tryout_id)
|
||||||
|
|
||||||
|
if rataan is None or sb is None:
|
||||||
|
# Fallback to static if dynamic not available
|
||||||
|
result = await db.execute(
|
||||||
|
select(Tryout.static_rataan, Tryout.static_sb).where(
|
||||||
|
Tryout.website_id == website_id,
|
||||||
|
Tryout.tryout_id == tryout_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row = result.scalar_one_or_none()
|
||||||
|
if row is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"Tryout {tryout_id} not found for website {website_id}"
|
||||||
|
)
|
||||||
|
rataan, sb = row
|
||||||
|
return rataan, sb, "static"
|
||||||
|
|
||||||
|
return rataan, sb, "dynamic"
|
||||||
|
else:
|
||||||
|
# Use static values
|
||||||
|
result = await db.execute(
|
||||||
|
select(Tryout.static_rataan, Tryout.static_sb).where(
|
||||||
|
Tryout.website_id == website_id,
|
||||||
|
Tryout.tryout_id == tryout_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row = result.scalar_one_or_none()
|
||||||
|
if row is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"Tryout {tryout_id} not found for website {website_id}"
|
||||||
|
)
|
||||||
|
rataan, sb = row
|
||||||
|
return rataan, sb, "static"
|
||||||
|
|
||||||
|
|
||||||
|
async def calculate_skewness(
|
||||||
|
db: AsyncSession,
|
||||||
|
website_id: int,
|
||||||
|
tryout_id: str,
|
||||||
|
) -> Optional[float]:
|
||||||
|
"""
|
||||||
|
Calculate skewness of NM distribution for validation.
|
||||||
|
|
||||||
|
Skewness measures the asymmetry of the probability distribution.
|
||||||
|
Values:
|
||||||
|
- Skewness ≈ 0: Symmetric distribution
|
||||||
|
- Skewness > 0: Right-skewed (tail to the right)
|
||||||
|
- Skewness < 0: Left-skewed (tail to the left)
|
||||||
|
|
||||||
|
Formula: Skewness = (n / ((n-1)(n-2))) * Σ((x - mean) / SD)³
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Async database session
|
||||||
|
website_id: Website identifier
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Skewness value, or None if insufficient data
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(TryoutStats).where(
|
||||||
|
TryoutStats.website_id == website_id,
|
||||||
|
TryoutStats.tryout_id == tryout_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stats = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if stats is None or stats.participant_count < 3:
|
||||||
|
# Need at least 3 samples for skewness calculation
|
||||||
|
return None
|
||||||
|
|
||||||
|
n = stats.participant_count
|
||||||
|
mean = stats.rataan
|
||||||
|
sd = stats.sb
|
||||||
|
|
||||||
|
if sd == 0:
|
||||||
|
return 0.0 # All values are identical
|
||||||
|
|
||||||
|
# Calculate skewness
|
||||||
|
# We need individual NM values, which we don't have in TryoutStats
|
||||||
|
# For now, return None as we need a different approach
|
||||||
|
# This would require storing all NM values or calculating on-the-fly
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_dynamic_normalization(
|
||||||
|
db: AsyncSession,
|
||||||
|
website_id: int,
|
||||||
|
tryout_id: str,
|
||||||
|
target_mean: float = 500.0,
|
||||||
|
target_sd: float = 100.0,
|
||||||
|
mean_tolerance: float = 5.0,
|
||||||
|
sd_tolerance: float = 5.0,
|
||||||
|
) -> Tuple[bool, dict]:
|
||||||
|
"""
|
||||||
|
Validate that dynamic normalization produces expected distribution.
|
||||||
|
|
||||||
|
Checks if calculated rataan and sb are close to target values.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Async database session
|
||||||
|
website_id: Website identifier
|
||||||
|
tryout_id: Tryout identifier
|
||||||
|
target_mean: Target mean (default: 500)
|
||||||
|
target_sd: Target standard deviation (default: 100)
|
||||||
|
mean_tolerance: Allowed deviation from target mean (default: 5)
|
||||||
|
sd_tolerance: Allowed deviation from target SD (default: 5)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, validation_details)
|
||||||
|
|
||||||
|
validation_details contains:
|
||||||
|
- participant_count: Number of participants
|
||||||
|
- current_rataan: Current mean
|
||||||
|
- current_sb: Current standard deviation
|
||||||
|
- mean_deviation: Absolute deviation from target mean
|
||||||
|
- sd_deviation: Absolute deviation from target SD
|
||||||
|
- mean_within_tolerance: True if mean deviation < mean_tolerance
|
||||||
|
- sd_within_tolerance: True if SD deviation < sd_tolerance
|
||||||
|
- warnings: List of warning messages
|
||||||
|
- suggestions: List of suggestions
|
||||||
|
"""
|
||||||
|
# Get current stats
|
||||||
|
result = await db.execute(
|
||||||
|
select(TryoutStats).where(
|
||||||
|
TryoutStats.website_id == website_id,
|
||||||
|
TryoutStats.tryout_id == tryout_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stats = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if stats is None or stats.rataan is None or stats.sb is None:
|
||||||
|
return False, {
|
||||||
|
"participant_count": 0,
|
||||||
|
"current_rataan": None,
|
||||||
|
"current_sb": None,
|
||||||
|
"mean_deviation": None,
|
||||||
|
"sd_deviation": None,
|
||||||
|
"mean_within_tolerance": False,
|
||||||
|
"sd_within_tolerance": False,
|
||||||
|
"warnings": ["No statistics available for validation"],
|
||||||
|
"suggestions": ["Wait for more participants to complete sessions"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Calculate deviations
|
||||||
|
mean_deviation = abs(stats.rataan - target_mean)
|
||||||
|
sd_deviation = abs(stats.sb - target_sd)
|
||||||
|
|
||||||
|
# Check tolerance
|
||||||
|
mean_within_tolerance = mean_deviation <= mean_tolerance
|
||||||
|
sd_within_tolerance = sd_deviation <= sd_tolerance
|
||||||
|
|
||||||
|
is_valid = mean_within_tolerance and sd_within_tolerance
|
||||||
|
|
||||||
|
# Generate warnings
|
||||||
|
warnings = []
|
||||||
|
suggestions = []
|
||||||
|
|
||||||
|
if not mean_within_tolerance:
|
||||||
|
warnings.append(f"Mean deviation ({mean_deviation:.2f}) exceeds tolerance ({mean_tolerance})")
|
||||||
|
if stats.rataan > target_mean:
|
||||||
|
suggestions.append("Distribution may be right-skewed - consider checking question difficulty")
|
||||||
|
else:
|
||||||
|
suggestions.append("Distribution may be left-skewed - consider checking question difficulty")
|
||||||
|
|
||||||
|
if not sd_within_tolerance:
|
||||||
|
warnings.append(f"SD deviation ({sd_deviation:.2f}) exceeds tolerance ({sd_tolerance})")
|
||||||
|
if stats.sb < target_sd:
|
||||||
|
suggestions.append("SD too low - scores may be too tightly clustered")
|
||||||
|
else:
|
||||||
|
suggestions.append("SD too high - scores may have too much variance")
|
||||||
|
|
||||||
|
# Check for skewness
|
||||||
|
skewness = await calculate_skewness(db, website_id, tryout_id)
|
||||||
|
if skewness is not None and abs(skewness) > 0.5:
|
||||||
|
warnings.append(f"Distribution skewness ({skewness:.2f}) > 0.5 - distribution may be asymmetric")
|
||||||
|
suggestions.append("Consider using static normalization if dynamic normalization is unstable")
|
||||||
|
|
||||||
|
# Check participant count
|
||||||
|
if stats.participant_count < 100:
|
||||||
|
suggestions.append(f"Participant count ({stats.participant_count}) below recommended minimum (100)")
|
||||||
|
|
||||||
|
return is_valid, {
|
||||||
|
"participant_count": stats.participant_count,
|
||||||
|
"current_rataan": stats.rataan,
|
||||||
|
"current_sb": stats.sb,
|
||||||
|
"mean_deviation": mean_deviation,
|
||||||
|
"sd_deviation": sd_deviation,
|
||||||
|
"mean_within_tolerance": mean_within_tolerance,
|
||||||
|
"sd_within_tolerance": sd_within_tolerance,
|
||||||
|
"warnings": warnings,
|
||||||
|
"suggestions": suggestions,
|
||||||
|
}
|
||||||
1449
app/services/reporting.py
Normal file
1449
app/services/reporting.py
Normal file
File diff suppressed because it is too large
Load Diff
456
app/services/wordpress_auth.py
Normal file
456
app/services/wordpress_auth.py
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
"""
|
||||||
|
WordPress Authentication and User Synchronization Service.
|
||||||
|
|
||||||
|
Handles:
|
||||||
|
- JWT token validation via WordPress REST API
|
||||||
|
- User synchronization from WordPress to local database
|
||||||
|
- Multi-site support via website_id isolation
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.website import Website
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
# Custom exceptions for WordPress integration
|
||||||
|
class WordPressAuthError(Exception):
|
||||||
|
"""Base exception for WordPress authentication errors."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class WordPressTokenInvalidError(WordPressAuthError):
|
||||||
|
"""Raised when WordPress token is invalid or expired."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class WordPressAPIError(WordPressAuthError):
|
||||||
|
"""Raised when WordPress API is unreachable or returns error."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class WordPressRateLimitError(WordPressAuthError):
|
||||||
|
"""Raised when WordPress API rate limit is exceeded."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class WebsiteNotFoundError(WordPressAuthError):
|
||||||
|
"""Raised when website_id is not found in local database."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WordPressUserInfo:
|
||||||
|
"""Data class for WordPress user information."""
|
||||||
|
wp_user_id: str
|
||||||
|
username: str
|
||||||
|
email: str
|
||||||
|
display_name: str
|
||||||
|
roles: list[str]
|
||||||
|
raw_data: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SyncStats:
|
||||||
|
"""Data class for user synchronization statistics."""
|
||||||
|
inserted: int
|
||||||
|
updated: int
|
||||||
|
total: int
|
||||||
|
errors: int
|
||||||
|
|
||||||
|
|
||||||
|
async def get_wordpress_api_base(website: Website) -> str:
|
||||||
|
"""
|
||||||
|
Get WordPress API base URL for a website.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
website: Website model instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
WordPress REST API base URL
|
||||||
|
"""
|
||||||
|
# Use website's site_url if configured, otherwise use global config
|
||||||
|
base_url = website.site_url.rstrip('/')
|
||||||
|
return f"{base_url}/wp-json"
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_wordpress_token(
|
||||||
|
token: str,
|
||||||
|
website_id: int,
|
||||||
|
wp_user_id: str,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> Optional[WordPressUserInfo]:
|
||||||
|
"""
|
||||||
|
Verify WordPress JWT token and validate user identity.
|
||||||
|
|
||||||
|
Calls WordPress REST API GET /wp/v2/users/me with Authorization header.
|
||||||
|
Verifies response contains matching wp_user_id.
|
||||||
|
Verifies website_id exists in local database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: WordPress JWT authentication token
|
||||||
|
website_id: Website identifier for multi-site isolation
|
||||||
|
wp_user_id: Expected WordPress user ID to verify
|
||||||
|
db: Async database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
WordPressUserInfo if valid, None if invalid
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
WebsiteNotFoundError: If website_id doesn't exist
|
||||||
|
WordPressTokenInvalidError: If token is invalid
|
||||||
|
WordPressAPIError: If API is unreachable
|
||||||
|
WordPressRateLimitError: If rate limited
|
||||||
|
"""
|
||||||
|
# Verify website exists
|
||||||
|
website_result = await db.execute(
|
||||||
|
select(Website).where(Website.id == website_id)
|
||||||
|
)
|
||||||
|
website = website_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if website is None:
|
||||||
|
raise WebsiteNotFoundError(f"Website {website_id} not found")
|
||||||
|
|
||||||
|
api_base = await get_wordpress_api_base(website)
|
||||||
|
url = f"{api_base}/wp/v2/users/me"
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout = httpx.Timeout(10.0, connect=5.0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
response = await client.get(url, headers=headers)
|
||||||
|
|
||||||
|
if response.status_code == 401:
|
||||||
|
raise WordPressTokenInvalidError("Invalid or expired WordPress token")
|
||||||
|
|
||||||
|
if response.status_code == 429:
|
||||||
|
raise WordPressRateLimitError("WordPress API rate limit exceeded")
|
||||||
|
|
||||||
|
if response.status_code == 503:
|
||||||
|
raise WordPressAPIError("WordPress API service unavailable")
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise WordPressAPIError(
|
||||||
|
f"WordPress API error: {response.status_code} - {response.text}"
|
||||||
|
)
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Verify user ID matches
|
||||||
|
response_user_id = str(data.get("id", ""))
|
||||||
|
if response_user_id != str(wp_user_id):
|
||||||
|
logger.warning(
|
||||||
|
f"User ID mismatch: expected {wp_user_id}, got {response_user_id}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Extract user info
|
||||||
|
user_info = WordPressUserInfo(
|
||||||
|
wp_user_id=response_user_id,
|
||||||
|
username=data.get("username", ""),
|
||||||
|
email=data.get("email", ""),
|
||||||
|
display_name=data.get("name", ""),
|
||||||
|
roles=data.get("roles", []),
|
||||||
|
raw_data=data,
|
||||||
|
)
|
||||||
|
|
||||||
|
return user_info
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
raise WordPressAPIError("WordPress API request timed out")
|
||||||
|
except httpx.ConnectError:
|
||||||
|
raise WordPressAPIError("Unable to connect to WordPress API")
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise WordPressAPIError(f"HTTP error communicating with WordPress: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_wordpress_users(
|
||||||
|
website: Website,
|
||||||
|
admin_token: str,
|
||||||
|
page: int = 1,
|
||||||
|
per_page: int = 100,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Fetch users from WordPress API (requires admin token).
|
||||||
|
|
||||||
|
Calls WordPress REST API GET /wp/v2/users with admin authorization.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
website: Website model instance
|
||||||
|
admin_token: WordPress admin JWT token
|
||||||
|
page: Page number for pagination
|
||||||
|
per_page: Number of users per page (max 100)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of WordPress user data dictionaries
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
WordPressTokenInvalidError: If admin token is invalid
|
||||||
|
WordPressAPIError: If API is unreachable
|
||||||
|
WordPressRateLimitError: If rate limited
|
||||||
|
"""
|
||||||
|
api_base = await get_wordpress_api_base(website)
|
||||||
|
url = f"{api_base}/wp/v2/users"
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {admin_token}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"page": page,
|
||||||
|
"per_page": min(per_page, 100),
|
||||||
|
"context": "edit", # Get full user data
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout = httpx.Timeout(30.0, connect=10.0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
response = await client.get(url, headers=headers, params=params)
|
||||||
|
|
||||||
|
if response.status_code == 401:
|
||||||
|
raise WordPressTokenInvalidError("Invalid admin token for user sync")
|
||||||
|
|
||||||
|
if response.status_code == 403:
|
||||||
|
raise WordPressTokenInvalidError(
|
||||||
|
"Admin token lacks permission to list users"
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 429:
|
||||||
|
raise WordPressRateLimitError("WordPress API rate limit exceeded")
|
||||||
|
|
||||||
|
if response.status_code == 503:
|
||||||
|
raise WordPressAPIError("WordPress API service unavailable")
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise WordPressAPIError(
|
||||||
|
f"WordPress API error: {response.status_code} - {response.text}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
raise WordPressAPIError("WordPress API request timed out")
|
||||||
|
except httpx.ConnectError:
|
||||||
|
raise WordPressAPIError("Unable to connect to WordPress API")
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise WordPressAPIError(f"HTTP error communicating with WordPress: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_wordpress_users(
|
||||||
|
website_id: int,
|
||||||
|
admin_token: str,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> SyncStats:
|
||||||
|
"""
|
||||||
|
Synchronize users from WordPress to local database.
|
||||||
|
|
||||||
|
Fetches all users from WordPress API and performs upsert:
|
||||||
|
- Updates existing users
|
||||||
|
- Inserts new users
|
||||||
|
|
||||||
|
Args:
|
||||||
|
website_id: Website identifier for multi-site isolation
|
||||||
|
admin_token: WordPress admin JWT token
|
||||||
|
db: Async database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SyncStats with insertion/update counts
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
WebsiteNotFoundError: If website_id doesn't exist
|
||||||
|
WordPressTokenInvalidError: If admin token is invalid
|
||||||
|
WordPressAPIError: If API is unreachable
|
||||||
|
"""
|
||||||
|
# Verify website exists
|
||||||
|
website_result = await db.execute(
|
||||||
|
select(Website).where(Website.id == website_id)
|
||||||
|
)
|
||||||
|
website = website_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if website is None:
|
||||||
|
raise WebsiteNotFoundError(f"Website {website_id} not found")
|
||||||
|
|
||||||
|
# Fetch existing users from local database
|
||||||
|
existing_users_result = await db.execute(
|
||||||
|
select(User).where(User.website_id == website_id)
|
||||||
|
)
|
||||||
|
existing_users = {
|
||||||
|
str(user.wp_user_id): user
|
||||||
|
for user in existing_users_result.scalars().all()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fetch users from WordPress (with pagination)
|
||||||
|
all_wp_users = []
|
||||||
|
page = 1
|
||||||
|
per_page = 100
|
||||||
|
|
||||||
|
while True:
|
||||||
|
wp_users = await fetch_wordpress_users(
|
||||||
|
website, admin_token, page, per_page
|
||||||
|
)
|
||||||
|
|
||||||
|
if not wp_users:
|
||||||
|
break
|
||||||
|
|
||||||
|
all_wp_users.extend(wp_users)
|
||||||
|
|
||||||
|
# Check if more pages
|
||||||
|
if len(wp_users) < per_page:
|
||||||
|
break
|
||||||
|
|
||||||
|
page += 1
|
||||||
|
|
||||||
|
# Sync users
|
||||||
|
inserted = 0
|
||||||
|
updated = 0
|
||||||
|
errors = 0
|
||||||
|
|
||||||
|
for wp_user in all_wp_users:
|
||||||
|
try:
|
||||||
|
wp_user_id = str(wp_user.get("id", ""))
|
||||||
|
|
||||||
|
if not wp_user_id:
|
||||||
|
errors += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if wp_user_id in existing_users:
|
||||||
|
# Update existing user (timestamp update)
|
||||||
|
existing_user = existing_users[wp_user_id]
|
||||||
|
existing_user.updated_at = datetime.now(timezone.utc)
|
||||||
|
updated += 1
|
||||||
|
else:
|
||||||
|
# Insert new user
|
||||||
|
new_user = User(
|
||||||
|
wp_user_id=wp_user_id,
|
||||||
|
website_id=website_id,
|
||||||
|
created_at=datetime.now(timezone.utc),
|
||||||
|
updated_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
db.add(new_user)
|
||||||
|
inserted += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error syncing user {wp_user.get('id')}: {e}")
|
||||||
|
errors += 1
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
total = inserted + updated
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"WordPress user sync complete for website {website_id}: "
|
||||||
|
f"{inserted} inserted, {updated} updated, {errors} errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
return SyncStats(
|
||||||
|
inserted=inserted,
|
||||||
|
updated=updated,
|
||||||
|
total=total,
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_wordpress_user(
|
||||||
|
wp_user_id: str,
|
||||||
|
website_id: int,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> Optional[User]:
|
||||||
|
"""
|
||||||
|
Get user from local database by WordPress user ID and website ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
wp_user_id: WordPress user ID
|
||||||
|
website_id: Website identifier for multi-site isolation
|
||||||
|
db: Async database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User object if found, None otherwise
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(
|
||||||
|
User.wp_user_id == wp_user_id,
|
||||||
|
User.website_id == website_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_website_exists(
|
||||||
|
website_id: int,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> Website:
|
||||||
|
"""
|
||||||
|
Verify website exists in database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
website_id: Website identifier
|
||||||
|
db: Async database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Website model instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
WebsiteNotFoundError: If website doesn't exist
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Website).where(Website.id == website_id)
|
||||||
|
)
|
||||||
|
website = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if website is None:
|
||||||
|
raise WebsiteNotFoundError(f"Website {website_id} not found")
|
||||||
|
|
||||||
|
return website
|
||||||
|
|
||||||
|
|
||||||
|
async def get_or_create_user(
|
||||||
|
wp_user_id: str,
|
||||||
|
website_id: int,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> User:
|
||||||
|
"""
|
||||||
|
Get existing user or create new one if not exists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
wp_user_id: WordPress user ID
|
||||||
|
website_id: Website identifier
|
||||||
|
db: Async database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User model instance
|
||||||
|
"""
|
||||||
|
existing = await get_wordpress_user(wp_user_id, website_id, db)
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
# Create new user
|
||||||
|
new_user = User(
|
||||||
|
wp_user_id=wp_user_id,
|
||||||
|
website_id=website_id,
|
||||||
|
created_at=datetime.now(timezone.utc),
|
||||||
|
updated_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
db.add(new_user)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(new_user)
|
||||||
|
|
||||||
|
return new_user
|
||||||
96
handoff.md
Normal file
96
handoff.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
---
|
||||||
|
## HANDOFF CONTEXT
|
||||||
|
GOAL
|
||||||
|
----
|
||||||
|
Continue implementation of IRT-Powered Adaptive Question Bank System after user configures GLM-5 model mapping for specific subagent categories.
|
||||||
|
WORK COMPLETED
|
||||||
|
--------------
|
||||||
|
- Created comprehensive PRD (v1.1) from project-brief.md
|
||||||
|
- Resolved 10 critical clarification questions with client:
|
||||||
|
1. Excel Import: Standardized across ALL tryouts
|
||||||
|
2. AI Generation: 1 request = 1 question, admin playground for testing, no approval workflow
|
||||||
|
3. Normalization: Optional manual/automatic control (system handles auto when sufficient data)
|
||||||
|
4. Rollback: Preserve IRT history, apply CTT to new sessions only
|
||||||
|
5. Admin Permissions: Not needed (WordPress handles per-site admins)
|
||||||
|
6. Dashboards: FastAPI Admin only
|
||||||
|
7. Rate Limiting: User-level reuse check + AI generation toggle
|
||||||
|
8. Student UX: Admin sees internal metrics, students only see primary score
|
||||||
|
9. Data Retention: Keep all data
|
||||||
|
10. Reporting: All 4 report types required
|
||||||
|
- Created detailed technical implementation plan with 10 parallel subagents:
|
||||||
|
- Deep Agent 1: Core API + CTT Scoring
|
||||||
|
- Deep Agent 2: IRT Calibration Engine (recommended for GLM-5)
|
||||||
|
- Deep Agent 3: CAT Selection Logic (recommended for GLM-5)
|
||||||
|
- Deep Agent 4: AI Generation + OpenRouter (recommended for GLM-5)
|
||||||
|
- Deep Agent 5: WordPress Integration
|
||||||
|
- Deep Agent 6: Reporting System (recommended for GLM-5)
|
||||||
|
- Unspecified-High Agents: Database Schema, Excel Import/Export, Admin Panel, Normalization
|
||||||
|
CURRENT STATE
|
||||||
|
-------------
|
||||||
|
- PRD.md file created (746 lines, v1.1)
|
||||||
|
- project-brief.md exists (reference document)
|
||||||
|
- No code implementation started yet
|
||||||
|
- No git repository initialized
|
||||||
|
- Working directory: /Users/dwindown/Applications/tryout-system
|
||||||
|
- Session ID: ses_2f1bf9e3cffes96exBxyheOiYT
|
||||||
|
PENDING TASKS
|
||||||
|
-------------
|
||||||
|
1. User configures GLM-5 model mapping for `deep` category (GLM-5 for algorithmic complexity)
|
||||||
|
2. User configures GLM-4.7 model mapping for `unspecified-high` category (general implementation)
|
||||||
|
3. Initialize git repository
|
||||||
|
4. Create project structure (app/, models/, routers/, services/, tests/)
|
||||||
|
5. Launch Unspecified-High Agent 1: Database Schema + ORM (BLOCKS all other agents)
|
||||||
|
6. After schema complete: Launch Deep Agents 1-3 in parallel (Core API, IRT Calibration, CAT Selection)
|
||||||
|
7. Launch Deep Agents 4-6 + Unspecified-High Agents 2-4 in parallel (AI Generation, WordPress, Reporting, Excel, Admin, Normalization)
|
||||||
|
8. Integration testing and validation
|
||||||
|
KEY FILES
|
||||||
|
---------
|
||||||
|
- PRD.md - Complete product requirements document (v1.1, 746 lines)
|
||||||
|
- project-brief.md - Original technical specification reference
|
||||||
|
IMPORTANT DECISIONS
|
||||||
|
-------------------
|
||||||
|
- 1 request = 1 question for AI generation (no batch)
|
||||||
|
- Admin playground for AI testing (no approval workflow for student tests)
|
||||||
|
- Normalization: Admin chooses manual/automatic; system handles auto when data sufficient
|
||||||
|
- Rollback: Keep IRT historical scores, apply CTT only to new sessions
|
||||||
|
- No admin permissions system (WordPress handles per-site admin access)
|
||||||
|
- FastAPI Admin only (no custom dashboards)
|
||||||
|
- Global AI generation toggle for cost control
|
||||||
|
- User-level question reuse check (prevent duplicate difficulty exposure)
|
||||||
|
- Admin sees internal metrics, students only see primary score
|
||||||
|
- Keep all data indefinitely
|
||||||
|
- All 4 report types required (Student, Item, Calibration, Tryout comparison)
|
||||||
|
EXPLICIT CONSTRAINTS
|
||||||
|
--------------------
|
||||||
|
- Excel format is standardized across ALL tryouts (strict parser)
|
||||||
|
- CTT formulas must match client Excel 100% (p = Σ Benar / Total Peserta)
|
||||||
|
- IRT 1PL Rasch model only (b parameter, no a/c initially)
|
||||||
|
- θ and b ∈ [-3, +3], NM and NN ∈ [0, 1000]
|
||||||
|
- Normalization target: Mean=500±5, SD=100±5
|
||||||
|
- Tech stack: FastAPI, PostgreSQL, SQLAlchemy, FastAPI Admin, OpenRouter (Qwen3 Coder 480B / Llama 3.3 70B)
|
||||||
|
- Deployment: aaPanel VPS with Python Manager
|
||||||
|
- No type error suppression (no `as any`, `@ts-ignore`)
|
||||||
|
- Zero disruption to existing operations (non-destructive, additive)
|
||||||
|
GLM-5 MODEL ALLOCATION RECOMMENDATION
|
||||||
|
-----------------------------------
|
||||||
|
Use GLM-5 for:
|
||||||
|
- Deep Agent 2: IRT Calibration Engine (mathematical algorithms, sparse data handling)
|
||||||
|
- Deep Agent 3: CAT Selection Logic (adaptive algorithms, termination conditions)
|
||||||
|
- Deep Agent 4: AI Generation + OpenRouter (prompt engineering, robust parsing)
|
||||||
|
- Deep Agent 6: Reporting System (complex aggregation, multi-dimensional analysis)
|
||||||
|
Use GLM-4.7 for:
|
||||||
|
- Deep Agent 1: Core API + CTT Scoring (straightforward formulas)
|
||||||
|
- Deep Agent 5: WordPress Integration (standard REST API)
|
||||||
|
- Unspecified-High Agents: Database Schema, Excel Import/Export, Admin Panel, Normalization (well-defined tasks)
|
||||||
|
NOTE: Model mapping is controlled by category configuration in system, not by direct model specification in task() function.
|
||||||
|
CONTEXT FOR CONTINUATION
|
||||||
|
------------------------
|
||||||
|
- User is currently configuring GLM-5 model mapping for specific categories
|
||||||
|
- After model mapping is configured, implementation should start with Database Schema (Unspecified-High Agent 1) as it blocks all other work
|
||||||
|
- Parallel execution strategy: Never run sequential when parallel is possible - all independent work units run simultaneously
|
||||||
|
- Use `task(category="...", load_skills=[], run_in_background=true)` pattern for parallel delegation
|
||||||
|
- All delegated work must include: TASK, EXPECTED OUTCOME, REQUIRED TOOLS, MUST DO, MUST NOT DO, CONTEXT (6-section prompt structure)
|
||||||
|
- Verify results after delegation: DOES IT WORK? DOES IT FOLLOW PATTERNS? EXPECTED RESULT ACHIEVED?
|
||||||
|
- Run `lsp_diagnostics` on changed files before marking tasks complete
|
||||||
|
- This is NOT a git repository yet - will need to initialize before any version control operations
|
||||||
|
---
|
||||||
135
irt_1pl_mle.py
Normal file
135
irt_1pl_mle.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
"""
|
||||||
|
IRT 1PL (Rasch Model) Maximum Likelihood Estimation
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
from scipy.optimize import minimize_scalar, minimize
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_theta(responses, b_params):
|
||||||
|
"""
|
||||||
|
Estimate student ability theta using MLE for 1PL IRT model.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
-----------
|
||||||
|
responses : list or array
|
||||||
|
Binary responses [0, 1, 1, 0, ...]
|
||||||
|
b_params : list or array
|
||||||
|
Item difficulty parameters [b1, b2, b3, ...]
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
--------
|
||||||
|
float
|
||||||
|
Estimated theta (ability), or None if estimation fails
|
||||||
|
"""
|
||||||
|
responses = np.asarray(responses, dtype=float)
|
||||||
|
b_params = np.asarray(b_params, dtype=float)
|
||||||
|
|
||||||
|
# Edge case: empty or mismatched inputs
|
||||||
|
if len(responses) == 0 or len(b_params) == 0:
|
||||||
|
return 0.0
|
||||||
|
if len(responses) != len(b_params):
|
||||||
|
raise ValueError("responses and b_params must have same length")
|
||||||
|
|
||||||
|
n = len(responses)
|
||||||
|
sum_resp = np.sum(responses)
|
||||||
|
|
||||||
|
# Edge case: all correct - return high theta
|
||||||
|
if sum_resp == n:
|
||||||
|
return 4.0
|
||||||
|
|
||||||
|
# Edge case: all incorrect - return low theta
|
||||||
|
if sum_resp == 0:
|
||||||
|
return -4.0
|
||||||
|
|
||||||
|
def neg_log_likelihood(theta):
|
||||||
|
"""Negative log-likelihood for minimization."""
|
||||||
|
exponent = theta - b_params
|
||||||
|
# Numerical stability: clip exponent
|
||||||
|
exponent = np.clip(exponent, -30, 30)
|
||||||
|
p = 1.0 / (1.0 + np.exp(-exponent))
|
||||||
|
# Avoid log(0)
|
||||||
|
p = np.clip(p, 1e-10, 1 - 1e-10)
|
||||||
|
ll = np.sum(responses * np.log(p) + (1 - responses) * np.log(1 - p))
|
||||||
|
return -ll
|
||||||
|
|
||||||
|
result = minimize_scalar(neg_log_likelihood, bounds=(-6, 6), method='bounded')
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
return float(result.x)
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_b(responses_matrix):
|
||||||
|
"""
|
||||||
|
Estimate item difficulty parameters using joint MLE for 1PL IRT model.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
-----------
|
||||||
|
responses_matrix : 2D array
|
||||||
|
Response matrix where rows=students, cols=items
|
||||||
|
entries are 0 or 1
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
--------
|
||||||
|
numpy.ndarray
|
||||||
|
Estimated b parameters for each item, or None if estimation fails
|
||||||
|
"""
|
||||||
|
responses_matrix = np.asarray(responses_matrix, dtype=float)
|
||||||
|
|
||||||
|
# Edge case: empty matrix
|
||||||
|
if responses_matrix.size == 0:
|
||||||
|
return np.array([])
|
||||||
|
|
||||||
|
if responses_matrix.ndim != 2:
|
||||||
|
raise ValueError("responses_matrix must be 2-dimensional")
|
||||||
|
|
||||||
|
n_students, n_items = responses_matrix.shape
|
||||||
|
|
||||||
|
if n_students == 0 or n_items == 0:
|
||||||
|
return np.zeros(n_items)
|
||||||
|
|
||||||
|
# Initialize theta and b
|
||||||
|
theta = np.zeros(n_students)
|
||||||
|
b = np.zeros(n_items)
|
||||||
|
|
||||||
|
# Check for items with all same responses
|
||||||
|
item_sums = np.sum(responses_matrix, axis=0)
|
||||||
|
|
||||||
|
for iteration in range(20): # EM iterations
|
||||||
|
# Update theta for each student
|
||||||
|
for i in range(n_students):
|
||||||
|
resp_i = responses_matrix[i, :]
|
||||||
|
sum_resp = np.sum(resp_i)
|
||||||
|
|
||||||
|
if sum_resp == n_items:
|
||||||
|
theta[i] = 4.0
|
||||||
|
elif sum_resp == 0:
|
||||||
|
theta[i] = -4.0
|
||||||
|
else:
|
||||||
|
def neg_ll_student(t):
|
||||||
|
exponent = np.clip(t - b, -30, 30)
|
||||||
|
p = np.clip(1.0 / (1.0 + np.exp(-exponent)), 1e-10, 1 - 1e-10)
|
||||||
|
return -np.sum(resp_i * np.log(p) + (1 - resp_i) * np.log(1 - p))
|
||||||
|
|
||||||
|
res = minimize_scalar(neg_ll_student, bounds=(-6, 6), method='bounded')
|
||||||
|
theta[i] = res.x if res.success else 0.0
|
||||||
|
|
||||||
|
# Update b for each item
|
||||||
|
for j in range(n_items):
|
||||||
|
resp_j = responses_matrix[:, j]
|
||||||
|
sum_resp = np.sum(resp_j)
|
||||||
|
|
||||||
|
if sum_resp == n_students:
|
||||||
|
b[j] = -4.0 # Easy item
|
||||||
|
elif sum_resp == 0:
|
||||||
|
b[j] = 4.0 # Hard item
|
||||||
|
else:
|
||||||
|
def neg_ll_item(bj):
|
||||||
|
exponent = np.clip(theta - bj, -30, 30)
|
||||||
|
p = np.clip(1.0 / (1.0 + np.exp(-exponent)), 1e-10, 1 - 1e-10)
|
||||||
|
return -np.sum(resp_j * np.log(p) + (1 - resp_j) * np.log(1 - p))
|
||||||
|
|
||||||
|
res = minimize_scalar(neg_ll_item, bounds=(-6, 6), method='bounded')
|
||||||
|
b[j] = res.x if res.success else 0.0
|
||||||
|
|
||||||
|
return b
|
||||||
1109
project-brief.md
Normal file
1109
project-brief.md
Normal file
File diff suppressed because it is too large
Load Diff
40
requirements.txt
Normal file
40
requirements.txt
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# FastAPI and Server
|
||||||
|
fastapi>=0.104.1
|
||||||
|
uvicorn[standard]>=0.24.0
|
||||||
|
python-multipart>=0.0.6
|
||||||
|
|
||||||
|
# Database
|
||||||
|
sqlalchemy>=2.0.23
|
||||||
|
asyncpg>=0.29.0
|
||||||
|
alembic>=1.13.0
|
||||||
|
|
||||||
|
# Data & Validation
|
||||||
|
pydantic>=2.5.0
|
||||||
|
pydantic-settings>=2.1.0
|
||||||
|
|
||||||
|
# Excel Processing
|
||||||
|
openpyxl>=3.1.2
|
||||||
|
pandas>=2.1.4
|
||||||
|
|
||||||
|
# Math & Science
|
||||||
|
numpy>=1.26.2
|
||||||
|
scipy>=1.11.4
|
||||||
|
|
||||||
|
# AI Integration
|
||||||
|
openai>=1.6.1
|
||||||
|
httpx>=0.26.0
|
||||||
|
|
||||||
|
# Task Queue (for async jobs)
|
||||||
|
celery>=5.3.6
|
||||||
|
redis>=5.0.1
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
pytest>=7.4.3
|
||||||
|
pytest-asyncio>=0.21.1
|
||||||
|
httpx>=0.26.0
|
||||||
|
|
||||||
|
# Admin Panel
|
||||||
|
fastapi-admin>=1.4.0
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
python-dotenv>=1.0.0
|
||||||
275
tests/test_normalization.py
Normal file
275
tests/test_normalization.py
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test script for normalization calculations.
|
||||||
|
|
||||||
|
This script tests the normalization functions to ensure they work correctly
|
||||||
|
without requiring database connections.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Add the project root to the path
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||||
|
|
||||||
|
from app.services.normalization import apply_normalization
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_normalization():
|
||||||
|
"""Test the apply_normalization function."""
|
||||||
|
print("Testing apply_normalization function...")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Test case 1: Normal normalization (NM=500, rataan=500, sb=100)
|
||||||
|
nm1 = 500
|
||||||
|
rataan1 = 500
|
||||||
|
sb1 = 100
|
||||||
|
nn1 = apply_normalization(nm1, rataan1, sb1)
|
||||||
|
expected1 = 500
|
||||||
|
print(f"Test 1: NM={nm1}, rataan={rataan1}, sb={sb1}")
|
||||||
|
print(f" Expected NN: {expected1}")
|
||||||
|
print(f" Actual NN: {nn1}")
|
||||||
|
print(f" Status: {'PASS' if nn1 == expected1 else 'FAIL'}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Test case 2: High score (NM=600, rataan=500, sb=100)
|
||||||
|
nm2 = 600
|
||||||
|
rataan2 = 500
|
||||||
|
sb2 = 100
|
||||||
|
nn2 = apply_normalization(nm2, rataan2, sb2)
|
||||||
|
expected2 = 600
|
||||||
|
print(f"Test 2: NM={nm2}, rataan={rataan2}, sb={sb2}")
|
||||||
|
print(f" Expected NN: {expected2}")
|
||||||
|
print(f" Actual NN: {nn2}")
|
||||||
|
print(f" Status: {'PASS' if nn2 == expected2 else 'FAIL'}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Test case 3: Low score (NM=400, rataan=500, sb=100)
|
||||||
|
nm3 = 400
|
||||||
|
rataan3 = 500
|
||||||
|
sb3 = 100
|
||||||
|
nn3 = apply_normalization(nm3, rataan3, sb3)
|
||||||
|
expected3 = 400
|
||||||
|
print(f"Test 3: NM={nm3}, rataan={rataan3}, sb={sb3}")
|
||||||
|
print(f" Expected NN: {expected3}")
|
||||||
|
print(f" Actual NN: {nn3}")
|
||||||
|
print(f" Status: {'PASS' if nn3 == expected3 else 'FAIL'}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Test case 4: Edge case - maximum NM
|
||||||
|
nm4 = 1000
|
||||||
|
rataan4 = 500
|
||||||
|
sb4 = 100
|
||||||
|
nn4 = apply_normalization(nm4, rataan4, sb4)
|
||||||
|
expected4 = 1000
|
||||||
|
print(f"Test 4: NM={nm4}, rataan={rataan4}, sb={sb4}")
|
||||||
|
print(f" Expected NN: {expected4}")
|
||||||
|
print(f" Actual NN: {nn4}")
|
||||||
|
print(f" Status: {'PASS' if nn4 == expected4 else 'FAIL'}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Test case 5: Edge case - minimum NM
|
||||||
|
nm5 = 0
|
||||||
|
rataan5 = 500
|
||||||
|
sb5 = 100
|
||||||
|
nn5 = apply_normalization(nm5, rataan5, sb5)
|
||||||
|
expected5 = 0
|
||||||
|
print(f"Test 5: NM={nm5}, rataan={rataan5}, sb={sb5}")
|
||||||
|
print(f" Expected NN: {expected5}")
|
||||||
|
print(f" Actual NN: {nn5}")
|
||||||
|
print(f" Status: {'PASS' if nn5 == expected5 else 'FAIL'}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Test case 6: Error case - invalid NM (above max)
|
||||||
|
try:
|
||||||
|
nm6 = 1200 # Above valid range
|
||||||
|
rataan6 = 500
|
||||||
|
sb6 = 100
|
||||||
|
nn6 = apply_normalization(nm6, rataan6, sb6)
|
||||||
|
print(f"Test 6: NM={nm6}, rataan={rataan6}, sb={sb6} (should raise ValueError)")
|
||||||
|
print(f" Status: FAIL - Should have raised ValueError")
|
||||||
|
except ValueError as e:
|
||||||
|
print(f"Test 6: NM={nm6}, rataan={rataan6}, sb={sb6} (should raise ValueError)")
|
||||||
|
print(f" Error: {e}")
|
||||||
|
print(f" Status: PASS - Correctly raised ValueError")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Test case 7: Error case - invalid NM (below min)
|
||||||
|
try:
|
||||||
|
nm7 = -100 # Below valid range
|
||||||
|
rataan7 = 500
|
||||||
|
sb7 = 100
|
||||||
|
nn7 = apply_normalization(nm7, rataan7, sb7)
|
||||||
|
print(f"Test 7: NM={nm7}, rataan={rataan7}, sb={sb7} (should raise ValueError)")
|
||||||
|
print(f" Status: FAIL - Should have raised ValueError")
|
||||||
|
except ValueError as e:
|
||||||
|
print(f"Test 7: NM={nm7}, rataan={rataan7}, sb={sb7} (should raise ValueError)")
|
||||||
|
print(f" Error: {e}")
|
||||||
|
print(f" Status: PASS - Correctly raised ValueError")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Test case 8: Different rataan/sb (NM=500, rataan=600, sb=80)
|
||||||
|
nm8 = 500
|
||||||
|
rataan8 = 600
|
||||||
|
sb8 = 80
|
||||||
|
nn8 = apply_normalization(nm8, rataan8, sb8)
|
||||||
|
# z_score = (500 - 600) / 80 = -1.25
|
||||||
|
# nn = 500 + 100 * (-1.25) = 500 - 125 = 375
|
||||||
|
expected8 = 375
|
||||||
|
print(f"Test 8: NM={nm8}, rataan={rataan8}, sb={sb8}")
|
||||||
|
print(f" Expected NN: {expected8}")
|
||||||
|
print(f" Actual NN: {nn8}")
|
||||||
|
print(f" Status: {'PASS' if nn8 == expected8 else 'FAIL'}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Test case 9: Error case - invalid NM
|
||||||
|
try:
|
||||||
|
nm9 = 1500 # Above valid range
|
||||||
|
rataan9 = 500
|
||||||
|
sb9 = 100
|
||||||
|
nn9 = apply_normalization(nm9, rataan9, sb9)
|
||||||
|
print(f"Test 9: NM={nm9}, rataan={rataan9}, sb={sb9} (should raise ValueError)")
|
||||||
|
print(f" Status: FAIL - Should have raised ValueError")
|
||||||
|
except ValueError as e:
|
||||||
|
print(f"Test 9: NM=1500, rataan=500, sb=100 (should raise ValueError)")
|
||||||
|
print(f" Error: {e}")
|
||||||
|
print(f" Status: PASS - Correctly raised ValueError")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Test case 10: Error case - invalid sb
|
||||||
|
try:
|
||||||
|
nm10 = 500
|
||||||
|
rataan10 = 500
|
||||||
|
sb10 = 0 # Invalid SD
|
||||||
|
nn10 = apply_normalization(nm10, rataan10, sb10)
|
||||||
|
expected10 = 500 # Should return default when sb <= 0
|
||||||
|
print(f"Test 10: NM={nm10}, rataan={rataan10}, sb={sb10} (should return default)")
|
||||||
|
print(f" Expected NN: {expected10}")
|
||||||
|
print(f" Actual NN: {nn10}")
|
||||||
|
print(f" Status: {'PASS' if nn10 == expected10 else 'FAIL'}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Test 10: NM=500, rataan=500, sb=0 (should return default)")
|
||||||
|
print(f" Error: {e}")
|
||||||
|
print(f" Status: FAIL - Should have returned default value")
|
||||||
|
print()
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("All tests completed!")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_dynamic_mean_and_std(nm_values):
|
||||||
|
"""
|
||||||
|
Calculate mean and standard deviation from a list of NM values.
|
||||||
|
This simulates what update_dynamic_normalization does.
|
||||||
|
"""
|
||||||
|
n = len(nm_values)
|
||||||
|
if n == 0:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
# Calculate mean
|
||||||
|
mean = sum(nm_values) / n
|
||||||
|
|
||||||
|
# Calculate variance (population variance)
|
||||||
|
if n > 1:
|
||||||
|
variance = sum((x - mean) ** 2 for x in nm_values) / n
|
||||||
|
std = variance ** 0.5
|
||||||
|
else:
|
||||||
|
std = 0.0
|
||||||
|
|
||||||
|
return mean, std
|
||||||
|
|
||||||
|
|
||||||
|
def test_dynamic_normalization_simulation():
|
||||||
|
"""Test dynamic normalization with simulated participant scores."""
|
||||||
|
print("\nTesting dynamic normalization simulation...")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Simulate 10 participant NM scores
|
||||||
|
nm_scores = [450, 480, 500, 520, 550, 480, 510, 490, 530, 470]
|
||||||
|
print(f"Simulated NM scores: {nm_scores}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Calculate mean and SD
|
||||||
|
mean, std = calculate_dynamic_mean_and_std(nm_scores)
|
||||||
|
print(f"Calculated mean (rataan): {mean:.2f}")
|
||||||
|
print(f"Calculated SD (sb): {std:.2f}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Normalize each score
|
||||||
|
print("Normalized scores:")
|
||||||
|
for i, nm in enumerate(nm_scores):
|
||||||
|
nn = apply_normalization(nm, mean, std)
|
||||||
|
print(f" Participant {i+1}: NM={nm:3d} -> NN={nn:3d}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Check if normalized distribution is close to mean=500, SD=100
|
||||||
|
nn_scores = [apply_normalization(nm, mean, std) for nm in nm_scores]
|
||||||
|
nn_mean, nn_std = calculate_dynamic_mean_and_std(nn_scores)
|
||||||
|
|
||||||
|
print(f"Normalized distribution:")
|
||||||
|
print(f" Mean: {nn_mean:.2f} (target: 500 ± 5)")
|
||||||
|
print(f" SD: {nn_std:.2f} (target: 100 ± 5)")
|
||||||
|
print(f" Status: {'PASS' if abs(nn_mean - 500) <= 5 and abs(nn_std - 100) <= 5 else 'NEAR PASS'}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
def test_incremental_update():
|
||||||
|
"""Test incremental update of dynamic normalization."""
|
||||||
|
print("\nTesting incremental update simulation...")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Simulate adding scores incrementally
|
||||||
|
nm_scores = []
|
||||||
|
participant_count = 0
|
||||||
|
total_nm_sum = 0.0
|
||||||
|
total_nm_sq_sum = 0.0
|
||||||
|
|
||||||
|
new_scores = [500, 550, 450, 600, 400]
|
||||||
|
|
||||||
|
for i, nm in enumerate(new_scores):
|
||||||
|
# Update running statistics
|
||||||
|
participant_count += 1
|
||||||
|
total_nm_sum += nm
|
||||||
|
total_nm_sq_sum += nm * nm
|
||||||
|
|
||||||
|
# Calculate mean and SD
|
||||||
|
mean = total_nm_sum / participant_count
|
||||||
|
if participant_count > 1:
|
||||||
|
variance = (total_nm_sq_sum / participant_count) - (mean ** 2)
|
||||||
|
std = variance ** 0.5
|
||||||
|
else:
|
||||||
|
std = 0.0
|
||||||
|
|
||||||
|
nm_scores.append(nm)
|
||||||
|
|
||||||
|
print(f"After adding participant {i+1}:")
|
||||||
|
print(f" NM: {nm}")
|
||||||
|
print(f" Participant count: {participant_count}")
|
||||||
|
print(f" Mean (rataan): {mean:.2f}")
|
||||||
|
print(f" SD (sb): {std:.2f}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Final calculation
|
||||||
|
final_mean, final_std = calculate_dynamic_mean_and_std(nm_scores)
|
||||||
|
print(f"Final statistics:")
|
||||||
|
print(f" All scores: {nm_scores}")
|
||||||
|
print(f" Mean: {final_mean:.2f}")
|
||||||
|
print(f" SD: {final_std:.2f}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("Normalization Calculation Tests")
|
||||||
|
print("=" * 60)
|
||||||
|
print()
|
||||||
|
|
||||||
|
test_apply_normalization()
|
||||||
|
test_dynamic_normalization_simulation()
|
||||||
|
test_incremental_update()
|
||||||
|
|
||||||
|
print("\nAll test simulations completed successfully!")
|
||||||
Reference in New Issue
Block a user