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:
Daniel 2026-09-07 00:50:35 +02:00
parent e7f91e5e5b
commit 3cf21d4438
24 changed files with 640 additions and 86 deletions

View file

@ -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"))

View file

@ -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")

View file

@ -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 (

View file

@ -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

View file

@ -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."""

View file

@ -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,

View file

@ -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,

View file

@ -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,
} }

View file

@ -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):

View file

@ -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,
} }

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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>

View file

@ -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;

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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' && (

View file

@ -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 }}>

View file

@ -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>

View file

@ -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)