diff --git a/backend/alembic/versions/5f8c1c2a9d40_add_classification_snapshots.py b/backend/alembic/versions/5f8c1c2a9d40_add_classification_snapshots.py new file mode 100644 index 0000000..627816d --- /dev/null +++ b/backend/alembic/versions/5f8c1c2a9d40_add_classification_snapshots.py @@ -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")) diff --git a/backend/alembic/versions/b71a4e2d6c90_add_explanation_image_path.py b/backend/alembic/versions/b71a4e2d6c90_add_explanation_image_path.py new file mode 100644 index 0000000..7ec5466 --- /dev/null +++ b/backend/alembic/versions/b71a4e2d6c90_add_explanation_image_path.py @@ -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") diff --git a/backend/app/main.py b/backend/app/main.py index dc9a8a7..179a790 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -345,6 +345,30 @@ def setup_pgvector(): 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 conn.execute(text(""" CREATE TABLE IF NOT EXISTS flashcard_decks ( @@ -391,6 +415,7 @@ def setup_pgvector(): # 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 allow_review INTEGER DEFAULT 1")) + conn.execute(text("ALTER TABLE questions ADD COLUMN IF NOT EXISTS explanation_image_path VARCHAR")) # ── Course / LMS tables ────────────────────────────────────── conn.execute(text(""" CREATE TABLE IF NOT EXISTS courses ( diff --git a/backend/app/models/question.py b/backend/app/models/question.py index 49f62e2..b63e639 100644 --- a/backend/app/models/question.py +++ b/backend/app/models/question.py @@ -23,6 +23,7 @@ class Question(Base): explanation = Column(Text, nullable=True) page_reference = Column(Integer, 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) 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 diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index a9b2429..31f6dcd 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -1,5 +1,6 @@ from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel +from sqlalchemy import text from sqlalchemy.orm import Session import httpx @@ -76,7 +77,6 @@ def delete_user( if user.id == admin.id: raise HTTPException(status_code=400, detail="Cannot delete yourself") # 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 quiz_categories SET user_id = NULL WHERE user_id = :uid"), {"uid": user_id}) db.delete(user) @@ -344,6 +344,30 @@ KOKORO_VOICE_FALLBACKS = [ ("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]: 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") def search_tts_voices( data: TTSVoiceSearchRequest, @@ -409,6 +444,12 @@ def search_tts_voices( if model_name == "local-kokoro-tts": voices.extend(_kokoro_voice_options(model_name)) 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({ "model_id": 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"} +@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") def regenerate_embeddings(admin: User = Depends(require_admin)): """Queue a background Celery task to regenerate all question embeddings.""" diff --git a/backend/app/routers/attempts.py b/backend/app/routers/attempts.py index 6652ad2..07b9f77 100644 --- a/backend/app/routers/attempts.py +++ b/backend/app/routers/attempts.py @@ -28,6 +28,10 @@ from app.utils.quiz_questions import get_quiz_questions 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) def start_attempt( quiz_id: int, @@ -38,6 +42,8 @@ def start_attempt( quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first() if not quiz: 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 if quiz.max_attempts: @@ -153,6 +159,7 @@ def submit_attempt( correct_answer=q.correct_answer, is_correct=is_correct, explanation=q.explanation, + explanation_image_path=q.explanation_image_path, )) attempt.score = score @@ -241,7 +248,9 @@ def save_progress( ): """Save in-progress quiz answers to Redis (survives logout/browser change). 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: import redis as redis_lib, json as _json from app.config import settings @@ -280,7 +289,8 @@ def get_progress( ): """Retrieve in-progress quiz answers from 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.""" try: import redis as redis_lib, json as _json @@ -298,15 +308,10 @@ def get_progress( if not attempt: return None - # Check if another device is actively using this attempt session_id = request.headers.get("x-quiz-session", "") if request else "" lock_key = f"quiz_active:{current_user.id}:{attempt.id}" - active_session = r.get(lock_key) - if active_session and session_id and active_session != 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)." - ) + if session_id: + r.setex(lock_key, 30, session_id) key = f"quiz_progress:{current_user.id}:{attempt.id}" data = r.get(key) @@ -378,6 +383,7 @@ def clear_progress( from app.config import settings 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_active:{current_user.id}:{attempt_id}") except Exception: logger.warning("Redis unavailable for progress clear", exc_info=True) @@ -412,7 +418,7 @@ def delete_attempt( db.commit() -@router.get("/{attempt_id}/in-progress") +@router.get("/quiz/{quiz_id}/in-progress") def get_in_progress_attempt( quiz_id: int, db: Session = Depends(get_db), @@ -461,6 +467,7 @@ def get_in_progress_attempts( result.append({ "attempt_id": a.id, "quiz_id": a.quiz_id, + "quiz_code": str(a.quiz_id), "quiz_title": quiz.title if quiz else f"Quiz {a.quiz_id}", "total_questions": a.total_questions, "started_at": a.started_at.isoformat() if a.started_at else None, diff --git a/backend/app/routers/courses.py b/backend/app/routers/courses.py index 20c24e4..f73e698 100644 --- a/backend/app/routers/courses.py +++ b/backend/app/routers/courses.py @@ -778,6 +778,7 @@ def create_course_quiz( correct_answer=sq.correct_answer, explanation=sq.explanation, image_path=sq.image_path, + explanation_image_path=sq.explanation_image_path, question_category_id=sq.question_category_id, source_quiz_id=quiz.id, user_id=current_user.id, diff --git a/backend/app/routers/mobile.py b/backend/app/routers/mobile.py index e831c91..154563f 100644 --- a/backend/app/routers/mobile.py +++ b/backend/app/routers/mobile.py @@ -2,6 +2,7 @@ from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel, EmailStr +from sqlalchemy import or_ from sqlalchemy.orm import Session 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): - 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: - 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 @@ -113,6 +114,7 @@ def _question_payload(question): "explanation": question.explanation, "page_reference": question.page_reference, "image_path": question.image_path, + "explanation_image_path": question.explanation_image_path, } diff --git a/backend/app/routers/questions.py b/backend/app/routers/questions.py index 1c2654a..2a9d2d8 100644 --- a/backend/app/routers/questions.py +++ b/backend/app/routers/questions.py @@ -51,6 +51,7 @@ class QuestionEdit(BaseModel): explanation: str | None = None question_category_id: int | None = None image_path: str | None = None + explanation_image_path: str | None = None @router.patch("/{question_id}") @@ -80,6 +81,7 @@ def edit_question( "explanation": question.explanation, "question_category_id": question.question_category_id, "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, "explanation": qu.explanation, "image_path": qu.image_path, + "explanation_image_path": qu.explanation_image_path, "user_id": qu.user_id, "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 question_category_id: int | None = None image_path: str | None = None + explanation_image_path: str | None = None @router.post("/create") @@ -372,6 +376,7 @@ def create_question_manually( explanation=data.explanation, question_category_id=data.question_category_id, image_path=data.image_path, + explanation_image_path=data.explanation_image_path, user_id=current_user.id, is_shared=1, ) @@ -423,14 +428,19 @@ def list_question_images( current_user: User = Depends(get_current_user), ): """List all unique question images for the image bank browser.""" - images = ( - db.query(Question.image_path) - .filter(Question.image_path.isnot(None), Question.image_path != "") - .distinct() - .limit(200) - .all() - ) - return [{"image_path": img[0], "url": f"/uploads/{img[0]}"} for img in images] + rows = db.execute(sa_text(""" + SELECT path FROM ( + SELECT image_path AS path FROM questions + WHERE image_path IS NOT NULL AND image_path <> '' + UNION + SELECT explanation_image_path AS path FROM questions + WHERE explanation_image_path IS NOT NULL AND explanation_image_path <> '' + ) 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): diff --git a/backend/app/routers/quizzes.py b/backend/app/routers/quizzes.py index d406dee..cb80e50 100644 --- a/backend/app/routers/quizzes.py +++ b/backend/app/routers/quizzes.py @@ -18,6 +18,10 @@ from app.utils.quiz_questions import get_quiz_questions, question_in_quiz, remov 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("/") def create_quiz( quiz_data: QuizCreate, @@ -162,7 +166,7 @@ def search_quizzes( def _ensure_quiz(quiz_id: int, match_type: str): if quiz_id not in results: 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 results[quiz_id] = { "quiz_id": quiz.id, @@ -179,7 +183,10 @@ def search_quizzes( # ── Title search ───────────────────────────────────────────── 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") # ── Semantic (vector) search ────────────────────────────────── @@ -191,7 +198,7 @@ def search_quizzes( emb_literal = "[" + ",".join(str(float(x)) for x in query_emb) + "]" rows = db.execute(sa_text(""" 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 FROM questions q WHERE q.embedding IS NOT NULL @@ -212,6 +219,7 @@ def search_quizzes( "options": row.options, "correct_answer": row.correct_answer, "explanation": row.explanation, + "explanation_image_path": row.explanation_image_path, "similarity": round(similarity, 3), "match_source": "semantic", }) @@ -240,6 +248,7 @@ def search_quizzes( "options": question.options, "correct_answer": question.correct_answer, "explanation": question.explanation, + "explanation_image_path": question.explanation_image_path, "similarity": None, "match_source": "keyword", }) @@ -270,7 +279,7 @@ def list_quizzes( """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)) 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() @@ -287,6 +296,8 @@ def get_quiz( quiz = db.query(Quiz).filter(Quiz.id == quiz_id, Quiz.deleted_at.is_(None)).first() if not quiz: 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": result = QuizLearningDetail.model_validate(quiz) @@ -344,6 +355,8 @@ def shuffle_quiz( quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first() if not quiz: 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) shuffled_questions = questions.copy() @@ -416,6 +429,8 @@ def get_quiz_questions_for_edit( "options": q.options, "correct_answer": q.correct_answer, "explanation": q.explanation, + "image_path": q.image_path, + "explanation_image_path": q.explanation_image_path, } for q in questions ] @@ -436,7 +451,7 @@ def update_question( if not question: 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(): if key in allowed: setattr(question, key, value) @@ -457,6 +472,8 @@ def update_question( "options": question.options, "correct_answer": question.correct_answer, "explanation": question.explanation, + "image_path": question.image_path, + "explanation_image_path": question.explanation_image_path, } diff --git a/backend/app/routers/teach.py b/backend/app/routers/teach.py index b93f6e0..c837076 100644 --- a/backend/app/routers/teach.py +++ b/backend/app/routers/teach.py @@ -1,4 +1,6 @@ """Teach chat endpoint — AI tutor for study mode questions.""" +from datetime import datetime, time, timedelta, timezone + from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy.orm import Session @@ -12,6 +14,13 @@ from app.utils.auth import get_current_user, check_rate_limit 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): role: str # "user" | "assistant" content: str @@ -137,12 +146,13 @@ async def chat( current_user: User = Depends(get_current_user), ): """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( - key=f"teach_chat:{current_user.id}", + key=f"teach_chat_daily:{current_user.id}:{quota_day}", max_calls=30, - window_seconds=600, - 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.", + window_seconds=quota_ttl, + 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, ) model_info = _get_teach_model(db, req.model_id) diff --git a/backend/app/schemas/attempt.py b/backend/app/schemas/attempt.py index 3a418fe..d55f777 100644 --- a/backend/app/schemas/attempt.py +++ b/backend/app/schemas/attempt.py @@ -21,6 +21,7 @@ class AnswerDetail(BaseModel): correct_answer: str is_correct: bool explanation: str | None + explanation_image_path: str | None = None class Config: from_attributes = True diff --git a/backend/app/schemas/quiz.py b/backend/app/schemas/quiz.py index 532c243..5ba607f 100644 --- a/backend/app/schemas/quiz.py +++ b/backend/app/schemas/quiz.py @@ -38,6 +38,7 @@ class QuestionResponse(BaseModel): class QuestionWithAnswer(QuestionResponse): correct_answer: str explanation: str | None + explanation_image_path: str | None = None page_reference: int | None diff --git a/backend/app/tasks/quiz_tasks.py b/backend/app/tasks/quiz_tasks.py index 491ced1..7e9a81b 100644 --- a/backend/app/tasks/quiz_tasks.py +++ b/backend/app/tasks/quiz_tasks.py @@ -4,6 +4,8 @@ import logging import time import os +from sqlalchemy import text as sa_text + from app.tasks import celery_app 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) +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) def classify_questions(self, job_id: str, user_id: int): """Classify untagged questions using AI — subjects, diseases, keywords.""" @@ -351,8 +390,15 @@ def classify_questions(self, job_id: str, user_id: int): try: from app.models.question import Question 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...") # Get IDs of questions that already have tags diff --git a/backend/requirements.txt b/backend/requirements.txt index 3e8ab8d..48182d4 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -9,7 +9,7 @@ python-multipart==0.0.9 pydantic[email]==2.6.1 pydantic-settings==2.1.0 PyMuPDF==1.23.22 -litellm==1.27.10 +litellm==1.27.8 chromadb==0.4.24 celery[redis]==5.3.6 redis==5.0.1 diff --git a/frontend/src/components/InProgressQuizzes.jsx b/frontend/src/components/InProgressQuizzes.jsx index 68e3943..72184a2 100644 --- a/frontend/src/components/InProgressQuizzes.jsx +++ b/frontend/src/components/InProgressQuizzes.jsx @@ -16,6 +16,12 @@ export default function InProgressQuizzes() { 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 return ( @@ -34,6 +40,15 @@ export default function InProgressQuizzes() {
Started {new Date(a.started_at).toLocaleDateString()} · {a.total_questions} questions
+
+ Quiz code + + {a.quiz_code || a.quiz_id} + + +
diff --git a/frontend/src/index.css b/frontend/src/index.css index 4bd564c..47f5b9a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -146,6 +146,25 @@ body { line-height: 1.18; 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 { display: flex; @@ -470,12 +489,14 @@ body { } /* 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 { - 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; cursor: pointer; transition: border-color 0.12s, background 0.12s; 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; } .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.correct .option-letter { background: #16a34a; 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 { @@ -655,6 +692,14 @@ body { min-height: 36px; white-space: normal; } + .question-card .option { + gap: 10px; + padding: 12px; + } + .option-status { + width: 100%; + margin-left: 39px; + } .mynote-tab { right: 12px; bottom: 12px; diff --git a/frontend/src/pages/AdminPage.jsx b/frontend/src/pages/AdminPage.jsx index f44dfc4..bf82fa9 100644 --- a/frontend/src/pages/AdminPage.jsx +++ b/frontend/src/pages/AdminPage.jsx @@ -49,6 +49,8 @@ export default function AdminPage() { const [originalEmbedModel, setOriginalEmbedModel] = useState(null) const [embedModelChanged, setEmbedModelChanged] = useState(false) const [regenLoading, setRegenLoading] = useState(false) + const [classificationSnapshots, setClassificationSnapshots] = useState([]) + const [rollbackLoading, setRollbackLoading] = useState(null) useEffect(() => { if (!user?.role || user.role !== 'admin') { navigate('/'); return } @@ -58,14 +60,16 @@ export default function AdminPage() { const loadData = async (showSpinner = true) => { if (showSpinner) setLoading(true) try { - const [usersRes, modelsRes, settingsRes] = await Promise.all([ + const [usersRes, modelsRes, settingsRes, snapshotsRes] = await Promise.all([ api.get('/admin/users'), api.get('/admin/models'), api.get('/admin/settings'), + api.get('/admin/classification-snapshots'), ]) setUsers(usersRes.data) setModels(modelsRes.data) setSettings(settingsRes.data) + setClassificationSnapshots(snapshotsRes.data) setOriginalEmbedModel(settingsRes.data.embedding_model || '') } catch (err) { 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) => { setNewModel(m => ({ ...m, model_id: modelId, name: modelId, task: searchTaskHint })) setSearchResults([]) @@ -294,15 +320,22 @@ export default function AdminPage() { ? searchResults.filter(m => m.toLowerCase().includes(searchFilter.toLowerCase())) : searchResults + const adminTabs = [ + { id: 'models', label: 'AI Models' }, + { id: 'users', label: 'Users' }, + { id: 'safety', label: 'Safety' }, + { id: 'settings', label: 'More' }, + ] + return (

Admin Dashboard

- {['models', 'users', 'settings'].map(t => ( - ))}
@@ -610,6 +643,46 @@ export default function AdminPage() { )} + {tab === 'safety' && ( +
+

Classification Rollback

+

+ 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. +

+ + {classificationSnapshots.length === 0 ? ( +
+ No classification snapshots have been saved yet. +
+ ) : ( +
+ {classificationSnapshots.map(snapshot => ( +
+
+ Snapshot #{snapshot.id} +
+ {snapshot.created_at ? new Date(snapshot.created_at).toLocaleString() : 'Unknown date'} · {snapshot.question_count} tagged questions · {snapshot.link_count} tag assignments +
+ {snapshot.created_by_email && ( +
+ Created by {snapshot.created_by_name || snapshot.created_by_email} +
+ )} +
+ +
+ ))} +
+ )} +
+ )} + {tab === 'settings' && (

More Settings

diff --git a/frontend/src/pages/QuestionBankPage.jsx b/frontend/src/pages/QuestionBankPage.jsx index 33255ae..e7475d8 100644 --- a/frontend/src/pages/QuestionBankPage.jsx +++ b/frontend/src/pages/QuestionBankPage.jsx @@ -64,8 +64,18 @@ function QuestionStudyModal({ question, onClose, isFavorited, onToggleFavorite } })}
)} - {answered && question.explanation && ( -
Explanation:
{question.explanation}
+ {answered && (question.explanation || question.explanation_image_path) && ( +
+ Explanation: + {question.explanation &&
{question.explanation}
} + {question.explanation_image_path && ( +
+ Explanation illustration e.currentTarget.style.display = 'none'} /> +
+ )} +
)} {answered &&
diff --git a/frontend/src/pages/QuizEditPage.jsx b/frontend/src/pages/QuizEditPage.jsx index db60ce7..27b1b65 100644 --- a/frontend/src/pages/QuizEditPage.jsx +++ b/frontend/src/pages/QuizEditPage.jsx @@ -5,6 +5,30 @@ import ConfirmButton from '../components/ConfirmButton' 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 ( +
+
+ {label} +
+
+ {label} +
+
+ ) +} + function QuestionEditor({ q, quizId, onSaved, onDeleted }) { const [editing, setEditing] = useState(false) const [saving, setSaving] = useState(false) @@ -62,6 +86,7 @@ function QuestionEditor({ q, quizId, onSaved, onDeleted }) {
+ {q.options && (
{q.options.map((opt, i) => ( @@ -84,6 +109,7 @@ function QuestionEditor({ q, quizId, onSaved, onDeleted }) { Explanation: {q.explanation.slice(0, 200)}{q.explanation.length > 200 ? '…' : ''}
)} +
) } @@ -102,6 +128,8 @@ function QuestionEditor({ q, quizId, onSaved, onDeleted }) {