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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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 }) {
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' && (

View file

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

View file

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

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