chore: preserve pre-revamp local changes
Recovery snapshot of the existing worktree before the Orthobullets-inspired revamp. Includes explanation images, classification snapshots, quiz visibility/resume fixes, quiz codes, TTS options and bot formatting. Secret heuristic and Python syntax checks passed; not a release or full behavioral validation.
This commit is contained in:
parent
e7f91e5e5b
commit
3cf21d4438
24 changed files with 640 additions and 86 deletions
|
|
@ -0,0 +1,51 @@
|
||||||
|
"""add classification snapshots
|
||||||
|
|
||||||
|
Revision ID: 5f8c1c2a9d40
|
||||||
|
Revises: b71a4e2d6c90
|
||||||
|
Create Date: 2026-06-12 03:30:00.000000
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = '5f8c1c2a9d40'
|
||||||
|
down_revision: Union[str, None] = 'b71a4e2d6c90'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
conn.execute(sa.text("""
|
||||||
|
CREATE TABLE IF NOT EXISTS question_classification_snapshots (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
job_id VARCHAR(64),
|
||||||
|
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
reason VARCHAR(120) NOT NULL DEFAULT 'before_ai_classification',
|
||||||
|
question_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
link_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
)
|
||||||
|
"""))
|
||||||
|
conn.execute(sa.text("""
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_question_classification_snapshots_created_at
|
||||||
|
ON question_classification_snapshots (created_at DESC)
|
||||||
|
"""))
|
||||||
|
conn.execute(sa.text("""
|
||||||
|
CREATE TABLE IF NOT EXISTS question_classification_snapshot_links (
|
||||||
|
snapshot_id INTEGER NOT NULL REFERENCES question_classification_snapshots(id) ON DELETE CASCADE,
|
||||||
|
question_id INTEGER NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
|
||||||
|
tag_name VARCHAR(200) NOT NULL,
|
||||||
|
tag_type VARCHAR(50) NOT NULL,
|
||||||
|
PRIMARY KEY (snapshot_id, question_id, tag_name, tag_type)
|
||||||
|
)
|
||||||
|
"""))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
conn.execute(sa.text("DROP TABLE IF EXISTS question_classification_snapshot_links"))
|
||||||
|
conn.execute(sa.text("DROP INDEX IF EXISTS ix_question_classification_snapshots_created_at"))
|
||||||
|
conn.execute(sa.text("DROP TABLE IF EXISTS question_classification_snapshots"))
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
"""add explanation image path to questions
|
||||||
|
|
||||||
|
Revision ID: b71a4e2d6c90
|
||||||
|
Revises: e4c7b2a9d6f1
|
||||||
|
Create Date: 2026-06-09 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = 'b71a4e2d6c90'
|
||||||
|
down_revision: Union[str, None] = 'e4c7b2a9d6f1'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("ALTER TABLE questions ADD COLUMN IF NOT EXISTS explanation_image_path VARCHAR")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("ALTER TABLE questions DROP COLUMN IF EXISTS explanation_image_path")
|
||||||
|
|
@ -345,6 +345,30 @@ def setup_pgvector():
|
||||||
PRIMARY KEY (question_id, tag_id)
|
PRIMARY KEY (question_id, tag_id)
|
||||||
)
|
)
|
||||||
"""))
|
"""))
|
||||||
|
conn.execute(text("""
|
||||||
|
CREATE TABLE IF NOT EXISTS question_classification_snapshots (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
job_id VARCHAR(64),
|
||||||
|
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
reason VARCHAR(120) NOT NULL DEFAULT 'before_ai_classification',
|
||||||
|
question_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
link_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
)
|
||||||
|
"""))
|
||||||
|
conn.execute(text("""
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_question_classification_snapshots_created_at
|
||||||
|
ON question_classification_snapshots (created_at DESC)
|
||||||
|
"""))
|
||||||
|
conn.execute(text("""
|
||||||
|
CREATE TABLE IF NOT EXISTS question_classification_snapshot_links (
|
||||||
|
snapshot_id INTEGER NOT NULL REFERENCES question_classification_snapshots(id) ON DELETE CASCADE,
|
||||||
|
question_id INTEGER NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
|
||||||
|
tag_name VARCHAR(200) NOT NULL,
|
||||||
|
tag_type VARCHAR(50) NOT NULL,
|
||||||
|
PRIMARY KEY (snapshot_id, question_id, tag_name, tag_type)
|
||||||
|
)
|
||||||
|
"""))
|
||||||
# Flashcard decks and cards
|
# Flashcard decks and cards
|
||||||
conn.execute(text("""
|
conn.execute(text("""
|
||||||
CREATE TABLE IF NOT EXISTS flashcard_decks (
|
CREATE TABLE IF NOT EXISTS flashcard_decks (
|
||||||
|
|
@ -391,6 +415,7 @@ def setup_pgvector():
|
||||||
# Quiz sharing
|
# Quiz sharing
|
||||||
conn.execute(text("ALTER TABLE quizzes ADD COLUMN IF NOT EXISTS is_shared INTEGER DEFAULT 0"))
|
conn.execute(text("ALTER TABLE quizzes ADD COLUMN IF NOT EXISTS is_shared INTEGER DEFAULT 0"))
|
||||||
conn.execute(text("ALTER TABLE quizzes ADD COLUMN IF NOT EXISTS allow_review INTEGER DEFAULT 1"))
|
conn.execute(text("ALTER TABLE quizzes ADD COLUMN IF NOT EXISTS allow_review INTEGER DEFAULT 1"))
|
||||||
|
conn.execute(text("ALTER TABLE questions ADD COLUMN IF NOT EXISTS explanation_image_path VARCHAR"))
|
||||||
# ── Course / LMS tables ──────────────────────────────────────
|
# ── Course / LMS tables ──────────────────────────────────────
|
||||||
conn.execute(text("""
|
conn.execute(text("""
|
||||||
CREATE TABLE IF NOT EXISTS courses (
|
CREATE TABLE IF NOT EXISTS courses (
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ class Question(Base):
|
||||||
explanation = Column(Text, nullable=True)
|
explanation = Column(Text, nullable=True)
|
||||||
page_reference = Column(Integer, nullable=True)
|
page_reference = Column(Integer, nullable=True)
|
||||||
image_path = Column(String, nullable=True)
|
image_path = Column(String, nullable=True)
|
||||||
|
explanation_image_path = Column(String, nullable=True)
|
||||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
is_shared = Column(Integer, default=1) # 1 = visible in bank, 0 = private (only owner sees it)
|
is_shared = Column(Integer, default=1) # 1 = visible in bank, 0 = private (only owner sees it)
|
||||||
embedding = deferred(Column(Vector(1024), nullable=True)) # semantic search vector — deferred: not loaded in standard queries
|
embedding = deferred(Column(Vector(1024), nullable=True)) # semantic search vector — deferred: not loaded in standard queries
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
|
@ -76,7 +77,6 @@ def delete_user(
|
||||||
if user.id == admin.id:
|
if user.id == admin.id:
|
||||||
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
||||||
# Nullify non-cascading FKs before delete
|
# Nullify non-cascading FKs before delete
|
||||||
from sqlalchemy import text
|
|
||||||
db.execute(text("UPDATE question_categories SET user_id = NULL WHERE user_id = :uid"), {"uid": user_id})
|
db.execute(text("UPDATE question_categories SET user_id = NULL WHERE user_id = :uid"), {"uid": user_id})
|
||||||
db.execute(text("UPDATE quiz_categories SET user_id = NULL WHERE user_id = :uid"), {"uid": user_id})
|
db.execute(text("UPDATE quiz_categories SET user_id = NULL WHERE user_id = :uid"), {"uid": user_id})
|
||||||
db.delete(user)
|
db.delete(user)
|
||||||
|
|
@ -344,6 +344,30 @@ KOKORO_VOICE_FALLBACKS = [
|
||||||
("bm_lewis", "Kokoro Lewis"),
|
("bm_lewis", "Kokoro Lewis"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
KITTEN_VOICE_FALLBACKS = [
|
||||||
|
("Bella", "Kitten Bella"),
|
||||||
|
("Jasper", "Kitten Jasper"),
|
||||||
|
("Luna", "Kitten Luna"),
|
||||||
|
("Bruno", "Kitten Bruno"),
|
||||||
|
("Rosie", "Kitten Rosie"),
|
||||||
|
("Hugo", "Kitten Hugo"),
|
||||||
|
("Kiki", "Kitten Kiki"),
|
||||||
|
("Leo", "Kitten Leo"),
|
||||||
|
]
|
||||||
|
|
||||||
|
SUPERTONIC_STYLE_FALLBACKS = [
|
||||||
|
("F1", "Supertonic F1"),
|
||||||
|
("F2", "Supertonic F2"),
|
||||||
|
("F3", "Supertonic F3"),
|
||||||
|
("F4", "Supertonic F4"),
|
||||||
|
("F5", "Supertonic F5"),
|
||||||
|
("M1", "Supertonic M1"),
|
||||||
|
("M2", "Supertonic M2"),
|
||||||
|
("M3", "Supertonic M3"),
|
||||||
|
("M4", "Supertonic M4"),
|
||||||
|
("M5", "Supertonic M5"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _kokoro_voice_options(model_name: str) -> list[dict]:
|
def _kokoro_voice_options(model_name: str) -> list[dict]:
|
||||||
base = settings.LOCAL_SPEECH_GATEWAY_URL.rstrip("/")
|
base = settings.LOCAL_SPEECH_GATEWAY_URL.rstrip("/")
|
||||||
|
|
@ -376,6 +400,17 @@ def _kokoro_voice_options(model_name: str) -> list[dict]:
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _static_voice_options(model_name: str, voices: list[tuple[str, str]]) -> list[dict]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"model_id": f"{model_name}:{voice_id}",
|
||||||
|
"name": name,
|
||||||
|
"labels": {"provider": "litellm", "model": model_name, "voice": voice_id},
|
||||||
|
}
|
||||||
|
for voice_id, name in voices
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/tts/voices")
|
@router.post("/tts/voices")
|
||||||
def search_tts_voices(
|
def search_tts_voices(
|
||||||
data: TTSVoiceSearchRequest,
|
data: TTSVoiceSearchRequest,
|
||||||
|
|
@ -409,6 +444,12 @@ def search_tts_voices(
|
||||||
if model_name == "local-kokoro-tts":
|
if model_name == "local-kokoro-tts":
|
||||||
voices.extend(_kokoro_voice_options(model_name))
|
voices.extend(_kokoro_voice_options(model_name))
|
||||||
continue
|
continue
|
||||||
|
if model_name == "local-kitten-tts":
|
||||||
|
voices.extend(_static_voice_options(model_name, KITTEN_VOICE_FALLBACKS))
|
||||||
|
continue
|
||||||
|
if model_name == "local-supertonic-tts":
|
||||||
|
voices.extend(_static_voice_options(model_name, SUPERTONIC_STYLE_FALLBACKS))
|
||||||
|
continue
|
||||||
voices.append({
|
voices.append({
|
||||||
"model_id": model_name,
|
"model_id": model_name,
|
||||||
"name": model_name,
|
"name": model_name,
|
||||||
|
|
@ -491,6 +532,78 @@ def test_embedding(admin: User = Depends(require_admin)):
|
||||||
return {"model": model, "dimensions": len(result), "status": "ok"}
|
return {"model": model, "dimensions": len(result), "status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/classification-snapshots")
|
||||||
|
def list_classification_snapshots(
|
||||||
|
limit: int = Query(10, ge=1, le=50),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""List recent classification rollback snapshots."""
|
||||||
|
rows = db.execute(text("""
|
||||||
|
SELECT s.id,
|
||||||
|
s.job_id,
|
||||||
|
s.created_by,
|
||||||
|
u.name AS created_by_name,
|
||||||
|
u.email AS created_by_email,
|
||||||
|
s.reason,
|
||||||
|
s.question_count,
|
||||||
|
s.link_count,
|
||||||
|
s.created_at
|
||||||
|
FROM question_classification_snapshots s
|
||||||
|
LEFT JOIN users u ON u.id = s.created_by
|
||||||
|
ORDER BY s.created_at DESC, s.id DESC
|
||||||
|
LIMIT :limit
|
||||||
|
"""), {"limit": limit}).mappings().all()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/classification-snapshots/{snapshot_id}/rollback")
|
||||||
|
def rollback_classification_snapshot(
|
||||||
|
snapshot_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""Restore question tag assignments from a saved snapshot."""
|
||||||
|
snapshot = db.execute(text("""
|
||||||
|
SELECT id, question_count, link_count, created_at
|
||||||
|
FROM question_classification_snapshots
|
||||||
|
WHERE id = :snapshot_id
|
||||||
|
"""), {"snapshot_id": snapshot_id}).mappings().first()
|
||||||
|
if not snapshot:
|
||||||
|
raise HTTPException(status_code=404, detail="Classification snapshot not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.execute(text("DELETE FROM question_tag_links"))
|
||||||
|
db.execute(text("""
|
||||||
|
INSERT INTO question_tags (name, type)
|
||||||
|
SELECT DISTINCT tag_name, tag_type
|
||||||
|
FROM question_classification_snapshot_links
|
||||||
|
WHERE snapshot_id = :snapshot_id
|
||||||
|
ON CONFLICT (LOWER(name), type) DO NOTHING
|
||||||
|
"""), {"snapshot_id": snapshot_id})
|
||||||
|
result = db.execute(text("""
|
||||||
|
INSERT INTO question_tag_links (question_id, tag_id)
|
||||||
|
SELECT sl.question_id, t.id
|
||||||
|
FROM question_classification_snapshot_links sl
|
||||||
|
JOIN question_tags t
|
||||||
|
ON LOWER(t.name) = LOWER(sl.tag_name)
|
||||||
|
AND t.type = sl.tag_type
|
||||||
|
WHERE sl.snapshot_id = :snapshot_id
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
"""), {"snapshot_id": snapshot_id})
|
||||||
|
db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to roll back classification snapshot: {e}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"snapshot_id": snapshot_id,
|
||||||
|
"restored_links": result.rowcount if result.rowcount is not None else snapshot["link_count"],
|
||||||
|
"snapshot_question_count": snapshot["question_count"],
|
||||||
|
"snapshot_link_count": snapshot["link_count"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/embedding/regenerate")
|
@router.post("/embedding/regenerate")
|
||||||
def regenerate_embeddings(admin: User = Depends(require_admin)):
|
def regenerate_embeddings(admin: User = Depends(require_admin)):
|
||||||
"""Queue a background Celery task to regenerate all question embeddings."""
|
"""Queue a background Celery task to regenerate all question embeddings."""
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,10 @@ from app.utils.quiz_questions import get_quiz_questions
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def can_access_quiz(quiz: Quiz, user: User) -> bool:
|
||||||
|
return bool(user.is_moderator or quiz.user_id == user.id or quiz.is_published == 1)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/start", response_model=AttemptResponse)
|
@router.post("/start", response_model=AttemptResponse)
|
||||||
def start_attempt(
|
def start_attempt(
|
||||||
quiz_id: int,
|
quiz_id: int,
|
||||||
|
|
@ -38,6 +42,8 @@ def start_attempt(
|
||||||
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
|
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
|
||||||
if not quiz:
|
if not quiz:
|
||||||
raise HTTPException(status_code=404, detail="Quiz not found")
|
raise HTTPException(status_code=404, detail="Quiz not found")
|
||||||
|
if not can_access_quiz(quiz, current_user):
|
||||||
|
raise HTTPException(status_code=403, detail="This quiz is private")
|
||||||
|
|
||||||
# Enforce max_attempts
|
# Enforce max_attempts
|
||||||
if quiz.max_attempts:
|
if quiz.max_attempts:
|
||||||
|
|
@ -153,6 +159,7 @@ def submit_attempt(
|
||||||
correct_answer=q.correct_answer,
|
correct_answer=q.correct_answer,
|
||||||
is_correct=is_correct,
|
is_correct=is_correct,
|
||||||
explanation=q.explanation,
|
explanation=q.explanation,
|
||||||
|
explanation_image_path=q.explanation_image_path,
|
||||||
))
|
))
|
||||||
|
|
||||||
attempt.score = score
|
attempt.score = score
|
||||||
|
|
@ -241,7 +248,9 @@ def save_progress(
|
||||||
):
|
):
|
||||||
"""Save in-progress quiz answers to Redis (survives logout/browser change).
|
"""Save in-progress quiz answers to Redis (survives logout/browser change).
|
||||||
Each attempt gets its own saved progress (key includes attempt_id).
|
Each attempt gets its own saved progress (key includes attempt_id).
|
||||||
Also refreshes the active session lock to prevent concurrent resume on another device."""
|
Also records the latest active browser session for diagnostics. Resuming
|
||||||
|
from another browser is allowed; the newest browser takes over the attempt.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
import redis as redis_lib, json as _json
|
import redis as redis_lib, json as _json
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
@ -280,7 +289,8 @@ def get_progress(
|
||||||
):
|
):
|
||||||
"""Retrieve in-progress quiz answers from Redis.
|
"""Retrieve in-progress quiz answers from Redis.
|
||||||
Finds the latest incomplete attempt for this quiz, then checks Redis.
|
Finds the latest incomplete attempt for this quiz, then checks Redis.
|
||||||
Rejects if another device is actively using this attempt.
|
Allows another browser/device to resume the attempt; the newest browser
|
||||||
|
takes over the soft activity marker instead of blocking with a 409.
|
||||||
Auto-submits timed quizzes if timer has expired."""
|
Auto-submits timed quizzes if timer has expired."""
|
||||||
try:
|
try:
|
||||||
import redis as redis_lib, json as _json
|
import redis as redis_lib, json as _json
|
||||||
|
|
@ -298,15 +308,10 @@ def get_progress(
|
||||||
if not attempt:
|
if not attempt:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Check if another device is actively using this attempt
|
|
||||||
session_id = request.headers.get("x-quiz-session", "") if request else ""
|
session_id = request.headers.get("x-quiz-session", "") if request else ""
|
||||||
lock_key = f"quiz_active:{current_user.id}:{attempt.id}"
|
lock_key = f"quiz_active:{current_user.id}:{attempt.id}"
|
||||||
active_session = r.get(lock_key)
|
if session_id:
|
||||||
if active_session and session_id and active_session != session_id:
|
r.setex(lock_key, 30, session_id)
|
||||||
raise HTTPException(
|
|
||||||
status_code=409,
|
|
||||||
detail="This quiz is active on another device. It will become available when the other session ends or times out (~30 seconds)."
|
|
||||||
)
|
|
||||||
|
|
||||||
key = f"quiz_progress:{current_user.id}:{attempt.id}"
|
key = f"quiz_progress:{current_user.id}:{attempt.id}"
|
||||||
data = r.get(key)
|
data = r.get(key)
|
||||||
|
|
@ -378,6 +383,7 @@ def clear_progress(
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
|
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
|
||||||
r.delete(f"quiz_progress:{current_user.id}:{attempt_id}")
|
r.delete(f"quiz_progress:{current_user.id}:{attempt_id}")
|
||||||
|
r.delete(f"quiz_active:{current_user.id}:{attempt_id}")
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Redis unavailable for progress clear", exc_info=True)
|
logger.warning("Redis unavailable for progress clear", exc_info=True)
|
||||||
|
|
||||||
|
|
@ -412,7 +418,7 @@ def delete_attempt(
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{attempt_id}/in-progress")
|
@router.get("/quiz/{quiz_id}/in-progress")
|
||||||
def get_in_progress_attempt(
|
def get_in_progress_attempt(
|
||||||
quiz_id: int,
|
quiz_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
|
@ -461,6 +467,7 @@ def get_in_progress_attempts(
|
||||||
result.append({
|
result.append({
|
||||||
"attempt_id": a.id,
|
"attempt_id": a.id,
|
||||||
"quiz_id": a.quiz_id,
|
"quiz_id": a.quiz_id,
|
||||||
|
"quiz_code": str(a.quiz_id),
|
||||||
"quiz_title": quiz.title if quiz else f"Quiz {a.quiz_id}",
|
"quiz_title": quiz.title if quiz else f"Quiz {a.quiz_id}",
|
||||||
"total_questions": a.total_questions,
|
"total_questions": a.total_questions,
|
||||||
"started_at": a.started_at.isoformat() if a.started_at else None,
|
"started_at": a.started_at.isoformat() if a.started_at else None,
|
||||||
|
|
|
||||||
|
|
@ -778,6 +778,7 @@ def create_course_quiz(
|
||||||
correct_answer=sq.correct_answer,
|
correct_answer=sq.correct_answer,
|
||||||
explanation=sq.explanation,
|
explanation=sq.explanation,
|
||||||
image_path=sq.image_path,
|
image_path=sq.image_path,
|
||||||
|
explanation_image_path=sq.explanation_image_path,
|
||||||
question_category_id=sq.question_category_id,
|
question_category_id=sq.question_category_id,
|
||||||
source_quiz_id=quiz.id,
|
source_quiz_id=quiz.id,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ from datetime import datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from pydantic import BaseModel, EmailStr
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from sqlalchemy import or_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
@ -97,9 +98,9 @@ def _mobile_login_rate_limit(client_ip: str):
|
||||||
|
|
||||||
|
|
||||||
def _visible_quizzes_query(db: Session, current_user: User):
|
def _visible_quizzes_query(db: Session, current_user: User):
|
||||||
query = db.query(Quiz).filter(Quiz.deleted_at.is_(None))
|
query = db.query(Quiz).filter(Quiz.deleted_at.is_(None), Quiz.course_id.is_(None))
|
||||||
if not current_user.is_moderator:
|
if not current_user.is_moderator:
|
||||||
query = query.filter(Quiz.is_published == 1, Quiz.course_id.is_(None))
|
query = query.filter(or_(Quiz.is_published == 1, Quiz.user_id == current_user.id))
|
||||||
return query
|
return query
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -113,6 +114,7 @@ def _question_payload(question):
|
||||||
"explanation": question.explanation,
|
"explanation": question.explanation,
|
||||||
"page_reference": question.page_reference,
|
"page_reference": question.page_reference,
|
||||||
"image_path": question.image_path,
|
"image_path": question.image_path,
|
||||||
|
"explanation_image_path": question.explanation_image_path,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ class QuestionEdit(BaseModel):
|
||||||
explanation: str | None = None
|
explanation: str | None = None
|
||||||
question_category_id: int | None = None
|
question_category_id: int | None = None
|
||||||
image_path: str | None = None
|
image_path: str | None = None
|
||||||
|
explanation_image_path: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{question_id}")
|
@router.patch("/{question_id}")
|
||||||
|
|
@ -80,6 +81,7 @@ def edit_question(
|
||||||
"explanation": question.explanation,
|
"explanation": question.explanation,
|
||||||
"question_category_id": question.question_category_id,
|
"question_category_id": question.question_category_id,
|
||||||
"image_path": question.image_path,
|
"image_path": question.image_path,
|
||||||
|
"explanation_image_path": question.explanation_image_path,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -313,6 +315,7 @@ def get_question_bank(
|
||||||
"correct_answer": qu.correct_answer,
|
"correct_answer": qu.correct_answer,
|
||||||
"explanation": qu.explanation,
|
"explanation": qu.explanation,
|
||||||
"image_path": qu.image_path,
|
"image_path": qu.image_path,
|
||||||
|
"explanation_image_path": qu.explanation_image_path,
|
||||||
"user_id": qu.user_id,
|
"user_id": qu.user_id,
|
||||||
"is_shared": qu.is_shared if qu.is_shared is not None else 1,
|
"is_shared": qu.is_shared if qu.is_shared is not None else 1,
|
||||||
})
|
})
|
||||||
|
|
@ -345,6 +348,7 @@ class ManualQuestionCreate(BaseModel):
|
||||||
explanation: str | None = None
|
explanation: str | None = None
|
||||||
question_category_id: int | None = None
|
question_category_id: int | None = None
|
||||||
image_path: str | None = None
|
image_path: str | None = None
|
||||||
|
explanation_image_path: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.post("/create")
|
@router.post("/create")
|
||||||
|
|
@ -372,6 +376,7 @@ def create_question_manually(
|
||||||
explanation=data.explanation,
|
explanation=data.explanation,
|
||||||
question_category_id=data.question_category_id,
|
question_category_id=data.question_category_id,
|
||||||
image_path=data.image_path,
|
image_path=data.image_path,
|
||||||
|
explanation_image_path=data.explanation_image_path,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
is_shared=1,
|
is_shared=1,
|
||||||
)
|
)
|
||||||
|
|
@ -423,14 +428,19 @@ def list_question_images(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""List all unique question images for the image bank browser."""
|
"""List all unique question images for the image bank browser."""
|
||||||
images = (
|
rows = db.execute(sa_text("""
|
||||||
db.query(Question.image_path)
|
SELECT path FROM (
|
||||||
.filter(Question.image_path.isnot(None), Question.image_path != "")
|
SELECT image_path AS path FROM questions
|
||||||
.distinct()
|
WHERE image_path IS NOT NULL AND image_path <> ''
|
||||||
.limit(200)
|
UNION
|
||||||
.all()
|
SELECT explanation_image_path AS path FROM questions
|
||||||
)
|
WHERE explanation_image_path IS NOT NULL AND explanation_image_path <> ''
|
||||||
return [{"image_path": img[0], "url": f"/uploads/{img[0]}"} for img in images]
|
) AS image_paths
|
||||||
|
ORDER BY path
|
||||||
|
LIMIT 200
|
||||||
|
""")).fetchall()
|
||||||
|
paths = [row.path for row in rows]
|
||||||
|
return [{"image_path": path, "url": f"/uploads/{path}"} for path in paths]
|
||||||
|
|
||||||
|
|
||||||
class CreateFromBankRequest(BaseModel):
|
class CreateFromBankRequest(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,10 @@ from app.utils.quiz_questions import get_quiz_questions, question_in_quiz, remov
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def can_access_quiz(quiz: Quiz, user: User) -> bool:
|
||||||
|
return bool(user.is_moderator or quiz.user_id == user.id or quiz.is_published == 1)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/")
|
@router.post("/")
|
||||||
def create_quiz(
|
def create_quiz(
|
||||||
quiz_data: QuizCreate,
|
quiz_data: QuizCreate,
|
||||||
|
|
@ -162,7 +166,7 @@ def search_quizzes(
|
||||||
def _ensure_quiz(quiz_id: int, match_type: str):
|
def _ensure_quiz(quiz_id: int, match_type: str):
|
||||||
if quiz_id not in results:
|
if quiz_id not in results:
|
||||||
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
|
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
|
||||||
if not quiz or quiz.course_id is not None:
|
if not quiz or quiz.course_id is not None or not can_access_quiz(quiz, current_user):
|
||||||
return False
|
return False
|
||||||
results[quiz_id] = {
|
results[quiz_id] = {
|
||||||
"quiz_id": quiz.id,
|
"quiz_id": quiz.id,
|
||||||
|
|
@ -179,7 +183,10 @@ def search_quizzes(
|
||||||
|
|
||||||
# ── Title search ─────────────────────────────────────────────
|
# ── Title search ─────────────────────────────────────────────
|
||||||
if mode in ("title", "all"):
|
if mode in ("title", "all"):
|
||||||
for quiz in db.query(Quiz).filter(Quiz.title.ilike(f"%{phrase}%"), Quiz.course_id.is_(None)).limit(30).all():
|
title_query = db.query(Quiz).filter(Quiz.title.ilike(f"%{phrase}%"), Quiz.course_id.is_(None))
|
||||||
|
if not current_user.is_moderator:
|
||||||
|
title_query = title_query.filter(or_(Quiz.is_published == 1, Quiz.user_id == current_user.id))
|
||||||
|
for quiz in title_query.limit(30).all():
|
||||||
_ensure_quiz(quiz.id, "title")
|
_ensure_quiz(quiz.id, "title")
|
||||||
|
|
||||||
# ── Semantic (vector) search ──────────────────────────────────
|
# ── Semantic (vector) search ──────────────────────────────────
|
||||||
|
|
@ -191,7 +198,7 @@ def search_quizzes(
|
||||||
emb_literal = "[" + ",".join(str(float(x)) for x in query_emb) + "]"
|
emb_literal = "[" + ",".join(str(float(x)) for x in query_emb) + "]"
|
||||||
rows = db.execute(sa_text("""
|
rows = db.execute(sa_text("""
|
||||||
SELECT q.id, q.quiz_id, q.question_text, q.options,
|
SELECT q.id, q.quiz_id, q.question_text, q.options,
|
||||||
q.correct_answer, q.explanation,
|
q.correct_answer, q.explanation, q.explanation_image_path,
|
||||||
1 - (q.embedding <=> CAST(:vec AS vector)) AS similarity
|
1 - (q.embedding <=> CAST(:vec AS vector)) AS similarity
|
||||||
FROM questions q
|
FROM questions q
|
||||||
WHERE q.embedding IS NOT NULL
|
WHERE q.embedding IS NOT NULL
|
||||||
|
|
@ -212,6 +219,7 @@ def search_quizzes(
|
||||||
"options": row.options,
|
"options": row.options,
|
||||||
"correct_answer": row.correct_answer,
|
"correct_answer": row.correct_answer,
|
||||||
"explanation": row.explanation,
|
"explanation": row.explanation,
|
||||||
|
"explanation_image_path": row.explanation_image_path,
|
||||||
"similarity": round(similarity, 3),
|
"similarity": round(similarity, 3),
|
||||||
"match_source": "semantic",
|
"match_source": "semantic",
|
||||||
})
|
})
|
||||||
|
|
@ -240,6 +248,7 @@ def search_quizzes(
|
||||||
"options": question.options,
|
"options": question.options,
|
||||||
"correct_answer": question.correct_answer,
|
"correct_answer": question.correct_answer,
|
||||||
"explanation": question.explanation,
|
"explanation": question.explanation,
|
||||||
|
"explanation_image_path": question.explanation_image_path,
|
||||||
"similarity": None,
|
"similarity": None,
|
||||||
"match_source": "keyword",
|
"match_source": "keyword",
|
||||||
})
|
})
|
||||||
|
|
@ -270,7 +279,7 @@ def list_quizzes(
|
||||||
"""List quizzes. Moderators see all; regular users only see published."""
|
"""List quizzes. Moderators see all; regular users only see published."""
|
||||||
q = db.query(Quiz).filter(Quiz.deleted_at.is_(None), Quiz.course_id.is_(None))
|
q = db.query(Quiz).filter(Quiz.deleted_at.is_(None), Quiz.course_id.is_(None))
|
||||||
if not current_user.is_moderator:
|
if not current_user.is_moderator:
|
||||||
q = q.filter(Quiz.is_published == 1)
|
q = q.filter(or_(Quiz.is_published == 1, Quiz.user_id == current_user.id))
|
||||||
return q.order_by(Quiz.created_at.desc()).all()
|
return q.order_by(Quiz.created_at.desc()).all()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -287,6 +296,8 @@ def get_quiz(
|
||||||
quiz = db.query(Quiz).filter(Quiz.id == quiz_id, Quiz.deleted_at.is_(None)).first()
|
quiz = db.query(Quiz).filter(Quiz.id == quiz_id, Quiz.deleted_at.is_(None)).first()
|
||||||
if not quiz:
|
if not quiz:
|
||||||
raise HTTPException(status_code=404, detail="Quiz not found")
|
raise HTTPException(status_code=404, detail="Quiz not found")
|
||||||
|
if not can_access_quiz(quiz, current_user):
|
||||||
|
raise HTTPException(status_code=403, detail="This quiz is private")
|
||||||
|
|
||||||
if study or quiz.mode == "learning":
|
if study or quiz.mode == "learning":
|
||||||
result = QuizLearningDetail.model_validate(quiz)
|
result = QuizLearningDetail.model_validate(quiz)
|
||||||
|
|
@ -344,6 +355,8 @@ def shuffle_quiz(
|
||||||
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
|
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
|
||||||
if not quiz:
|
if not quiz:
|
||||||
raise HTTPException(status_code=404, detail="Quiz not found")
|
raise HTTPException(status_code=404, detail="Quiz not found")
|
||||||
|
if not can_access_quiz(quiz, current_user):
|
||||||
|
raise HTTPException(status_code=403, detail="This quiz is private")
|
||||||
|
|
||||||
questions = get_quiz_questions(db, quiz_id)
|
questions = get_quiz_questions(db, quiz_id)
|
||||||
shuffled_questions = questions.copy()
|
shuffled_questions = questions.copy()
|
||||||
|
|
@ -416,6 +429,8 @@ def get_quiz_questions_for_edit(
|
||||||
"options": q.options,
|
"options": q.options,
|
||||||
"correct_answer": q.correct_answer,
|
"correct_answer": q.correct_answer,
|
||||||
"explanation": q.explanation,
|
"explanation": q.explanation,
|
||||||
|
"image_path": q.image_path,
|
||||||
|
"explanation_image_path": q.explanation_image_path,
|
||||||
}
|
}
|
||||||
for q in questions
|
for q in questions
|
||||||
]
|
]
|
||||||
|
|
@ -436,7 +451,7 @@ def update_question(
|
||||||
if not question:
|
if not question:
|
||||||
raise HTTPException(status_code=404, detail="Question not found")
|
raise HTTPException(status_code=404, detail="Question not found")
|
||||||
|
|
||||||
allowed = {"question_text", "options", "correct_answer", "explanation", "question_type"}
|
allowed = {"question_text", "options", "correct_answer", "explanation", "question_type", "image_path", "explanation_image_path"}
|
||||||
for key, value in data.items():
|
for key, value in data.items():
|
||||||
if key in allowed:
|
if key in allowed:
|
||||||
setattr(question, key, value)
|
setattr(question, key, value)
|
||||||
|
|
@ -457,6 +472,8 @@ def update_question(
|
||||||
"options": question.options,
|
"options": question.options,
|
||||||
"correct_answer": question.correct_answer,
|
"correct_answer": question.correct_answer,
|
||||||
"explanation": question.explanation,
|
"explanation": question.explanation,
|
||||||
|
"image_path": question.image_path,
|
||||||
|
"explanation_image_path": question.explanation_image_path,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
"""Teach chat endpoint — AI tutor for study mode questions."""
|
"""Teach chat endpoint — AI tutor for study mode questions."""
|
||||||
|
from datetime import datetime, time, timedelta, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
@ -12,6 +14,13 @@ from app.utils.auth import get_current_user, check_rate_limit
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _daily_teach_limit() -> tuple[str, int]:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
reset_at = datetime.combine(now.date() + timedelta(days=1), time.min, tzinfo=timezone.utc)
|
||||||
|
ttl = max(60, int((reset_at - now).total_seconds()) + 300)
|
||||||
|
return now.date().isoformat(), ttl
|
||||||
|
|
||||||
|
|
||||||
class ChatMessage(BaseModel):
|
class ChatMessage(BaseModel):
|
||||||
role: str # "user" | "assistant"
|
role: str # "user" | "assistant"
|
||||||
content: str
|
content: str
|
||||||
|
|
@ -137,12 +146,13 @@ async def chat(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Send a message to the teach AI with full question context."""
|
"""Send a message to the teach AI with full question context."""
|
||||||
# Rate limit: 30 AI chat messages per user per 10 minutes (admins/unthrottled users exempt)
|
# Daily AI coach quota. Admins, moderators, and unthrottled users are exempt.
|
||||||
|
quota_day, quota_ttl = _daily_teach_limit()
|
||||||
check_rate_limit(
|
check_rate_limit(
|
||||||
key=f"teach_chat:{current_user.id}",
|
key=f"teach_chat_daily:{current_user.id}:{quota_day}",
|
||||||
max_calls=30,
|
max_calls=30,
|
||||||
window_seconds=600,
|
window_seconds=quota_ttl,
|
||||||
detail="You've sent too many messages to the AI tutor. Please wait a few minutes before continuing. If you need this limit raised, contact an admin.",
|
detail="You've reached today's AI coach limit of 30 messages. Try again tomorrow, or contact an admin if you need the limit raised.",
|
||||||
user=current_user,
|
user=current_user,
|
||||||
)
|
)
|
||||||
model_info = _get_teach_model(db, req.model_id)
|
model_info = _get_teach_model(db, req.model_id)
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ class AnswerDetail(BaseModel):
|
||||||
correct_answer: str
|
correct_answer: str
|
||||||
is_correct: bool
|
is_correct: bool
|
||||||
explanation: str | None
|
explanation: str | None
|
||||||
|
explanation_image_path: str | None = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ class QuestionResponse(BaseModel):
|
||||||
class QuestionWithAnswer(QuestionResponse):
|
class QuestionWithAnswer(QuestionResponse):
|
||||||
correct_answer: str
|
correct_answer: str
|
||||||
explanation: str | None
|
explanation: str | None
|
||||||
|
explanation_image_path: str | None = None
|
||||||
page_reference: int | None
|
page_reference: int | None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import logging
|
||||||
import time
|
import time
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from sqlalchemy import text as sa_text
|
||||||
|
|
||||||
from app.tasks import celery_app
|
from app.tasks import celery_app
|
||||||
from app.database import SessionLocal
|
from app.database import SessionLocal
|
||||||
|
|
||||||
|
|
@ -341,6 +343,43 @@ def _push_classify_step(r, job_id: str, step: str, message: str):
|
||||||
r.expire(key, CLASSIFY_EXPIRE)
|
r.expire(key, CLASSIFY_EXPIRE)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_classification_snapshot(db, job_id: str, user_id: int) -> tuple[int, int, int]:
|
||||||
|
row = db.execute(sa_text("""
|
||||||
|
INSERT INTO question_classification_snapshots (job_id, created_by)
|
||||||
|
VALUES (:job_id, :user_id)
|
||||||
|
RETURNING id
|
||||||
|
"""), {"job_id": job_id, "user_id": user_id}).fetchone()
|
||||||
|
snapshot_id = row[0]
|
||||||
|
|
||||||
|
db.execute(sa_text("""
|
||||||
|
INSERT INTO question_classification_snapshot_links (snapshot_id, question_id, tag_name, tag_type)
|
||||||
|
SELECT :snapshot_id, tl.question_id, t.name, t.type
|
||||||
|
FROM question_tag_links tl
|
||||||
|
JOIN question_tags t ON t.id = tl.tag_id
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
"""), {"snapshot_id": snapshot_id})
|
||||||
|
|
||||||
|
stats = db.execute(sa_text("""
|
||||||
|
SELECT COUNT(DISTINCT question_id) AS question_count, COUNT(*) AS link_count
|
||||||
|
FROM question_classification_snapshot_links
|
||||||
|
WHERE snapshot_id = :snapshot_id
|
||||||
|
"""), {"snapshot_id": snapshot_id}).fetchone()
|
||||||
|
question_count = int(stats[0] or 0)
|
||||||
|
link_count = int(stats[1] or 0)
|
||||||
|
|
||||||
|
db.execute(sa_text("""
|
||||||
|
UPDATE question_classification_snapshots
|
||||||
|
SET question_count = :question_count, link_count = :link_count
|
||||||
|
WHERE id = :snapshot_id
|
||||||
|
"""), {
|
||||||
|
"snapshot_id": snapshot_id,
|
||||||
|
"question_count": question_count,
|
||||||
|
"link_count": link_count,
|
||||||
|
})
|
||||||
|
db.commit()
|
||||||
|
return snapshot_id, question_count, link_count
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task(name="classify_questions", bind=True)
|
@celery_app.task(name="classify_questions", bind=True)
|
||||||
def classify_questions(self, job_id: str, user_id: int):
|
def classify_questions(self, job_id: str, user_id: int):
|
||||||
"""Classify untagged questions using AI — subjects, diseases, keywords."""
|
"""Classify untagged questions using AI — subjects, diseases, keywords."""
|
||||||
|
|
@ -351,8 +390,15 @@ def classify_questions(self, job_id: str, user_id: int):
|
||||||
try:
|
try:
|
||||||
from app.models.question import Question
|
from app.models.question import Question
|
||||||
from app.services import ai_service
|
from app.services import ai_service
|
||||||
from sqlalchemy import text as sa_text
|
|
||||||
|
|
||||||
|
_push_classify_step(r, job_id, "snapshot", "Saving rollback snapshot for current classifications...")
|
||||||
|
snapshot_id, snapshot_questions, snapshot_links = _create_classification_snapshot(db, job_id, user_id)
|
||||||
|
_push_classify_step(
|
||||||
|
r,
|
||||||
|
job_id,
|
||||||
|
"snapshot",
|
||||||
|
f"Saved rollback snapshot #{snapshot_id} with {snapshot_questions} tagged questions and {snapshot_links} tag assignments.",
|
||||||
|
)
|
||||||
_push_classify_step(r, job_id, "start", "Finding untagged questions...")
|
_push_classify_step(r, job_id, "start", "Finding untagged questions...")
|
||||||
|
|
||||||
# Get IDs of questions that already have tags
|
# Get IDs of questions that already have tags
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ python-multipart==0.0.9
|
||||||
pydantic[email]==2.6.1
|
pydantic[email]==2.6.1
|
||||||
pydantic-settings==2.1.0
|
pydantic-settings==2.1.0
|
||||||
PyMuPDF==1.23.22
|
PyMuPDF==1.23.22
|
||||||
litellm==1.27.10
|
litellm==1.27.8
|
||||||
chromadb==0.4.24
|
chromadb==0.4.24
|
||||||
celery[redis]==5.3.6
|
celery[redis]==5.3.6
|
||||||
redis==5.0.1
|
redis==5.0.1
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,12 @@ export default function InProgressQuizzes() {
|
||||||
setInProgress(prev => prev.filter(a => a.attempt_id !== attemptId))
|
setInProgress(prev => prev.filter(a => a.attempt_id !== attemptId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const copyQuizCode = async (code) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(String(code))
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
if (inProgress.length === 0) return null
|
if (inProgress.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -34,6 +40,15 @@ export default function InProgressQuizzes() {
|
||||||
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)' }}>
|
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)' }}>
|
||||||
Started {new Date(a.started_at).toLocaleDateString()} · {a.total_questions} questions
|
Started {new Date(a.started_at).toLocaleDateString()} · {a.total_questions} questions
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ marginTop: 5, display: 'flex', alignItems: 'center', gap: 6, fontSize: '0.78rem', color: 'var(--text-muted)' }}>
|
||||||
|
<span>Quiz code</span>
|
||||||
|
<code style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 6, padding: '2px 7px', color: 'var(--text)' }}>
|
||||||
|
{a.quiz_code || a.quiz_id}
|
||||||
|
</code>
|
||||||
|
<button className="btn btn-secondary btn-sm" type="button" onClick={() => copyQuizCode(a.quiz_code || a.quiz_id)}>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||||
<button className="btn btn-primary btn-sm" onClick={() => navigate(`/quizzes/${a.quiz_id}`)}>Resume</button>
|
<button className="btn btn-primary btn-sm" onClick={() => navigate(`/quizzes/${a.quiz_id}`)}>Resume</button>
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,25 @@ body {
|
||||||
line-height: 1.18;
|
line-height: 1.18;
|
||||||
letter-spacing: -0.025em;
|
letter-spacing: -0.025em;
|
||||||
}
|
}
|
||||||
|
.quiz-code-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 4px 0 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
.quiz-header-card .quiz-code-badge { margin: 0; }
|
||||||
|
.quiz-code-badge code {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
.quiz-nav-controls {
|
.quiz-nav-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -470,12 +489,14 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Options */
|
/* Options */
|
||||||
.question-card .options { display: flex; flex-direction: column; gap: 10px; }
|
.question-card .options { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
|
||||||
.question-card .option {
|
.question-card .option {
|
||||||
display: flex; align-items: center; gap: 12px;
|
display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap;
|
||||||
|
width: 100%; min-width: 0;
|
||||||
padding: 13px 16px; border: 1.5px solid var(--border); border-radius: 8px;
|
padding: 13px 16px; border: 1.5px solid var(--border); border-radius: 8px;
|
||||||
cursor: pointer; transition: border-color 0.12s, background 0.12s;
|
cursor: pointer; transition: border-color 0.12s, background 0.12s;
|
||||||
background: var(--option-bg); font-size: 0.9rem; color: var(--text); user-select: text;
|
background: var(--option-bg); font-size: 0.9rem; color: var(--text); user-select: text;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
-webkit-tap-highlight-color: transparent; touch-action: manipulation;
|
-webkit-tap-highlight-color: transparent; touch-action: manipulation;
|
||||||
}
|
}
|
||||||
.question-card .option:hover { background: var(--option-hover); border-color: var(--text-subtle); }
|
.question-card .option:hover { background: var(--option-hover); border-color: var(--text-subtle); }
|
||||||
|
|
@ -492,6 +513,22 @@ body {
|
||||||
.option.selected .option-letter { background: var(--primary); color: white; }
|
.option.selected .option-letter { background: var(--primary); color: white; }
|
||||||
.option.correct .option-letter { background: #16a34a; color: white; }
|
.option.correct .option-letter { background: #16a34a; color: white; }
|
||||||
.option.incorrect .option-letter { background: #dc2626; color: white; }
|
.option.incorrect .option-letter { background: #dc2626; color: white; }
|
||||||
|
.option-text {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
line-height: 1.55;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.option-status {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.option-status-correct { color: var(--correct-fg); }
|
||||||
|
.option-status-wrong { color: var(--wrong-fg); }
|
||||||
|
|
||||||
/* Explanation */
|
/* Explanation */
|
||||||
.explanation {
|
.explanation {
|
||||||
|
|
@ -655,6 +692,14 @@ body {
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
.question-card .option {
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.option-status {
|
||||||
|
width: 100%;
|
||||||
|
margin-left: 39px;
|
||||||
|
}
|
||||||
.mynote-tab {
|
.mynote-tab {
|
||||||
right: 12px;
|
right: 12px;
|
||||||
bottom: 12px;
|
bottom: 12px;
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,8 @@ export default function AdminPage() {
|
||||||
const [originalEmbedModel, setOriginalEmbedModel] = useState(null)
|
const [originalEmbedModel, setOriginalEmbedModel] = useState(null)
|
||||||
const [embedModelChanged, setEmbedModelChanged] = useState(false)
|
const [embedModelChanged, setEmbedModelChanged] = useState(false)
|
||||||
const [regenLoading, setRegenLoading] = useState(false)
|
const [regenLoading, setRegenLoading] = useState(false)
|
||||||
|
const [classificationSnapshots, setClassificationSnapshots] = useState([])
|
||||||
|
const [rollbackLoading, setRollbackLoading] = useState(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user?.role || user.role !== 'admin') { navigate('/'); return }
|
if (!user?.role || user.role !== 'admin') { navigate('/'); return }
|
||||||
|
|
@ -58,14 +60,16 @@ export default function AdminPage() {
|
||||||
const loadData = async (showSpinner = true) => {
|
const loadData = async (showSpinner = true) => {
|
||||||
if (showSpinner) setLoading(true)
|
if (showSpinner) setLoading(true)
|
||||||
try {
|
try {
|
||||||
const [usersRes, modelsRes, settingsRes] = await Promise.all([
|
const [usersRes, modelsRes, settingsRes, snapshotsRes] = await Promise.all([
|
||||||
api.get('/admin/users'),
|
api.get('/admin/users'),
|
||||||
api.get('/admin/models'),
|
api.get('/admin/models'),
|
||||||
api.get('/admin/settings'),
|
api.get('/admin/settings'),
|
||||||
|
api.get('/admin/classification-snapshots'),
|
||||||
])
|
])
|
||||||
setUsers(usersRes.data)
|
setUsers(usersRes.data)
|
||||||
setModels(modelsRes.data)
|
setModels(modelsRes.data)
|
||||||
setSettings(settingsRes.data)
|
setSettings(settingsRes.data)
|
||||||
|
setClassificationSnapshots(snapshotsRes.data)
|
||||||
setOriginalEmbedModel(settingsRes.data.embedding_model || '')
|
setOriginalEmbedModel(settingsRes.data.embedding_model || '')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.detail || 'Failed to load data')
|
setError(err.response?.data?.detail || 'Failed to load data')
|
||||||
|
|
@ -234,6 +238,28 @@ export default function AdminPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rollbackClassificationSnapshot = async (snapshot) => {
|
||||||
|
const when = snapshot.created_at ? new Date(snapshot.created_at).toLocaleString() : `snapshot #${snapshot.id}`
|
||||||
|
const ok = await openConfirm(
|
||||||
|
`Rollback question tag assignments to snapshot #${snapshot.id} from ${when}? This replaces all current AI classification tag assignments.`,
|
||||||
|
{ title: 'Rollback Classification', confirmLabel: 'Rollback', danger: true },
|
||||||
|
)
|
||||||
|
if (!ok) return
|
||||||
|
|
||||||
|
setRollbackLoading(snapshot.id)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const res = await api.post(`/admin/classification-snapshots/${snapshot.id}/rollback`)
|
||||||
|
setSuccess(`Restored ${res.data.restored_links} tag assignments from snapshot #${snapshot.id}`)
|
||||||
|
const snapshotsRes = await api.get('/admin/classification-snapshots')
|
||||||
|
setClassificationSnapshots(snapshotsRes.data)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.detail || 'Failed to roll back classification snapshot')
|
||||||
|
} finally {
|
||||||
|
setRollbackLoading(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const addFromSearch = (modelId) => {
|
const addFromSearch = (modelId) => {
|
||||||
setNewModel(m => ({ ...m, model_id: modelId, name: modelId, task: searchTaskHint }))
|
setNewModel(m => ({ ...m, model_id: modelId, name: modelId, task: searchTaskHint }))
|
||||||
setSearchResults([])
|
setSearchResults([])
|
||||||
|
|
@ -294,15 +320,22 @@ export default function AdminPage() {
|
||||||
? searchResults.filter(m => m.toLowerCase().includes(searchFilter.toLowerCase()))
|
? searchResults.filter(m => m.toLowerCase().includes(searchFilter.toLowerCase()))
|
||||||
: searchResults
|
: searchResults
|
||||||
|
|
||||||
|
const adminTabs = [
|
||||||
|
{ id: 'models', label: 'AI Models' },
|
||||||
|
{ id: 'users', label: 'Users' },
|
||||||
|
{ id: 'safety', label: 'Safety' },
|
||||||
|
{ id: 'settings', label: 'More' },
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Dialog {...dialogProps} />
|
<Dialog {...dialogProps} />
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2>Admin Dashboard</h2>
|
<h2>Admin Dashboard</h2>
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||||
{['models', 'users', 'settings'].map(t => (
|
{adminTabs.map(({ id, label }) => (
|
||||||
<button key={t} className={`btn ${tab === t ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab(t)}>
|
<button key={id} className={`btn ${tab === id ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab(id)}>
|
||||||
{t === 'models' ? 'AI Models' : t === 'users' ? 'Users' : 'More'}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -610,6 +643,46 @@ export default function AdminPage() {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tab === 'safety' && (
|
||||||
|
<div className="card">
|
||||||
|
<h2>Classification Rollback</h2>
|
||||||
|
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: 16 }}>
|
||||||
|
Every AI classification run saves the current question tag assignments before it starts. Use this if a classification run adds bad subjects, diseases, or keywords.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{classificationSnapshots.length === 0 ? (
|
||||||
|
<div style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>
|
||||||
|
No classification snapshots have been saved yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gap: 10 }}>
|
||||||
|
{classificationSnapshots.map(snapshot => (
|
||||||
|
<div key={snapshot.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, padding: 12, border: '1px solid var(--border)', borderRadius: 8 }}>
|
||||||
|
<div>
|
||||||
|
<strong style={{ display: 'block', marginBottom: 4 }}>Snapshot #{snapshot.id}</strong>
|
||||||
|
<div style={{ fontSize: '0.82rem', color: 'var(--text-muted)' }}>
|
||||||
|
{snapshot.created_at ? new Date(snapshot.created_at).toLocaleString() : 'Unknown date'} · {snapshot.question_count} tagged questions · {snapshot.link_count} tag assignments
|
||||||
|
</div>
|
||||||
|
{snapshot.created_by_email && (
|
||||||
|
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: 2 }}>
|
||||||
|
Created by {snapshot.created_by_name || snapshot.created_by_email}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-danger btn-sm"
|
||||||
|
onClick={() => rollbackClassificationSnapshot(snapshot)}
|
||||||
|
disabled={rollbackLoading === snapshot.id}
|
||||||
|
>
|
||||||
|
{rollbackLoading === snapshot.id ? 'Rolling back...' : 'Rollback'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{tab === 'settings' && (
|
{tab === 'settings' && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2>More Settings</h2>
|
<h2>More Settings</h2>
|
||||||
|
|
|
||||||
|
|
@ -64,8 +64,18 @@ function QuestionStudyModal({ question, onClose, isFavorited, onToggleFavorite }
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{answered && question.explanation && (
|
{answered && (question.explanation || question.explanation_image_path) && (
|
||||||
<div className="explanation"><strong>Explanation:</strong><div style={{ marginTop: 8 }}>{question.explanation}</div></div>
|
<div className="explanation">
|
||||||
|
<strong>Explanation:</strong>
|
||||||
|
{question.explanation && <div style={{ marginTop: 8 }}>{question.explanation}</div>}
|
||||||
|
{question.explanation_image_path && (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<img src={`/uploads/${question.explanation_image_path}`} alt="Explanation illustration"
|
||||||
|
style={{ maxWidth: '100%', maxHeight: 280, borderRadius: 8, border: '1px solid var(--border)' }}
|
||||||
|
onError={e => e.currentTarget.style.display = 'none'} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{answered && <div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
|
{answered && <div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
|
||||||
<button className="btn btn-secondary btn-sm" onClick={() => setAnswered(null)}>Try again</button>
|
<button className="btn btn-secondary btn-sm" onClick={() => setAnswered(null)}>Try again</button>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,30 @@ import ConfirmButton from '../components/ConfirmButton'
|
||||||
|
|
||||||
const LETTERS = ['A', 'B', 'C', 'D', 'E', 'F']
|
const LETTERS = ['A', 'B', 'C', 'D', 'E', 'F']
|
||||||
|
|
||||||
|
function uploadUrl(path) {
|
||||||
|
if (!path) return ''
|
||||||
|
if (/^https?:\/\//i.test(path) || path.startsWith('/uploads/')) return path
|
||||||
|
return `/uploads/${path}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImagePreview({ label, path }) {
|
||||||
|
if (!path) return null
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<div style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text-subtle)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 6 }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div style={{ border: '1px solid var(--border)', borderRadius: 10, padding: 8, background: 'var(--card-bg)' }}>
|
||||||
|
<img
|
||||||
|
src={uploadUrl(path)}
|
||||||
|
alt={label}
|
||||||
|
style={{ display: 'block', maxWidth: '100%', maxHeight: 260, objectFit: 'contain', margin: '0 auto', borderRadius: 8 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function QuestionEditor({ q, quizId, onSaved, onDeleted }) {
|
function QuestionEditor({ q, quizId, onSaved, onDeleted }) {
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
@ -62,6 +86,7 @@ function QuestionEditor({ q, quizId, onSaved, onDeleted }) {
|
||||||
<ConfirmButton onConfirm={del} label="Delete" confirmLabel="Yes, delete" />
|
<ConfirmButton onConfirm={del} label="Delete" confirmLabel="Yes, delete" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ImagePreview label="Question image" path={q.image_path} />
|
||||||
{q.options && (
|
{q.options && (
|
||||||
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
{q.options.map((opt, i) => (
|
{q.options.map((opt, i) => (
|
||||||
|
|
@ -84,6 +109,7 @@ function QuestionEditor({ q, quizId, onSaved, onDeleted }) {
|
||||||
<strong>Explanation:</strong> <span style={{ marginLeft: 4 }}>{q.explanation.slice(0, 200)}{q.explanation.length > 200 ? '…' : ''}</span>
|
<strong>Explanation:</strong> <span style={{ marginLeft: 4 }}>{q.explanation.slice(0, 200)}{q.explanation.length > 200 ? '…' : ''}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<ImagePreview label="Explanation image" path={q.explanation_image_path} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -102,6 +128,8 @@ function QuestionEditor({ q, quizId, onSaved, onDeleted }) {
|
||||||
<textarea rows={4} value={form.question_text} onChange={e => setForm(f => ({ ...f, question_text: e.target.value }))} />
|
<textarea rows={4} value={form.question_text} onChange={e => setForm(f => ({ ...f, question_text: e.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ImagePreview label="Current question image" path={q.image_path} />
|
||||||
|
|
||||||
{form.options.length > 0 && (
|
{form.options.length > 0 && (
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Answer Options — select the correct one</label>
|
<label>Answer Options — select the correct one</label>
|
||||||
|
|
@ -141,6 +169,8 @@ function QuestionEditor({ q, quizId, onSaved, onDeleted }) {
|
||||||
<textarea rows={8} value={form.explanation} onChange={e => setForm(f => ({ ...f, explanation: e.target.value }))} />
|
<textarea rows={8} value={form.explanation} onChange={e => setForm(f => ({ ...f, explanation: e.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ImagePreview label="Current explanation image" path={q.explanation_image_path} />
|
||||||
|
|
||||||
<button className="btn btn-primary" onClick={save} disabled={saving}>
|
<button className="btn btn-primary" onClick={save} disabled={saving}>
|
||||||
{saving ? 'Saving…' : 'Save Question'}
|
{saving ? 'Saving…' : 'Save Question'}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -261,12 +261,43 @@ function TimerDisplay({ seconds, total }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function QuizCodeBadge({ code }) {
|
||||||
|
if (!code) return null
|
||||||
|
|
||||||
|
const copyCode = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(String(code))
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="quiz-code-badge" title="Share this PIN/code to identify the quiz">
|
||||||
|
<span>Quiz PIN</span>
|
||||||
|
<code>{code}</code>
|
||||||
|
<button type="button" className="btn btn-secondary btn-sm" onClick={copyCode}>Copy</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function CourseQuizStart({ quiz, onStart }) {
|
function CourseQuizStart({ quiz, onStart }) {
|
||||||
const mode = quiz.mode === 'timed' ? 'exam' : 'study'
|
const mode = quiz.mode === 'timed' ? 'exam' : 'study'
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [starting, setStarting] = useState(false)
|
||||||
|
const begin = async () => {
|
||||||
|
setError('')
|
||||||
|
setStarting(true)
|
||||||
|
try {
|
||||||
|
await onStart(mode, '', quiz.time_limit_minutes || null)
|
||||||
|
} catch {
|
||||||
|
setError('Could not start the quiz. Try again.')
|
||||||
|
setStarting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 480, margin: '40px auto' }}>
|
<div style={{ maxWidth: 480, margin: '40px auto' }}>
|
||||||
<div className="card" style={{ textAlign: 'center' }}>
|
<div className="card" style={{ textAlign: 'center' }}>
|
||||||
<h2 style={{ marginBottom: 8 }}>{quiz.title}</h2>
|
<h2 style={{ marginBottom: 8 }}>{quiz.title}</h2>
|
||||||
|
<QuizCodeBadge code={quiz.quiz_code || quiz.id} />
|
||||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.9rem', marginBottom: 8 }}>
|
<p style={{ color: 'var(--text-muted)', fontSize: '0.9rem', marginBottom: 8 }}>
|
||||||
{quiz.questions_per_attempt || quiz.questions_count} questions
|
{quiz.questions_per_attempt || quiz.questions_count} questions
|
||||||
{quiz.mode === 'timed' && quiz.time_limit_minutes ? ` · ${quiz.time_limit_minutes} min time limit` : ''}
|
{quiz.mode === 'timed' && quiz.time_limit_minutes ? ` · ${quiz.time_limit_minutes} min time limit` : ''}
|
||||||
|
|
@ -276,8 +307,13 @@ function CourseQuizStart({ quiz, onStart }) {
|
||||||
? 'This is a timed exam — answers are hidden until you submit.'
|
? 'This is a timed exam — answers are hidden until you submit.'
|
||||||
: 'Study mode — answers and explanations shown as you go.'}
|
: 'Study mode — answers and explanations shown as you go.'}
|
||||||
</p>
|
</p>
|
||||||
<button className="btn btn-primary" onClick={() => onStart(mode, '', quiz.time_limit_minutes || null)}>
|
{error && (
|
||||||
Begin Quiz
|
<div style={{ background: '#fef2f2', color: '#991b1b', border: '1px solid #fecaca', borderRadius: 8, padding: '8px 10px', fontSize: '0.82rem', marginBottom: 14 }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button className="btn btn-primary" onClick={begin} disabled={starting}>
|
||||||
|
{starting ? 'Starting...' : 'Begin Quiz'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -308,6 +344,7 @@ function ModeSelectScreen({ quiz, voices, onStart }) {
|
||||||
<div className="card" style={{ textAlign: 'center' }}>
|
<div className="card" style={{ textAlign: 'center' }}>
|
||||||
<div style={{ fontSize: '2.5rem', marginBottom: 12 }}>📝</div>
|
<div style={{ fontSize: '2.5rem', marginBottom: 12 }}>📝</div>
|
||||||
<h2 style={{ marginBottom: 6 }}>{quiz.title}</h2>
|
<h2 style={{ marginBottom: 6 }}>{quiz.title}</h2>
|
||||||
|
<QuizCodeBadge code={quiz.quiz_code || quiz.id} />
|
||||||
<p style={{ color: '#64748b', fontSize: '0.9rem', marginBottom: 24 }}>
|
<p style={{ color: '#64748b', fontSize: '0.9rem', marginBottom: 24 }}>
|
||||||
{quiz.questions_count} questions
|
{quiz.questions_count} questions
|
||||||
{quiz.time_limit_minutes ? ` · ${quiz.time_limit_minutes} min limit` : ''}
|
{quiz.time_limit_minutes ? ` · ${quiz.time_limit_minutes} min limit` : ''}
|
||||||
|
|
@ -616,11 +653,7 @@ export default function QuizPage() {
|
||||||
if (progressRes.data) {
|
if (progressRes.data) {
|
||||||
await resumeQuiz(progressRes.data, voicesRes.data)
|
await resumeQuiz(progressRes.data, voicesRes.data)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch {}
|
||||||
if (err.response?.status === 409) {
|
|
||||||
showToast(err.response.data.detail || 'Quiz active on another device')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch { navigate('/') }
|
} catch { navigate('/') }
|
||||||
finally { setLoading(false) }
|
finally { setLoading(false) }
|
||||||
}
|
}
|
||||||
|
|
@ -668,7 +701,10 @@ export default function QuizPage() {
|
||||||
started_at: now,
|
started_at: now,
|
||||||
total_time: mode === 'exam' && mins ? mins * 60 : null,
|
total_time: mode === 'exam' && mins ? mins * 60 : null,
|
||||||
}, { headers: { 'x-quiz-session': SESSION_ID } }).catch(() => {})
|
}, { headers: { 'x-quiz-session': SESSION_ID } }).catch(() => {})
|
||||||
} catch { navigate('/') }
|
} catch (err) {
|
||||||
|
hasStarted.current = false
|
||||||
|
throw err
|
||||||
|
}
|
||||||
finally { setStarting(false) }
|
finally { setStarting(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -813,6 +849,7 @@ const timerStarted = timeLeft !== null
|
||||||
const answeredCount = Object.keys(answers).length
|
const answeredCount = Object.keys(answers).length
|
||||||
const totalCount = questions.length
|
const totalCount = questions.length
|
||||||
const isLast = currentIdx === totalCount - 1
|
const isLast = currentIdx === totalCount - 1
|
||||||
|
const quizCode = quiz.quiz_code || quiz.id || id
|
||||||
const quizNavigation = (position = 'bottom') => (
|
const quizNavigation = (position = 'bottom') => (
|
||||||
<div className={`quiz-nav-controls quiz-nav-controls-${position}`}>
|
<div className={`quiz-nav-controls quiz-nav-controls-${position}`}>
|
||||||
<button className="btn btn-secondary"
|
<button className="btn btn-secondary"
|
||||||
|
|
@ -948,6 +985,7 @@ const timerStarted = timeLeft !== null
|
||||||
</span>
|
</span>
|
||||||
<span>Q {currentIdx + 1} / {totalCount}</span>
|
<span>Q {currentIdx + 1} / {totalCount}</span>
|
||||||
<span style={{ color: 'var(--text-subtle)' }}>{answeredCount} answered</span>
|
<span style={{ color: 'var(--text-subtle)' }}>{answeredCount} answered</span>
|
||||||
|
<QuizCodeBadge code={quizCode} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
|
@ -1110,7 +1148,7 @@ const timerStarted = timeLeft !== null
|
||||||
transition: 'background 0.15s ease, box-shadow 0.15s ease',
|
transition: 'background 0.15s ease, box-shadow 0.15s ease',
|
||||||
}}>
|
}}>
|
||||||
<span className="option-letter">{letter}</span>
|
<span className="option-letter">{letter}</span>
|
||||||
<span style={{ flex: 1 }}>
|
<span className="option-text">
|
||||||
<ManualHighlightText
|
<ManualHighlightText
|
||||||
text={opt}
|
text={opt}
|
||||||
textId={`${current.id}::${optionFieldKey}`}
|
textId={`${current.id}::${optionFieldKey}`}
|
||||||
|
|
@ -1119,8 +1157,8 @@ const timerStarted = timeLeft !== null
|
||||||
onRemoveHighlight={removeJoinedHighlight}
|
onRemoveHighlight={removeJoinedHighlight}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
{showCorrect && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--correct-fg)' }}>✓ Correct</span>}
|
{showCorrect && <span className="option-status option-status-correct">✓ Correct</span>}
|
||||||
{showWrong && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--wrong-fg)' }}>✗ Wrong</span>}
|
{showWrong && <span className="option-status option-status-wrong">✗ Wrong</span>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
@ -1133,9 +1171,15 @@ const timerStarted = timeLeft !== null
|
||||||
)}
|
)}
|
||||||
{isStudy && answers[current.id] && (
|
{isStudy && answers[current.id] && (
|
||||||
<>
|
<>
|
||||||
{current.explanation && (
|
{(current.explanation || current.explanation_image_path) && (
|
||||||
<div className="explanation" style={{ marginTop: 16, whiteSpace: 'pre-line' }}>
|
<div className="explanation" style={{ marginTop: 16, whiteSpace: 'pre-line' }}>
|
||||||
<strong>Explanation:</strong> {current.explanation}
|
{current.explanation && <><strong>Explanation:</strong> {current.explanation}</>}
|
||||||
|
{current.explanation_image_path && (
|
||||||
|
<button className="question-image-preview" onClick={() => { setImageZoom(1); setExpandedImagePath(current.explanation_image_path) }} title="Expand explanation image" type="button" style={{ marginTop: 12 }}>
|
||||||
|
<img src={`/uploads/${current.explanation_image_path}`} alt="Explanation illustration"
|
||||||
|
onError={e => e.currentTarget.closest('button').style.display = 'none'} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{current.question_type === 'fill_blank' && (
|
{current.question_type === 'fill_blank' && (
|
||||||
|
|
|
||||||
|
|
@ -159,8 +159,18 @@ function QuestionStudyModal({ question, query, onClose }) {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{answered && question.explanation && (
|
{answered && (question.explanation || question.explanation_image_path) && (
|
||||||
<div className="explanation"><strong>Explanation:</strong><div style={{ marginTop: 8 }}>{question.explanation}</div></div>
|
<div className="explanation">
|
||||||
|
<strong>Explanation:</strong>
|
||||||
|
{question.explanation && <div style={{ marginTop: 8 }}>{question.explanation}</div>}
|
||||||
|
{question.explanation_image_path && (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<img src={`/uploads/${question.explanation_image_path}`} alt="Explanation illustration"
|
||||||
|
style={{ maxWidth: '100%', maxHeight: 280, borderRadius: 8, border: '1px solid var(--border)' }}
|
||||||
|
onError={e => e.currentTarget.style.display = 'none'} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{answered && (
|
{answered && (
|
||||||
<div style={{ marginTop: 14, display: 'flex', gap: 8 }}>
|
<div style={{ marginTop: 14, display: 'flex', gap: 8 }}>
|
||||||
|
|
|
||||||
|
|
@ -201,10 +201,17 @@ export default function ResultsPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Explanation */}
|
{/* Explanation */}
|
||||||
{ans.explanation && (
|
{(ans.explanation || ans.explanation_image_path) && (
|
||||||
<div className="explanation">
|
<div className="explanation">
|
||||||
<strong>Explanation</strong>
|
<strong>Explanation</strong>
|
||||||
<div style={{ marginTop: 8 }}>{ans.explanation}</div>
|
{ans.explanation && <div style={{ marginTop: 8 }}>{ans.explanation}</div>}
|
||||||
|
{ans.explanation_image_path && (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<img src={`/uploads/${ans.explanation_image_path}`} alt="Explanation illustration"
|
||||||
|
style={{ maxWidth: '100%', maxHeight: 320, borderRadius: 8, border: '1px solid var(--border)' }}
|
||||||
|
onError={e => e.currentTarget.style.display = 'none'} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,9 @@ DEFAULT_QUIZ_SIZE = int(os.getenv("DEFAULT_QUIZ_SIZE", "20"))
|
||||||
MAX_QUIZ_SIZE = int(os.getenv("MAX_QUIZ_SIZE", "50"))
|
MAX_QUIZ_SIZE = int(os.getenv("MAX_QUIZ_SIZE", "50"))
|
||||||
PUBLIC_APP_URL = os.getenv("PUBLIC_APP_URL", "https://pedshub.com").rstrip("/")
|
PUBLIC_APP_URL = os.getenv("PUBLIC_APP_URL", "https://pedshub.com").rstrip("/")
|
||||||
TELEGRAM_MESSAGE_LIMIT = 4096
|
TELEGRAM_MESSAGE_LIMIT = 4096
|
||||||
|
QUESTION_TEXT_LIMIT = 3200
|
||||||
|
OPTION_TEXT_LIMIT = 3200
|
||||||
|
TELEGRAM_MESSAGE_MARGIN = 200
|
||||||
|
|
||||||
LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
NUMBER_RE = re.compile(r"^\s*(\d{1,3})\s*$")
|
NUMBER_RE = re.compile(r"^\s*(\d{1,3})\s*$")
|
||||||
|
|
@ -269,6 +272,42 @@ def truncate_text(value: str, limit: int) -> str:
|
||||||
return value[:limit].rsplit(" ", 1)[0].rstrip() + "..."
|
return value[:limit].rsplit(" ", 1)[0].rstrip() + "..."
|
||||||
|
|
||||||
|
|
||||||
|
def question_message_text(
|
||||||
|
state: QuizState,
|
||||||
|
question: dict[str, Any],
|
||||||
|
chosen_idx: int | None = None,
|
||||||
|
correct_idx: int | None = None,
|
||||||
|
) -> str:
|
||||||
|
def build(question_limit: int, option_limit: int) -> str:
|
||||||
|
lines = [
|
||||||
|
f"<b>Question {state.index + 1}/{len(state.questions)}</b>",
|
||||||
|
html.escape(truncate_text(question["question_text"], question_limit)),
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
for idx, option in enumerate(question["options"][:8]):
|
||||||
|
marker = ""
|
||||||
|
if correct_idx is not None and idx == correct_idx:
|
||||||
|
marker = " ✓ correct"
|
||||||
|
elif chosen_idx is not None and idx == chosen_idx:
|
||||||
|
marker = " ✗ your answer"
|
||||||
|
lines.append(f"{LETTERS[idx]}. {html.escape(truncate_text(option, option_limit))}{marker}")
|
||||||
|
lines.extend([
|
||||||
|
"",
|
||||||
|
f"Category: {html.escape(str(question.get('category') or 'Uncategorized'))}",
|
||||||
|
])
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
for question_limit, option_limit in (
|
||||||
|
(QUESTION_TEXT_LIMIT, OPTION_TEXT_LIMIT),
|
||||||
|
(QUESTION_TEXT_LIMIT, 700),
|
||||||
|
(1600, 420),
|
||||||
|
):
|
||||||
|
text = build(question_limit, option_limit)
|
||||||
|
if len(text) <= TELEGRAM_MESSAGE_LIMIT - TELEGRAM_MESSAGE_MARGIN:
|
||||||
|
return text
|
||||||
|
return build(1200, 280)
|
||||||
|
|
||||||
|
|
||||||
def answer_feedback_header(chosen_idx: int, correct_idx: int, ok: bool) -> str:
|
def answer_feedback_header(chosen_idx: int, correct_idx: int, ok: bool) -> str:
|
||||||
chosen = LETTERS[chosen_idx] if chosen_idx < len(LETTERS) else "?"
|
chosen = LETTERS[chosen_idx] if chosen_idx < len(LETTERS) else "?"
|
||||||
correct = LETTERS[correct_idx] if correct_idx < len(LETTERS) else "?"
|
correct = LETTERS[correct_idx] if correct_idx < len(LETTERS) else "?"
|
||||||
|
|
@ -322,23 +361,7 @@ async def send_answer_feedback(
|
||||||
|
|
||||||
|
|
||||||
def answered_question_text(state: QuizState, question: dict[str, Any], chosen_idx: int, correct_idx: int) -> str:
|
def answered_question_text(state: QuizState, question: dict[str, Any], chosen_idx: int, correct_idx: int) -> str:
|
||||||
lines = [
|
return question_message_text(state, question, chosen_idx, correct_idx)
|
||||||
f"<b>Question {state.index + 1}/{len(state.questions)}</b>",
|
|
||||||
html.escape(truncate_text(question["question_text"], 1600)),
|
|
||||||
"",
|
|
||||||
]
|
|
||||||
for idx, option in enumerate(question["options"][:8]):
|
|
||||||
marker = ""
|
|
||||||
if idx == correct_idx:
|
|
||||||
marker = " ✓ correct"
|
|
||||||
elif idx == chosen_idx:
|
|
||||||
marker = " ✗ your answer"
|
|
||||||
lines.append(f"{LETTERS[idx]}. {html.escape(truncate_text(option, 420))}{marker}")
|
|
||||||
lines.extend([
|
|
||||||
"",
|
|
||||||
f"Category: {html.escape(str(question.get('category') or 'Uncategorized'))}",
|
|
||||||
])
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
|
|
@ -479,18 +502,7 @@ async def send_current_question(chat_id: int, update: Update | None, context: Co
|
||||||
[InlineKeyboardButton(LETTERS[idx], callback_data=f"answer:{idx}")]
|
[InlineKeyboardButton(LETTERS[idx], callback_data=f"answer:{idx}")]
|
||||||
for idx, option in enumerate(options[:8])
|
for idx, option in enumerate(options[:8])
|
||||||
]
|
]
|
||||||
lines = [
|
text = question_message_text(state, question)
|
||||||
f"<b>Question {state.index + 1}/{len(state.questions)}</b>",
|
|
||||||
html.escape(truncate_text(question["question_text"], 1600)),
|
|
||||||
"",
|
|
||||||
]
|
|
||||||
for idx, option in enumerate(options[:8]):
|
|
||||||
lines.append(f"{LETTERS[idx]}. {html.escape(truncate_text(option, 700))}")
|
|
||||||
lines.extend([
|
|
||||||
"",
|
|
||||||
f"Category: {html.escape(str(question.get('category') or 'Uncategorized'))}",
|
|
||||||
])
|
|
||||||
text = "\n".join(lines)
|
|
||||||
markup = InlineKeyboardMarkup(buttons)
|
markup = InlineKeyboardMarkup(buttons)
|
||||||
if update and update.message:
|
if update and update.message:
|
||||||
await update.message.reply_text(text, parse_mode=ParseMode.HTML, reply_markup=markup)
|
await update.message.reply_text(text, parse_mode=ParseMode.HTML, reply_markup=markup)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue