chore: preserve pre-revamp local changes
Recovery snapshot of the existing worktree before the Orthobullets-inspired revamp. Includes explanation images, classification snapshots, quiz visibility/resume fixes, quiz codes, TTS options and bot formatting. Secret heuristic and Python syntax checks passed; not a release or full behavioral validation.
This commit is contained in:
parent
e7f91e5e5b
commit
3cf21d4438
24 changed files with 640 additions and 86 deletions
|
|
@ -0,0 +1,51 @@
|
|||
"""add classification snapshots
|
||||
|
||||
Revision ID: 5f8c1c2a9d40
|
||||
Revises: b71a4e2d6c90
|
||||
Create Date: 2026-06-12 03:30:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = '5f8c1c2a9d40'
|
||||
down_revision: Union[str, None] = 'b71a4e2d6c90'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS question_classification_snapshots (
|
||||
id SERIAL PRIMARY KEY,
|
||||
job_id VARCHAR(64),
|
||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
reason VARCHAR(120) NOT NULL DEFAULT 'before_ai_classification',
|
||||
question_count INTEGER NOT NULL DEFAULT 0,
|
||||
link_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
conn.execute(sa.text("""
|
||||
CREATE INDEX IF NOT EXISTS ix_question_classification_snapshots_created_at
|
||||
ON question_classification_snapshots (created_at DESC)
|
||||
"""))
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS question_classification_snapshot_links (
|
||||
snapshot_id INTEGER NOT NULL REFERENCES question_classification_snapshots(id) ON DELETE CASCADE,
|
||||
question_id INTEGER NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
|
||||
tag_name VARCHAR(200) NOT NULL,
|
||||
tag_type VARCHAR(50) NOT NULL,
|
||||
PRIMARY KEY (snapshot_id, question_id, tag_name, tag_type)
|
||||
)
|
||||
"""))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
conn.execute(sa.text("DROP TABLE IF EXISTS question_classification_snapshot_links"))
|
||||
conn.execute(sa.text("DROP INDEX IF EXISTS ix_question_classification_snapshots_created_at"))
|
||||
conn.execute(sa.text("DROP TABLE IF EXISTS question_classification_snapshots"))
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
"""add explanation image path to questions
|
||||
|
||||
Revision ID: b71a4e2d6c90
|
||||
Revises: e4c7b2a9d6f1
|
||||
Create Date: 2026-06-09 00:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = 'b71a4e2d6c90'
|
||||
down_revision: Union[str, None] = 'e4c7b2a9d6f1'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE questions ADD COLUMN IF NOT EXISTS explanation_image_path VARCHAR")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE questions DROP COLUMN IF EXISTS explanation_image_path")
|
||||
|
|
@ -345,6 +345,30 @@ def setup_pgvector():
|
|||
PRIMARY KEY (question_id, tag_id)
|
||||
)
|
||||
"""))
|
||||
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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)' }}>
|
||||
Started {new Date(a.started_at).toLocaleDateString()} · {a.total_questions} questions
|
||||
</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 style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => navigate(`/quizzes/${a.quiz_id}`)}>Resume</button>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div>
|
||||
<Dialog {...dialogProps} />
|
||||
<div className="card">
|
||||
<h2>Admin Dashboard</h2>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||
{['models', 'users', 'settings'].map(t => (
|
||||
<button key={t} className={`btn ${tab === t ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab(t)}>
|
||||
{t === 'models' ? 'AI Models' : t === 'users' ? 'Users' : 'More'}
|
||||
{adminTabs.map(({ id, label }) => (
|
||||
<button key={id} className={`btn ${tab === id ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab(id)}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</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' && (
|
||||
<div className="card">
|
||||
<h2>More Settings</h2>
|
||||
|
|
|
|||
|
|
@ -64,8 +64,18 @@ function QuestionStudyModal({ question, onClose, isFavorited, onToggleFavorite }
|
|||
})}
|
||||
</div>
|
||||
)}
|
||||
{answered && question.explanation && (
|
||||
<div className="explanation"><strong>Explanation:</strong><div style={{ marginTop: 8 }}>{question.explanation}</div></div>
|
||||
{answered && (question.explanation || question.explanation_image_path) && (
|
||||
<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 }}>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setAnswered(null)}>Try again</button>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,30 @@ import ConfirmButton from '../components/ConfirmButton'
|
|||
|
||||
const LETTERS = ['A', 'B', 'C', 'D', 'E', 'F']
|
||||
|
||||
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 }) {
|
||||
const [editing, setEditing] = 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" />
|
||||
</div>
|
||||
</div>
|
||||
<ImagePreview label="Question image" path={q.image_path} />
|
||||
{q.options && (
|
||||
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
<ImagePreview label="Explanation image" path={q.explanation_image_path} />
|
||||
</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 }))} />
|
||||
</div>
|
||||
|
||||
<ImagePreview label="Current question image" path={q.image_path} />
|
||||
|
||||
{form.options.length > 0 && (
|
||||
<div className="form-group">
|
||||
<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 }))} />
|
||||
</div>
|
||||
|
||||
<ImagePreview label="Current explanation image" path={q.explanation_image_path} />
|
||||
|
||||
<button className="btn btn-primary" onClick={save} disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save Question'}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -261,12 +261,43 @@ function TimerDisplay({ seconds, total }) {
|
|||
)
|
||||
}
|
||||
|
||||
function QuizCodeBadge({ code }) {
|
||||
if (!code) return null
|
||||
|
||||
const copyCode = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(String(code))
|
||||
} catch { }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="quiz-code-badge" title="Share this PIN/code to identify the quiz">
|
||||
<span>Quiz PIN</span>
|
||||
<code>{code}</code>
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={copyCode}>Copy</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CourseQuizStart({ quiz, onStart }) {
|
||||
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 (
|
||||
<div style={{ maxWidth: 480, margin: '40px auto' }}>
|
||||
<div className="card" style={{ textAlign: 'center' }}>
|
||||
<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 }}>
|
||||
{quiz.questions_per_attempt || quiz.questions_count} questions
|
||||
{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.'
|
||||
: 'Study mode — answers and explanations shown as you go.'}
|
||||
</p>
|
||||
<button className="btn btn-primary" onClick={() => onStart(mode, '', quiz.time_limit_minutes || null)}>
|
||||
Begin Quiz
|
||||
{error && (
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -308,6 +344,7 @@ function ModeSelectScreen({ quiz, voices, onStart }) {
|
|||
<div className="card" style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '2.5rem', marginBottom: 12 }}>📝</div>
|
||||
<h2 style={{ marginBottom: 6 }}>{quiz.title}</h2>
|
||||
<QuizCodeBadge code={quiz.quiz_code || quiz.id} />
|
||||
<p style={{ color: '#64748b', fontSize: '0.9rem', marginBottom: 24 }}>
|
||||
{quiz.questions_count} questions
|
||||
{quiz.time_limit_minutes ? ` · ${quiz.time_limit_minutes} min limit` : ''}
|
||||
|
|
@ -616,11 +653,7 @@ export default function QuizPage() {
|
|||
if (progressRes.data) {
|
||||
await resumeQuiz(progressRes.data, voicesRes.data)
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.response?.status === 409) {
|
||||
showToast(err.response.data.detail || 'Quiz active on another device')
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
} catch { navigate('/') }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
|
@ -668,7 +701,10 @@ export default function QuizPage() {
|
|||
started_at: now,
|
||||
total_time: mode === 'exam' && mins ? mins * 60 : null,
|
||||
}, { headers: { 'x-quiz-session': SESSION_ID } }).catch(() => {})
|
||||
} catch { navigate('/') }
|
||||
} catch (err) {
|
||||
hasStarted.current = false
|
||||
throw err
|
||||
}
|
||||
finally { setStarting(false) }
|
||||
}
|
||||
|
||||
|
|
@ -813,6 +849,7 @@ const timerStarted = timeLeft !== null
|
|||
const answeredCount = Object.keys(answers).length
|
||||
const totalCount = questions.length
|
||||
const isLast = currentIdx === totalCount - 1
|
||||
const quizCode = quiz.quiz_code || quiz.id || id
|
||||
const quizNavigation = (position = 'bottom') => (
|
||||
<div className={`quiz-nav-controls quiz-nav-controls-${position}`}>
|
||||
<button className="btn btn-secondary"
|
||||
|
|
@ -948,6 +985,7 @@ const timerStarted = timeLeft !== null
|
|||
</span>
|
||||
<span>Q {currentIdx + 1} / {totalCount}</span>
|
||||
<span style={{ color: 'var(--text-subtle)' }}>{answeredCount} answered</span>
|
||||
<QuizCodeBadge code={quizCode} />
|
||||
</div>
|
||||
</div>
|
||||
<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',
|
||||
}}>
|
||||
<span className="option-letter">{letter}</span>
|
||||
<span style={{ flex: 1 }}>
|
||||
<span className="option-text">
|
||||
<ManualHighlightText
|
||||
text={opt}
|
||||
textId={`${current.id}::${optionFieldKey}`}
|
||||
|
|
@ -1119,8 +1157,8 @@ const timerStarted = timeLeft !== null
|
|||
onRemoveHighlight={removeJoinedHighlight}
|
||||
/>
|
||||
</span>
|
||||
{showCorrect && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--correct-fg)' }}>✓ Correct</span>}
|
||||
{showWrong && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--wrong-fg)' }}>✗ Wrong</span>}
|
||||
{showCorrect && <span className="option-status option-status-correct">✓ Correct</span>}
|
||||
{showWrong && <span className="option-status option-status-wrong">✗ Wrong</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
|
@ -1133,9 +1171,15 @@ const timerStarted = timeLeft !== null
|
|||
)}
|
||||
{isStudy && answers[current.id] && (
|
||||
<>
|
||||
{current.explanation && (
|
||||
{(current.explanation || current.explanation_image_path) && (
|
||||
<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>
|
||||
)}
|
||||
{current.question_type === 'fill_blank' && (
|
||||
|
|
|
|||
|
|
@ -159,8 +159,18 @@ function QuestionStudyModal({ question, query, onClose }) {
|
|||
})}
|
||||
</div>
|
||||
)}
|
||||
{answered && question.explanation && (
|
||||
<div className="explanation"><strong>Explanation:</strong><div style={{ marginTop: 8 }}>{question.explanation}</div></div>
|
||||
{answered && (question.explanation || question.explanation_image_path) && (
|
||||
<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: 14, display: 'flex', gap: 8 }}>
|
||||
|
|
|
|||
|
|
@ -201,10 +201,17 @@ export default function ResultsPage() {
|
|||
)}
|
||||
|
||||
{/* Explanation */}
|
||||
{ans.explanation && (
|
||||
{(ans.explanation || ans.explanation_image_path) && (
|
||||
<div className="explanation">
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ DEFAULT_QUIZ_SIZE = int(os.getenv("DEFAULT_QUIZ_SIZE", "20"))
|
|||
MAX_QUIZ_SIZE = int(os.getenv("MAX_QUIZ_SIZE", "50"))
|
||||
PUBLIC_APP_URL = os.getenv("PUBLIC_APP_URL", "https://pedshub.com").rstrip("/")
|
||||
TELEGRAM_MESSAGE_LIMIT = 4096
|
||||
QUESTION_TEXT_LIMIT = 3200
|
||||
OPTION_TEXT_LIMIT = 3200
|
||||
TELEGRAM_MESSAGE_MARGIN = 200
|
||||
|
||||
LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
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() + "..."
|
||||
|
||||
|
||||
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:
|
||||
chosen = LETTERS[chosen_idx] if chosen_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:
|
||||
lines = [
|
||||
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)
|
||||
return question_message_text(state, question, chosen_idx, correct_idx)
|
||||
|
||||
|
||||
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}")]
|
||||
for idx, option in enumerate(options[:8])
|
||||
]
|
||||
lines = [
|
||||
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)
|
||||
text = question_message_text(state, question)
|
||||
markup = InlineKeyboardMarkup(buttons)
|
||||
if update and update.message:
|
||||
await update.message.reply_text(text, parse_mode=ParseMode.HTML, reply_markup=markup)
|
||||
|
|
|
|||
Loading…
Reference in a new issue