Remove coach modes and improve quiz audio

This commit is contained in:
Daniel 2026-05-10 01:24:06 +02:00
parent 67a90f73f5
commit d59c8bed6f
31 changed files with 4571 additions and 1474 deletions

View file

@ -112,6 +112,10 @@ APP_URL=https://your-domain.com
UPLOAD_DIR=/app/uploads
MAX_UPLOAD_SIZE=524288000
CHROMA_PERSIST_DIR=/app/chroma_data
# Optional bootstrap admin. Leave blank to let the first registered user become admin.
DEFAULT_ADMIN_EMAIL=
DEFAULT_ADMIN_PASSWORD=
```
### Frontend (`frontend/.env`)

View file

@ -4,6 +4,10 @@ SECRET_KEY=change-me-to-a-random-secret-key-in-production
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=1440
# Optional bootstrap admin. Leave blank to use first-user-becomes-admin registration.
DEFAULT_ADMIN_EMAIL=
DEFAULT_ADMIN_PASSWORD=
# Redis (use service name in Docker)
REDIS_URL=redis://redis:6379/0
@ -11,6 +15,9 @@ REDIS_URL=redis://redis:6379/0
LITELLM_MODEL=gpt-4o-mini
LITELLM_API_KEY=your-api-key-here
# Local Sherpa speech gateway for self-hosted TTS/STT
LOCAL_SPEECH_GATEWAY_URL=http://local-speech-gateway:8110
# Vector store
CHROMA_PERSIST_DIR=/app/chroma_data

View file

@ -18,6 +18,7 @@ class Settings(BaseSettings):
OPENAI_API_KEY: str = ""
ELEVENLABS_API_KEY: str = ""
GOOGLE_TTS_API_KEY: str = ""
LOCAL_SPEECH_GATEWAY_URL: str = "http://127.0.0.1:8110"
AWS_ACCESS_KEY_ID: str = ""
AWS_SECRET_ACCESS_KEY: str = ""
AWS_REGION: str = "us-east-1"
@ -41,6 +42,9 @@ class Settings(BaseSettings):
TURNSTILE_SECRET_KEY: str = "" # Cloudflare Turnstile — leave blank to disable captcha
ADMIN_EMAIL: str = "" # Where contact form submissions are emailed
DEFAULT_ADMIN_EMAIL: str = "" # Optional explicit bootstrap admin email
DEFAULT_ADMIN_PASSWORD: str = "" # Optional explicit bootstrap admin password
BBB_SERVER_URL: str = "" # BigBlueButton server URL (e.g. https://bbb.example.com/bigbluebutton)
BBB_SECRET: str = "" # BigBlueButton shared secret
@ -53,5 +57,4 @@ class Settings(BaseSettings):
LOG_LEVEL: str = "INFO" # DEBUG, INFO, WARNING, ERROR
settings = Settings()

View file

@ -11,30 +11,39 @@ from app.logging_config import setup_logging
# Configure structured JSON logging before anything else
setup_logging(settings.LOG_LEVEL)
from app.database import engine, Base, SessionLocal
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses, mobile
from app.utils.auth import get_password_hash
from app.utils.scheduler import start_scheduler, stop_scheduler
def seed_admin():
"""Create default admin user if none exists."""
"""Optionally create a configured bootstrap admin user if none exists."""
import logging
from datetime import datetime
from app.models.user import User
from app.models.email_verification import EmailVerification
from app.models.password_reset import PasswordReset
log = logging.getLogger(__name__)
db = SessionLocal()
try:
admin_exists = db.query(User).filter(User.role == "admin").first()
if not admin_exists:
if not settings.DEFAULT_ADMIN_EMAIL or not settings.DEFAULT_ADMIN_PASSWORD:
log.info("No admin exists; skipping bootstrap admin seed. First registered user will become admin.")
return
if len(settings.DEFAULT_ADMIN_PASSWORD) < 8:
log.warning("DEFAULT_ADMIN_PASSWORD is too short; skipping bootstrap admin seed.")
return
admin_user = User(
email="admin@quizapp.com",
hashed_password=get_password_hash("admin123"),
email=settings.DEFAULT_ADMIN_EMAIL.lower().strip(),
hashed_password=get_password_hash(settings.DEFAULT_ADMIN_PASSWORD),
name="Admin",
role="admin",
)
db.add(admin_user)
db.flush()
# Auto-verify seeded admin
from datetime import datetime
db.add(EmailVerification(
user_id=admin_user.id,
token="seeded",
@ -44,8 +53,6 @@ def seed_admin():
db.commit()
else:
# Ensure existing admin has a verified email record
from app.models.email_verification import EmailVerification
from datetime import datetime
existing_v = db.query(EmailVerification).filter(EmailVerification.user_id == admin_exists.id).first()
if not existing_v:
db.add(EmailVerification(
@ -82,35 +89,22 @@ def seed_default_models():
db.delete(titan)
db.commit()
# Always ensure OpenAI TTS voice models exist (idempotent)
# Always ensure LiteLLM-routed local speech models exist (idempotent).
tts_voices = [
# OpenAI (work with OPENAI_API_KEY)
("OpenAI Alloy", "tts-1:alloy", True),
("OpenAI Nova", "tts-1:nova", False),
("OpenAI Echo", "tts-1:echo", False),
("OpenAI Shimmer", "tts-1:shimmer", False),
("OpenAI Onyx", "tts-1:onyx", False),
("OpenAI Fable", "tts-1:fable", False),
("OpenAI Alloy HD", "tts-1-hd:alloy", False),
("OpenAI Nova HD", "tts-1-hd:nova", False),
# ElevenLabs (work with ELEVENLABS_API_KEY)
("ElevenLabs Adam", "elevenlabs/adam", False),
# Google Cloud TTS (work with GOOGLE_TTS_API_KEY)
("Google Wavenet F (en-US)", "google/en-US-Wavenet-F", False),
("Google Wavenet D (en-US)", "google/en-US-Wavenet-D", False),
("Google Studio O (en-US)", "google/en-US-Studio-O", False),
("Google Studio Q (en-US)", "google/en-US-Studio-Q", False),
("Google Chirp 3 HD (en-US)", "google/en-US-Chirp3-HD-Aoede", False),
# AWS Polly Neural (work with AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY)
("AWS Polly Joanna (en-US)", "polly/Joanna", False),
("AWS Polly Matthew (en-US)", "polly/Matthew", False),
("AWS Polly Amy (en-GB)", "polly/Amy", False),
("AWS Polly Brian (en-GB)", "polly/Brian", False),
("Kokoro TTS (LiteLLM)", "local-kokoro-tts", True),
("Qwen3 TTS (LiteLLM)", "local-qwen3-tts", False),
]
# Deactivate old generic tts-1 / tts-1-hd entries (no voice encoded)
for old_id in ("tts-1", "tts-1-hd"):
old = db.query(AIModelConfig).filter(AIModelConfig.model_id == old_id).first()
if old:
stt_models = [
("Parakeet STT (LiteLLM)", "local-parakeet-v3", True),
("Groq Whisper Turbo", "groq-whisper-large-v3-turbo", False),
("Groq Whisper Large v3", "groq-whisper-large-v3", False),
]
# Deactivate external/direct legacy TTS entries; speech should route through LiteLLM.
for old in db.query(AIModelConfig).filter(AIModelConfig.task == "tts").all():
if old.model_id and old.model_id.startswith(("tts-", "openai/", "elevenlabs/", "eleven_labs/", "google/", "polly/", "sherpa/")):
old.is_active = False
old.is_default = False
if old.model_id == "local-chatterbox-turbo":
old.is_active = False
old.is_default = False
@ -125,6 +119,33 @@ def seed_default_models():
db.add(AIModelConfig(name=name, model_id=model_id, task="tts", is_active=True, is_default=is_def))
if is_def:
has_default_tts = True
# LiteLLM Kokoro is the intended default for read-aloud; admins can change it later.
db.query(AIModelConfig).filter(AIModelConfig.task == "tts").update({"is_default": False})
local_default = db.query(AIModelConfig).filter(
AIModelConfig.task == "tts",
AIModelConfig.model_id == "local-kokoro-tts",
).first()
if local_default:
local_default.is_active = True
local_default.is_default = True
for task, rows in (("stt", stt_models),):
has_default = db.query(AIModelConfig).filter(
AIModelConfig.task == task,
AIModelConfig.is_default == True,
AIModelConfig.is_active == True,
).first() is not None
for name, model_id, preferred_default in rows:
exists = db.query(AIModelConfig).filter(
AIModelConfig.model_id == model_id,
AIModelConfig.task == task,
).first()
if not exists:
is_def = preferred_default and not has_default
db.add(AIModelConfig(name=name, model_id=model_id, task=task, is_active=True, is_default=is_def))
if is_def:
has_default = True
db.commit()
finally:
db.close()
@ -574,6 +595,7 @@ app.include_router(contact.router, prefix="/api/contact", tags=["contact"])
app.include_router(tags.router, prefix="/api/tags", tags=["tags"])
app.include_router(flashcards.router, prefix="/api/flashcards", tags=["flashcards"])
app.include_router(courses.router, prefix="/api/courses", tags=["courses"])
app.include_router(mobile.router, prefix="/api/mobile", tags=["mobile"])
@app.get("/api/health")

View file

@ -187,8 +187,9 @@ def create_model(
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
if data.task not in ("extraction", "tts", "teach", "keyword", "flashcard"):
raise HTTPException(status_code=400, detail="Task must be extraction, tts, teach, or keyword")
valid_tasks = ("extraction", "tts", "stt", "teach", "keyword", "flashcard")
if data.task not in valid_tasks:
raise HTTPException(status_code=400, detail=f"Task must be one of: {', '.join(valid_tasks)}")
if data.is_default:
db.query(AIModelConfig).filter(
@ -276,6 +277,27 @@ def test_model(
if model.task == "tts":
raise HTTPException(status_code=400, detail="Use the Preview button to test TTS voices — it plays audio directly.")
if model.task == "stt":
base = (settings.LITELLM_API_BASE or "").rstrip("/").removesuffix("/v1")
key = model.api_key or settings.LITELLM_API_KEY
if not base:
raise HTTPException(status_code=400, detail="LiteLLM API base is not configured")
try:
headers = {"Authorization": f"Bearer {key}"} if key else {}
resp = httpx.get(f"{base}/model/info", headers=headers, timeout=10)
resp.raise_for_status()
found = any(
m.get("model_name") == model.model_id and (m.get("model_info") or {}).get("mode") == "audio_transcription"
for m in resp.json().get("data", [])
)
if not found:
raise HTTPException(status_code=404, detail=f"{model.model_id} was not found as an audio_transcription model in LiteLLM")
return {"message": f"{model.model_id} is available for speech transcription"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=502, detail=str(e))
try:
import litellm
from app.services.ai_service import _proxy_model
@ -309,85 +331,42 @@ def search_tts_voices(
data: TTSVoiceSearchRequest,
admin: User = Depends(require_admin),
):
"""Discover available TTS voices from ElevenLabs, AWS Polly, or return OpenAI hardcoded list."""
"""Discover local TTS voices/models from LiteLLM or the local speech gateway."""
import logging
log = logging.getLogger(__name__)
provider = data.provider
api_key = data.api_key
region = data.region
if provider == "elevenlabs":
key = api_key or settings.ELEVENLABS_API_KEY
if not key:
raise HTTPException(status_code=400, detail="ElevenLabs API key required (set ELEVENLABS_API_KEY in .env or enter it above)")
if provider != "litellm":
raise HTTPException(status_code=400, detail="TTS discovery is routed through LiteLLM only")
if provider == "litellm":
base = (settings.LITELLM_API_BASE or "").rstrip("/").removesuffix("/v1")
key = api_key or settings.LITELLM_API_KEY
if not base:
raise HTTPException(status_code=400, detail="LiteLLM API base is not configured")
try:
resp = httpx.get(
"https://api.elevenlabs.io/v1/voices",
headers={"xi-api-key": key},
timeout=15,
)
headers = {"Authorization": f"Bearer {key}"} if key else {}
resp = httpx.get(f"{base}/model/info", headers=headers, timeout=10)
resp.raise_for_status()
voices = resp.json().get("voices", [])
models = resp.json().get("data", [])
return {"voices": [
{
"model_id": f"elevenlabs/{v['voice_id']}",
"name": v["name"],
"labels": v.get("labels", {}),
"model_id": m.get("model_name"),
"name": m.get("model_name"),
"labels": {"provider": "litellm", "mode": "audio_speech"},
}
for v in sorted(voices, key=lambda x: x["name"])
for m in models
if m.get("model_name") and (m.get("model_info") or {}).get("mode") == "audio_speech"
]}
except HTTPException:
raise
except Exception as e:
log.warning(f"ElevenLabs voice discovery failed: {e}")
raise HTTPException(status_code=400, detail=f"ElevenLabs API error: {e}")
log.warning(f"LiteLLM local TTS discovery failed: {e}")
raise HTTPException(status_code=400, detail=f"LiteLLM TTS discovery error: {e}")
elif provider == "polly":
access_key = api_key or settings.AWS_ACCESS_KEY_ID
secret_key = settings.AWS_SECRET_ACCESS_KEY
aws_region = region or settings.AWS_REGION or "us-east-1"
if not access_key or not secret_key:
raise HTTPException(
status_code=400,
detail="AWS credentials required — set AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY in .env",
)
try:
import boto3
polly = boto3.client(
"polly",
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
region_name=aws_region,
)
resp = polly.describe_voices(Engine="neural")
voices = resp.get("Voices", [])
return {"voices": [
{
"model_id": f"polly/{v['Id']}",
"name": f"{v['Name']}{v['LanguageName']} ({v.get('Gender', '')})",
"labels": {"gender": v.get("Gender", ""), "language": v.get("LanguageName", "")},
}
for v in sorted(voices, key=lambda x: x["Name"])
]}
except HTTPException:
raise
except Exception as e:
log.warning(f"AWS Polly voice discovery failed: {e}")
raise HTTPException(status_code=400, detail=f"AWS Polly error: {e}")
elif provider == "openai":
voices = []
for model_name in ["tts-1", "tts-1-hd"]:
for voice in ["alloy", "ash", "coral", "echo", "fable", "nova", "onyx", "sage", "shimmer"]:
voices.append({
"model_id": f"{model_name}:{voice}",
"name": f"{model_name} · {voice}",
"labels": {"model": model_name, "voice": voice},
})
return {"voices": voices}
else:
raise HTTPException(status_code=400, detail=f"Unknown provider '{provider}'. Valid: elevenlabs, polly, openai")
raise HTTPException(status_code=400, detail=f"Unknown provider '{provider}'. Valid: litellm")
# --- System Settings ---

View file

@ -90,6 +90,8 @@ class ProgressUpdate(BaseModel):
class AIGenerateRequest(BaseModel):
model_config = {"protected_namespaces": ()}
prompt: str
action: str = "generate" # generate, refine, summarize
existing_content: str | None = None

View file

@ -18,6 +18,8 @@ router = APIRouter()
# ── Schemas ──────────────────────────────────────────────────────────
class FlashcardDeckCreate(BaseModel):
model_config = {"protected_namespaces": ()}
section_id: int
title: str
model_id: str | None = None

View file

@ -0,0 +1,244 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, EmailStr
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.models.attempt import AttemptAnswer, QuizAttempt
from app.models.email_verification import EmailVerification
from app.models.quiz import Quiz
from app.models.user import User
from app.schemas.auth import Token
from app.utils.auth import create_access_token, get_current_user, verify_password
from app.utils.quiz_questions import get_quiz_questions
router = APIRouter()
class MobileLoginRequest(BaseModel):
email: EmailStr
password: str
class MobileAnswerSubmit(BaseModel):
question_id: int
user_answer: str
transcript: str | None = None
selected_letter: str | None = None
confidence: float | None = None
class MobileAttemptSubmit(BaseModel):
quiz_id: int
started_at: datetime | None = None
completed_at: datetime | None = None
selected_question_ids: list[int] | None = None
answers: list[MobileAnswerSubmit]
ANDROID_MODEL_MANIFEST = {
"version": 1,
"default_stack": {
"stt": "android-speech-offline",
"mapper": "deterministic-option-matcher",
"tts": "android-system-tts",
},
"models": [
{
"id": "android-speech-offline",
"role": "stt",
"runtime": "android.speech.SpeechRecognizer",
"label": "Android offline speech recognizer",
"download_url": None,
"required": False,
"notes": "Uses the device speech service with offline preference while bundled STT artifacts are added.",
},
{
"id": "deterministic-option-matcher",
"role": "mapper",
"runtime": "in-app",
"label": "Exact option/letter matcher",
"download_url": None,
"required": True,
"notes": "Grades locally against synced answer keys; no cloud model decides correctness.",
},
{
"id": "android-system-tts",
"role": "tts",
"runtime": "android.speech.tts.TextToSpeech",
"label": "Android system TTS",
"download_url": None,
"required": False,
"notes": "Fast local read-aloud fallback before packaged neural TTS is shipped.",
},
],
}
def _mobile_login_rate_limit(client_ip: str):
try:
import redis as redis_lib
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True, socket_connect_timeout=1)
key = f"mobile_login_attempts:{client_ip}"
count = r.incr(key)
if count == 1:
r.expire(key, 15 * 60)
if count > 20:
raise HTTPException(status_code=429, detail="Too many login attempts. Try again in 15 minutes.")
except HTTPException:
raise
except Exception:
# Mobile login should still work if Redis is briefly unavailable.
return
def _visible_quizzes_query(db: Session, current_user: User):
query = db.query(Quiz).filter(Quiz.deleted_at.is_(None))
if not current_user.is_moderator:
query = query.filter(Quiz.is_published == 1, Quiz.course_id.is_(None))
return query
def _question_payload(question):
return {
"id": question.id,
"question_text": question.question_text,
"question_type": question.question_type,
"options": question.options,
"correct_answer": question.correct_answer,
"explanation": question.explanation,
"page_reference": question.page_reference,
"image_path": question.image_path,
}
def _quiz_payload(db: Session, quiz: Quiz, include_questions: bool = True):
payload = {
"id": quiz.id,
"title": quiz.title,
"mode": quiz.mode,
"questions_count": quiz.questions_count,
"time_limit_minutes": quiz.time_limit_minutes,
"questions_per_attempt": quiz.questions_per_attempt,
"category_id": quiz.category_id,
"course_id": quiz.course_id,
"is_published": quiz.is_published,
"is_shared": quiz.is_shared,
"created_at": quiz.created_at.isoformat() if quiz.created_at else None,
}
if include_questions:
payload["questions"] = [_question_payload(question) for question in get_quiz_questions(db, quiz.id)]
return payload
@router.post("/auth/login")
def mobile_login(data: MobileLoginRequest, request: Request, db: Session = Depends(get_db)):
"""Password login for native apps. Uses rate limiting instead of browser Turnstile."""
client_ip = request.client.host if request and request.client else "unknown"
_mobile_login_rate_limit(client_ip)
email = data.email.lower().strip()
user = db.query(User).filter(User.email == email).first()
if not user or not verify_password(data.password, user.hashed_password):
raise HTTPException(status_code=401, detail="Invalid email or password")
verification = db.query(EmailVerification).filter(EmailVerification.user_id == user.id).first()
if verification and verification.verified_at is None:
raise HTTPException(status_code=403, detail="Email not verified")
token = create_access_token(data={"sub": user.email})
return {
**Token(access_token=token).model_dump(),
"user": {"id": user.id, "email": user.email, "name": user.name, "role": user.role},
"server_time": datetime.utcnow().isoformat(),
}
@router.get("/sync")
def mobile_sync(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Bulk sync all visible quizzes with answer keys for offline study."""
quizzes = _visible_quizzes_query(db, current_user).order_by(Quiz.created_at.desc()).all()
return {
"server_time": datetime.utcnow().isoformat(),
"user": {"id": current_user.id, "email": current_user.email, "name": current_user.name, "role": current_user.role},
"quizzes": [_quiz_payload(db, quiz) for quiz in quizzes],
"deleted_quiz_ids": [],
}
@router.get("/quizzes/{quiz_id}")
def mobile_quiz_detail(
quiz_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
quiz = _visible_quizzes_query(db, current_user).filter(Quiz.id == quiz_id).first()
if not quiz:
raise HTTPException(status_code=404, detail="Quiz not found")
return _quiz_payload(db, quiz)
@router.get("/models")
def mobile_models(current_user: User = Depends(get_current_user)):
_ = current_user
return ANDROID_MODEL_MANIFEST
@router.post("/attempts")
def upload_mobile_attempt(
data: MobileAttemptSubmit,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
quiz = _visible_quizzes_query(db, current_user).filter(Quiz.id == data.quiz_id).first()
if not quiz:
raise HTTPException(status_code=404, detail="Quiz not found")
question_map = {question.id: question for question in get_quiz_questions(db, quiz.id)}
selected_ids = data.selected_question_ids or [answer.question_id for answer in data.answers]
total_questions = len(selected_ids) if selected_ids else len(question_map)
score = 0
attempt = QuizAttempt(
quiz_id=quiz.id,
user_id=current_user.id,
total_questions=total_questions,
started_at=data.started_at or datetime.utcnow(),
completed_at=data.completed_at or datetime.utcnow(),
selected_question_ids=selected_ids or None,
)
db.add(attempt)
db.flush()
for answer in data.answers:
question = question_map.get(answer.question_id)
if not question:
continue
is_correct = bool(answer.user_answer) and answer.user_answer.strip().lower() == question.correct_answer.strip().lower()
if is_correct:
score += 1
db.add(AttemptAnswer(
attempt_id=attempt.id,
question_id=question.id,
user_answer=answer.user_answer,
is_correct=is_correct,
))
attempt.score = score
db.commit()
percentage = round((score / total_questions * 100) if total_questions else 0, 1)
return {
"id": attempt.id,
"quiz_id": quiz.id,
"score": score,
"total_questions": total_questions,
"percentage": percentage,
"completed_at": attempt.completed_at.isoformat() if attempt.completed_at else None,
}

View file

@ -18,6 +18,8 @@ class ChatMessage(BaseModel):
class ChatRequest(BaseModel):
model_config = {"protected_namespaces": ()}
question_id: int
messages: list[ChatMessage]
model_id: int | None = None # AIModelConfig.id — if None, use default

View file

@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from fastapi.responses import Response
from pydantic import BaseModel
from sqlalchemy.orm import Session
@ -18,6 +18,20 @@ class TTSRequest(BaseModel):
voice: str | None = None # model_id override
MAX_AUDIO_UPLOAD_BYTES = 25 * 1024 * 1024
def _task_model(db: Session, task: str, fallback: str) -> tuple[str, str | None]:
config = db.query(AIModelConfig).filter(
AIModelConfig.task == task,
AIModelConfig.is_active == True,
AIModelConfig.is_default == True,
).first()
if config:
return config.model_id, config.api_key or None
return fallback, None
def _polly_enabled() -> bool:
try:
import redis as redis_lib
@ -28,15 +42,28 @@ def _polly_enabled() -> bool:
return True
def _default_tts_model(db: Session) -> tuple[str, str | None]:
config = db.query(AIModelConfig).filter(
AIModelConfig.task == "tts",
AIModelConfig.is_active == True,
AIModelConfig.is_default == True,
AIModelConfig.model_id.like("local-%"),
).first()
if config:
return config.model_id, config.api_key or None
return "local-kokoro-tts", None
@router.get("/voices")
def get_voices(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return available TTS voices from DB, excluding Polly if disabled."""
"""Return LiteLLM-routed TTS models only."""
query = db.query(AIModelConfig).filter(
AIModelConfig.task == "tts",
AIModelConfig.is_active == True,
AIModelConfig.model_id.like("local-%"),
)
if not _polly_enabled():
query = query.filter(~AIModelConfig.model_id.like("polly/%"))
@ -54,9 +81,9 @@ def text_to_speech(
# Rate limit: 60 TTS requests per user per hour (admins/unthrottled users exempt)
check_rate_limit(
key=f"tts_speak:{current_user.id}",
max_calls=60,
max_calls=240,
window_seconds=3600,
detail="You've reached the audio limit (60 clips/hour). The limit resets automatically — try again shortly. Contact an admin if you need this raised.",
detail="You've reached the audio limit. The limit resets automatically — try again shortly. Contact an admin if you need this raised.",
user=current_user,
)
if not request.text.strip():
@ -64,23 +91,50 @@ def text_to_speech(
text = request.text[:2000]
if request.voice:
if request.voice and request.voice.startswith("local-"):
if request.voice.startswith("polly/") and not _polly_enabled():
raise HTTPException(status_code=400, detail="AWS Polly is currently disabled")
config = db.query(AIModelConfig).filter(AIModelConfig.model_id == request.voice).first()
model_id = config.model_id if config else request.voice
api_key = config.api_key if (config and config.api_key) else None
else:
config = db.query(AIModelConfig).filter(
AIModelConfig.task == "tts",
AIModelConfig.is_active == True,
AIModelConfig.is_default == True,
).first()
model_id = config.model_id if config else "tts-1:alloy"
api_key = config.api_key if (config and config.api_key) else None
model_id, api_key = _default_tts_model(db)
audio = ai_service.generate_tts_audio(text, model_id=model_id, api_key=api_key)
if audio is None:
raise HTTPException(status_code=500, detail="TTS generation failed. Check model configuration.")
return Response(content=audio, media_type="audio/mpeg")
@router.post("/transcribe")
def speech_to_text(
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Transcribe microphone audio using the configured STT model."""
check_rate_limit(
key=f"stt_transcribe:{current_user.id}",
max_calls=120,
window_seconds=3600,
detail="You've reached the speech transcription limit. Try again shortly.",
user=current_user,
)
audio = file.file.read()
if not audio:
raise HTTPException(status_code=400, detail="Audio file is empty")
if len(audio) > MAX_AUDIO_UPLOAD_BYTES:
raise HTTPException(status_code=413, detail="Audio file is too large")
model_id, api_key = _task_model(db, "stt", "local-parakeet-v3")
text = ai_service.transcribe_audio(
audio,
filename=file.filename or "audio.webm",
content_type=file.content_type or "audio/webm",
model_id=model_id,
api_key=api_key,
)
if text is None:
raise HTTPException(status_code=502, detail="Speech transcription failed. Check STT model configuration.")
return {"text": text, "model": model_id}

View file

@ -7,7 +7,7 @@ class AIModelConfigCreate(BaseModel):
model_config = {"protected_namespaces": ()}
name: str
model_id: str
task: str # extraction, tts, general
task: str # extraction, tts, stt, teach, keyword, flashcard
api_key: str | None = None
is_active: bool = True
is_default: bool = False

View file

@ -2,6 +2,8 @@ from datetime import datetime
from pydantic import BaseModel
class FlashcardDeckCreate(BaseModel):
model_config = {"protected_namespaces": ()}
section_id: int
title: str
model_id: str | None = None

View file

@ -4,6 +4,8 @@ from pydantic import BaseModel
class QuizCreate(BaseModel):
model_config = {"protected_namespaces": ()}
section_id: int | None = None
title: str
mode: str = "timed" # timed, learning

View file

@ -233,6 +233,56 @@ def _call_model(prompt: str, model_id: str | None, api_key: str | None) -> str:
return response.choices[0].message.content
def _parse_json_response(text: str) -> dict:
text = text.strip()
if text.startswith("```"):
text = text.split("\n", 1)[1] if "\n" in text else text[3:]
if text.endswith("```"):
text = text[:-3]
text = text.strip()
return json.loads(text)
def transcribe_audio(
audio: bytes,
filename: str = "audio.webm",
content_type: str = "audio/webm",
model_id: str | None = None,
api_key: str | None = None,
) -> str | None:
"""Transcribe uploaded audio through LiteLLM's OpenAI-compatible audio endpoint."""
import httpx
if not audio:
return None
base = (settings.LITELLM_API_BASE or "").rstrip("/").removesuffix("/v1")
if not base:
logger.error("LiteLLM API base not configured for STT")
return None
key = api_key or settings.LITELLM_API_KEY
headers = {"Authorization": f"Bearer {key}"} if key else {}
try:
resp = httpx.post(
f"{base}/v1/audio/transcriptions",
headers=headers,
data={"model": model_id or "local-parakeet-v3", "response_format": "json"},
files={"file": (filename or "audio.webm", audio, content_type or "application/octet-stream")},
timeout=120,
)
resp.raise_for_status()
try:
data = resp.json()
if isinstance(data, dict):
return str(data.get("text") or data.get("transcript") or data.get("transcription") or "").strip()
except ValueError:
return resp.text.strip()
except Exception as e:
logger.error(f"LiteLLM STT failed: {e}")
return None
def extract_questions_no_answers(
content: str,
page_info: str = "unknown",
@ -330,7 +380,7 @@ def generate_tts_audio(
model_id: str | None = None,
api_key: str | None = None,
) -> bytes | None:
"""Generate TTS audio. Supports OpenAI, ElevenLabs, Google Cloud TTS, and AWS Polly.
"""Generate TTS audio. Supports local Sherpa, OpenAI, ElevenLabs, Google Cloud TTS, and AWS Polly.
model_id conventions:
tts-1:alloy OpenAI TTS (voice after colon)
@ -338,11 +388,52 @@ def generate_tts_audio(
elevenlabs/<voice> ElevenLabs
google/<voice_name> Google Cloud TTS (e.g. google/en-US-Wavenet-D)
polly/<VoiceId> AWS Polly Neural (e.g. polly/Joanna)
sherpa/<profile>:<voice> Local speech gateway (e.g. sherpa/kokoro:am_adam)
local-<model> Local LiteLLM TTS model (e.g. local-chatterbox-turbo)
"""
import httpx, base64
use_model = model_id or "tts-1:alloy"
# ── Local LiteLLM TTS models ─────────────────────────────────
if use_model.startswith("local-"):
key = api_key or settings.LITELLM_API_KEY
base = (settings.LITELLM_API_BASE or "").rstrip("/").removesuffix("/v1")
if not base:
logger.error("LiteLLM API base not configured for local TTS")
return None
try:
resp = httpx.post(
f"{base}/v1/audio/speech",
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"} if key else {"Content-Type": "application/json"},
json={"model": use_model, "input": text, "voice": "alloy", "response_format": "mp3"},
timeout=90,
)
resp.raise_for_status()
return resp.content
except Exception as e:
logger.error(f"Local LiteLLM TTS failed: {e}")
return None
# ── Local Sherpa gateway ───────────────────────────────────
if use_model.startswith("sherpa/"):
payload = use_model[len("sherpa/"):]
profile = payload
voice = "0"
if ":" in payload:
profile, voice = payload.split(":", 1)
try:
resp = httpx.post(
f"{settings.LOCAL_SPEECH_GATEWAY_URL.rstrip('/')}/v1/audio/speech",
json={"model": f"sherpa/{profile}", "input": text, "voice": voice, "response_format": "mp3"},
timeout=90,
)
resp.raise_for_status()
return resp.content
except Exception as e:
logger.error(f"Local Sherpa TTS failed: {e}")
return None
# ── ElevenLabs ─────────────────────────────────────────────
if use_model.startswith("elevenlabs/") or use_model.startswith("eleven_labs/"):
voice = use_model.split("/", 1)[1]

View file

@ -16,3 +16,4 @@ celery_app.conf.task_serializer = "json"
celery_app.conf.result_serializer = "json"
celery_app.conf.accept_content = ["json"]
celery_app.conf.worker_hijack_root_logger = False # Don't override our JSON logging
celery_app.conf.broker_connection_retry_on_startup = True

View file

@ -630,4 +630,4 @@ def generate_flashcard_deck(self, job_id: str, section_id: int, user_id: int,
except Exception:
pass
finally:
db.close()
db.close()

View file

@ -1,5 +1,3 @@
version: "3.8"
services:
postgres:
image: pgvector/pgvector:pg16
@ -37,6 +35,9 @@ services:
volumes:
- uploads_data:/app/uploads
- chroma_data:/app/chroma_data
networks:
- default
- speech_net
depends_on:
postgres:
condition: service_healthy
@ -132,3 +133,7 @@ volumes:
loki_data:
grafana_data:
promtail_positions:
networks:
speech_net:
external: true

View file

@ -1,4 +1,4 @@
FROM node:18-alpine AS build
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./

File diff suppressed because it is too large Load diff

View file

@ -6,33 +6,39 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"@milkdown/core": "^7.6.2",
"@milkdown/ctx": "^7.6.2",
"@milkdown/plugin-history": "^7.6.2",
"@milkdown/plugin-listener": "^7.6.2",
"@milkdown/plugin-math": "^7.5.9",
"@milkdown/preset-commonmark": "^7.6.2",
"@milkdown/preset-gfm": "^7.6.2",
"@milkdown/react": "^7.6.2",
"@milkdown/theme-nord": "^7.6.2",
"axios": "^1.6.7",
"katex": "^0.16.11",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.22.0",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"@milkdown/core": "^7.6.2",
"@milkdown/ctx": "^7.6.2",
"@milkdown/preset-commonmark": "^7.6.2",
"@milkdown/preset-gfm": "^7.6.2",
"@milkdown/plugin-listener": "^7.6.2",
"@milkdown/plugin-history": "^7.6.2",
"@milkdown/react": "^7.6.2",
"@milkdown/theme-nord": "^7.6.2",
"@milkdown/plugin-math": "^7.5.9",
"katex": "^0.16.11",
"remark-math": "^6.0.0",
"rehype-katex": "^7.0.1"
"remark-math": "^6.0.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@vitejs/plugin-react": "^4.2.1",
"vite": "^5.1.0"
"@vitejs/plugin-react": "^6.0.1",
"jsdom": "^29.1.1",
"vite": "^8.0.11",
"vitest": "^4.1.5"
}
}

View file

@ -1,33 +1,39 @@
import { lazy, Suspense } from 'react'
import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext'
import { ThemeProvider } from './context/ThemeContext'
import Navbar from './components/Navbar'
import LoginPage from './pages/LoginPage'
import RegisterPage from './pages/RegisterPage'
import DashboardPage from './pages/DashboardPage'
import UploadPage from './pages/UploadPage'
import DocumentDetailPage from './pages/DocumentDetailPage'
import QuizPage from './pages/QuizPage'
import QuizzesPage from './pages/QuizzesPage'
import ResultsPage from './pages/ResultsPage'
import AdminPage from './pages/AdminPage'
import AccountPage from './pages/AccountPage'
import SettingsPage from './pages/SettingsPage'
import QuestionBankPage from './pages/QuestionBankPage'
import JobsPage from './pages/JobsPage'
import TrashPage from './pages/TrashPage'
import QuizEditPage from './pages/QuizEditPage'
import VerifyEmailPage from './pages/VerifyEmailPage'
import ForgotPasswordPage from './pages/ForgotPasswordPage'
import ResetPasswordPage from './pages/ResetPasswordPage'
import NotFoundPage from './pages/NotFoundPage'
import LandingPage from './pages/LandingPage'
import FlashcardsPage from './pages/FlashcardsPage'
import FlashcardStudyPage from './pages/FlashcardStudyPage'
import CoursesPage from './pages/CoursesPage'
import CourseDetailPage from './pages/CourseDetailPage'
import CourseEditorPage from './pages/CourseEditorPage'
import SsoCallbackPage from './pages/SsoCallbackPage'
const LoginPage = lazy(() => import('./pages/LoginPage'))
const RegisterPage = lazy(() => import('./pages/RegisterPage'))
const DashboardPage = lazy(() => import('./pages/DashboardPage'))
const UploadPage = lazy(() => import('./pages/UploadPage'))
const DocumentDetailPage = lazy(() => import('./pages/DocumentDetailPage'))
const QuizPage = lazy(() => import('./pages/QuizPage'))
const QuizzesPage = lazy(() => import('./pages/QuizzesPage'))
const ResultsPage = lazy(() => import('./pages/ResultsPage'))
const AdminPage = lazy(() => import('./pages/AdminPage'))
const AccountPage = lazy(() => import('./pages/AccountPage'))
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
const QuestionBankPage = lazy(() => import('./pages/QuestionBankPage'))
const JobsPage = lazy(() => import('./pages/JobsPage'))
const TrashPage = lazy(() => import('./pages/TrashPage'))
const QuizEditPage = lazy(() => import('./pages/QuizEditPage'))
const VerifyEmailPage = lazy(() => import('./pages/VerifyEmailPage'))
const ForgotPasswordPage = lazy(() => import('./pages/ForgotPasswordPage'))
const ResetPasswordPage = lazy(() => import('./pages/ResetPasswordPage'))
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'))
const LandingPage = lazy(() => import('./pages/LandingPage'))
const FlashcardsPage = lazy(() => import('./pages/FlashcardsPage'))
const FlashcardStudyPage = lazy(() => import('./pages/FlashcardStudyPage'))
const CoursesPage = lazy(() => import('./pages/CoursesPage'))
const CourseDetailPage = lazy(() => import('./pages/CourseDetailPage'))
const CourseEditorPage = lazy(() => import('./pages/CourseEditorPage'))
const SsoCallbackPage = lazy(() => import('./pages/SsoCallbackPage'))
function LoadingFallback() {
return <div className="loading"><div className="spinner" /></div>
}
// Layout wrapper for authenticated app pages (Navbar + container + footer)
function AppLayout() {
@ -47,7 +53,7 @@ function AppLayout() {
// Guard: redirect to /home if not logged in, or to / if not moderator
function RequireAuth({ moderator = false }) {
const { user, loading } = useAuth()
if (loading) return <div className="loading"><div className="spinner" /></div>
if (loading) return <LoadingFallback />
if (!user) return <Navigate to="/home" replace />
if (moderator && user.role !== 'admin' && user.role !== 'moderator') return <Navigate to="/" replace />
return <Outlet />
@ -55,52 +61,54 @@ function RequireAuth({ moderator = false }) {
function AppRoutes() {
const { user, loading } = useAuth()
if (loading) return <div className="loading"><div className="spinner" /></div>
if (loading) return <LoadingFallback />
return (
<Routes>
{/* Always public */}
<Route path="/home" element={<LandingPage />} />
<Route path="/login" element={user ? <Navigate to="/" replace /> : <LoginPage />} />
<Route path="/register" element={user ? <Navigate to="/" replace /> : <RegisterPage />} />
<Route path="/verify-email" element={<VerifyEmailPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/sso-callback" element={<SsoCallbackPage />} />
<Suspense fallback={<LoadingFallback />}>
<Routes>
{/* Always public */}
<Route path="/home" element={<LandingPage />} />
<Route path="/login" element={user ? <Navigate to="/" replace /> : <LoginPage />} />
<Route path="/register" element={user ? <Navigate to="/" replace /> : <RegisterPage />} />
<Route path="/verify-email" element={<VerifyEmailPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/sso-callback" element={<SsoCallbackPage />} />
{/* Authenticated app — wrapped in AppLayout */}
<Route element={<RequireAuth />}>
<Route element={<AppLayout />}>
<Route path="/" element={<DashboardPage />} />
<Route path="/quizzes" element={<QuizzesPage />} />
<Route path="/quizzes/:id" element={<QuizPage />} />
<Route path="/results/:id" element={<ResultsPage />} />
<Route path="/documents/:id" element={<DocumentDetailPage />} />
<Route path="/account" element={<AccountPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/question-bank" element={<QuestionBankPage />} />
<Route path="/flashcards" element={<FlashcardsPage />} />
<Route path="/flashcards/:deckId/study" element={<FlashcardStudyPage />} />
<Route path="/courses" element={<CoursesPage />} />
<Route path="/courses/:courseId" element={<CourseDetailPage />} />
<Route path="/courses/:courseId/edit" element={<CourseEditorPage />} />
<Route path="/admin" element={<AdminPage />} />
{/* Authenticated app — wrapped in AppLayout */}
<Route element={<RequireAuth />}>
<Route element={<AppLayout />}>
<Route path="/" element={<DashboardPage />} />
<Route path="/quizzes" element={<QuizzesPage />} />
<Route path="/quizzes/:id" element={<QuizPage />} />
<Route path="/results/:id" element={<ResultsPage />} />
<Route path="/documents/:id" element={<DocumentDetailPage />} />
<Route path="/account" element={<AccountPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/question-bank" element={<QuestionBankPage />} />
<Route path="/flashcards" element={<FlashcardsPage />} />
<Route path="/flashcards/:deckId/study" element={<FlashcardStudyPage />} />
<Route path="/courses" element={<CoursesPage />} />
<Route path="/courses/:courseId" element={<CourseDetailPage />} />
<Route path="/courses/:courseId/edit" element={<CourseEditorPage />} />
<Route path="/admin" element={<AdminPage />} />
</Route>
</Route>
</Route>
{/* Moderator-only */}
<Route element={<RequireAuth moderator />}>
<Route element={<AppLayout />}>
<Route path="/upload" element={<UploadPage />} />
<Route path="/quizzes/:id/edit" element={<QuizEditPage />} />
<Route path="/jobs" element={<JobsPage />} />
<Route path="/trash" element={<TrashPage />} />
{/* Moderator-only */}
<Route element={<RequireAuth moderator />}>
<Route element={<AppLayout />}>
<Route path="/upload" element={<UploadPage />} />
<Route path="/quizzes/:id/edit" element={<QuizEditPage />} />
<Route path="/jobs" element={<JobsPage />} />
<Route path="/trash" element={<TrashPage />} />
</Route>
</Route>
</Route>
{/* Catch-all */}
<Route path="*" element={user ? <NotFoundPage /> : <Navigate to="/home" replace />} />
</Routes>
{/* Catch-all */}
<Route path="*" element={user ? <NotFoundPage /> : <Navigate to="/home" replace />} />
</Routes>
</Suspense>
)
}

View file

@ -5,7 +5,7 @@ import api from '../api/client'
import Dialog from '../components/Dialog'
import { useDialog } from '../hooks/useDialog'
const TASKS = ['extraction', 'tts', 'teach', 'keyword', 'flashcard']
const TASKS = ['extraction', 'tts', 'stt', 'teach', 'keyword', 'flashcard']
export default function AdminPage() {
const { user } = useAuth()
@ -19,12 +19,10 @@ export default function AdminPage() {
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [newModel, setNewModel] = useState({ name: '', model_id: '', task: 'extraction', api_key: '', is_active: true, is_default: false })
const [newModel, setNewModel] = useState({ name: '', model_id: '', task: 'extraction', is_active: true, is_default: false })
const [newUser, setNewUser] = useState({ name: '', email: '', password: '' })
// LiteLLM search state
const [searchApiKey, setSearchApiKey] = useState('')
const [searchApiBase, setSearchApiBase] = useState('')
const [searchResults, setSearchResults] = useState([])
const [searchLoading, setSearchLoading] = useState(false)
const [searchError, setSearchError] = useState('')
@ -32,8 +30,7 @@ export default function AdminPage() {
const [searchTaskHint, setSearchTaskHint] = useState('extraction')
// TTS voice discovery state
const [ttsProvider, setTtsProvider] = useState('elevenlabs')
const [ttsSearchKey, setTtsSearchKey] = useState('')
const [ttsProvider, setTtsProvider] = useState('litellm')
const [ttsVoices, setTtsVoices] = useState([])
const [ttsVoicesLoading, setTtsVoicesLoading] = useState(false)
const [ttsVoicesError, setTtsVoicesError] = useState('')
@ -115,7 +112,7 @@ export default function AdminPage() {
try {
await api.post('/admin/models', newModel)
setSuccess('Model added')
setNewModel({ name: '', model_id: '', task: 'extraction', api_key: '', is_active: true, is_default: false })
setNewModel({ name: '', model_id: '', task: 'extraction', is_active: true, is_default: false })
loadData(false)
} catch (err) {
setError(err.response?.data?.detail || 'Failed to add model')
@ -173,10 +170,7 @@ export default function AdminPage() {
setSearchResults([])
setSearchLoading(true)
try {
const res = await api.post('/admin/litellm/models', {
api_key: searchApiKey || null,
api_base: searchApiBase || null,
})
const res = await api.post('/admin/litellm/models', {})
setSearchResults(res.data.models)
} catch (err) {
setSearchError(err.response?.data?.detail || 'Failed to query models')
@ -255,7 +249,6 @@ export default function AdminPage() {
try {
const res = await api.post('/admin/tts/voices', {
provider: ttsProvider,
api_key: ttsSearchKey || null,
})
setTtsVoices(res.data.voices)
} catch (err) {
@ -325,27 +318,9 @@ export default function AdminPage() {
<div className="card">
<h2>Search LLM Models</h2>
<p style={{ color: '#64748b', fontSize: '0.85rem', marginBottom: 16 }}>
Query your LiteLLM proxy or any OpenAI-compatible API. Works for <strong>extraction</strong>, <strong>teach</strong>, and <strong>general</strong> tasks.
Query the configured LiteLLM proxy from the server environment. Works for chat, extraction, STT, and other configured tasks.
</p>
<div className="grid-2">
<div className="form-group">
<label>API Base URL (optional uses .env if blank)</label>
<input
type="text"
value={searchApiBase}
onChange={e => setSearchApiBase(e.target.value)}
placeholder="e.g. https://litellm.myserver.com"
/>
</div>
<div className="form-group">
<label>API Key (optional uses .env if blank)</label>
<input
type="password"
value={searchApiKey}
onChange={e => setSearchApiKey(e.target.value)}
placeholder="sk-..."
/>
</div>
<div className="form-group">
<label>Adding model for task</label>
<select value={searchTaskHint} onChange={e => setSearchTaskHint(e.target.value)}>
@ -403,9 +378,11 @@ export default function AdminPage() {
{task} Models
<span style={{ fontSize: '0.75rem', color: '#64748b', marginLeft: 8, fontWeight: 400 }}>
{task === 'extraction' ? '— AI that extracts questions from PDFs' :
task === 'tts' ? '— Text-to-speech voices' :
task === 'teach' ? '— AI tutor shown in study mode chat' :
'— General purpose AI'}
task === 'tts' ? '— Text-to-speech voices' :
task === 'stt' ? '— Speech-to-text models' :
task === 'teach' ? '— AI tutor shown in study mode chat' :
task === 'keyword' ? '— Keyword and topic classification' :
'— Flashcard generation'}
</span>
</h2>
@ -416,13 +393,8 @@ export default function AdminPage() {
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 8 }}>
<select value={ttsProvider} onChange={e => { setTtsProvider(e.target.value); setTtsVoices([]) }}
style={{ padding: '5px 10px', borderRadius: 6, border: '1px solid var(--border)', fontSize: '0.85rem', background: 'var(--input-bg)', color: 'var(--text)' }}>
<option value="elevenlabs">ElevenLabs</option>
<option value="polly">AWS Polly (Neural)</option>
<option value="openai">OpenAI TTS</option>
<option value="litellm">LiteLLM Local</option>
</select>
<input type="password" value={ttsSearchKey} onChange={e => setTtsSearchKey(e.target.value)}
placeholder="API key (uses .env if blank)"
style={{ flex: 1, minWidth: 140, padding: '5px 10px', borderRadius: 6, border: '1px solid var(--border)', fontSize: '0.85rem', background: 'var(--input-bg)', color: 'var(--text)' }} />
<button className="btn btn-primary btn-sm" onClick={searchTtsVoices} disabled={ttsVoicesLoading}>
{ttsVoicesLoading ? 'Searching...' : 'Search'}
</button>
@ -546,10 +518,6 @@ export default function AdminPage() {
<label>Model ID (LiteLLM format)</label>
<input type="text" value={newModel.model_id} onChange={e => setNewModel(m => ({ ...m, model_id: e.target.value }))} placeholder="e.g. gpt-4o-mini" required />
</div>
<div className="form-group">
<label>API Key (optional override)</label>
<input type="password" value={newModel.api_key} onChange={e => setNewModel(m => ({ ...m, api_key: e.target.value }))} placeholder="Leave blank to use .env key" />
</div>
<div className="form-group" style={{ display: 'flex', gap: 16, alignItems: 'center', marginTop: 24 }}>
<label style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input type="checkbox" checked={newModel.is_default} onChange={e => setNewModel(m => ({ ...m, is_default: e.target.checked }))} />

View file

@ -532,6 +532,8 @@ export default function QuestionBankPage() {
const [importFile, setImportFile] = useState(null)
const [importing, setImporting] = useState(false)
const [importResult, setImportResult] = useState(null)
const [qtiImporting, setQtiImporting] = useState(false)
const [qtiExporting, setQtiExporting] = useState(false)
const debounceRef = useRef(null)
const classifyPollRef = useRef(null)
const { user } = useAuth()
@ -700,6 +702,49 @@ export default function QuestionBankPage() {
}
}
const handleQtiImport = () => {
if (qtiImporting) return
const input = document.createElement('input')
input.type = 'file'
input.accept = '.xml'
input.onchange = async (e) => {
const file = e.target.files?.[0]
if (!file) return
setQtiImporting(true)
try {
const fd = new FormData()
fd.append('file', file)
const res = await api.post('/questions/import/qti', fd)
const errors = res.data.errors?.length ? ` ${res.data.errors.length} error(s).` : ''
openAlert(`Imported ${res.data.imported} of ${res.data.total_items} questions.${errors}`, { title: 'QTI Import Complete' })
loadQuestions()
} catch (err) {
openAlert(err.response?.data?.detail || 'QTI import failed', { title: 'Import Failed' })
} finally {
setQtiImporting(false)
}
}
input.click()
}
const handleQtiExport = async () => {
setQtiExporting(true)
try {
const ids = selectedIds.size > 0 ? [...selectedIds].join(',') : ''
const res = await api.get(`/questions/export/qti${ids ? `?question_ids=${ids}` : ''}`, { responseType: 'blob' })
const url = URL.createObjectURL(res.data)
const a = document.createElement('a')
a.href = url
a.download = 'questions_qti.xml'
a.click()
URL.revokeObjectURL(url)
} catch (err) {
openAlert(err.response?.data?.detail || 'QTI export failed', { title: 'Export Failed' })
} finally {
setQtiExporting(false)
}
}
const toggleFavorite = async (questionId) => {
const isFavorited = favorites.includes(questionId)
try {
@ -808,27 +853,12 @@ export default function QuestionBankPage() {
)}
<button className="btn btn-secondary btn-sm" onClick={() => setShowCreateQuestion(true)}>+ Question</button>
<button className="btn btn-secondary btn-sm" onClick={() => { setShowImport(true); setImportFile(null); setImportResult(null) }}>Import CSV/Excel</button>
<button className="btn btn-secondary btn-sm" onClick={async () => {
const input = document.createElement('input'); input.type = 'file'; input.accept = '.xml'
input.onchange = async (e) => {
const f = e.target.files[0]; if (!f) return
const fd = new FormData(); fd.append('file', f)
try {
const res = await api.post('/questions/import/qti', fd)
alert(`Imported ${res.data.imported} of ${res.data.total_items} questions${res.data.errors?.length ? `. ${res.data.errors.length} error(s).` : ''}`)
loadQuestions()
} catch (err) { alert(err.response?.data?.detail || 'QTI import failed') }
}; input.click()
}}>Import QTI</button>
<button className="btn btn-secondary btn-sm" onClick={async () => {
try {
const ids = selectedIds.size > 0 ? [...selectedIds].join(',') : ''
const res = await api.get(`/questions/export/qti${ids ? `?question_ids=${ids}` : ''}`, { responseType: 'blob' })
const url = URL.createObjectURL(res.data)
const a = document.createElement('a'); a.href = url; a.download = 'questions_qti.xml'; a.click()
URL.revokeObjectURL(url)
} catch { }
}}>Export QTI{selectedIds.size > 0 ? ` (${selectedIds.size})` : ''}</button>
<button className="btn btn-secondary btn-sm" onClick={handleQtiImport} disabled={qtiImporting}>
{qtiImporting ? 'Importing QTI...' : 'Import QTI'}
</button>
<button className="btn btn-secondary btn-sm" onClick={handleQtiExport} disabled={qtiExporting}>
{qtiExporting ? 'Exporting QTI...' : `Export QTI${selectedIds.size > 0 ? ` (${selectedIds.size})` : ''}`}
</button>
{isModerator && <button className="btn btn-secondary btn-sm" onClick={() => setShowCatForm(v => !v)}>+ Category</button>}
</div>
</div>

View file

@ -0,0 +1,101 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import QuestionBankPage from './QuestionBankPage'
import api from '../api/client'
vi.mock('../api/client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}))
vi.mock('../context/AuthContext', () => ({
useAuth: () => ({ user: { role: 'admin' } }),
}))
function mockInitialRequests() {
api.get.mockImplementation((url) => {
if (url === '/question-categories/') return Promise.resolve({ data: [] })
if (url === '/favorites') return Promise.resolve({ data: [] })
if (url === '/tags') return Promise.resolve({ data: { subjects: [], diseases: [], keywords: [] } })
if (url === '/questions/bank') return Promise.resolve({ data: { questions: [], total: 0 } })
return Promise.resolve({ data: [] })
})
}
function renderPage() {
return render(
<MemoryRouter>
<QuestionBankPage />
</MemoryRouter>
)
}
describe('QuestionBankPage QTI actions', () => {
let originalCreateElement
let fileInput
beforeEach(() => {
vi.clearAllMocks()
mockInitialRequests()
originalCreateElement = document.createElement.bind(document)
fileInput = null
vi.spyOn(document, 'createElement').mockImplementation((tagName, options) => {
const element = originalCreateElement(tagName, options)
if (tagName === 'input') {
fileInput = element
vi.spyOn(element, 'click').mockImplementation(() => {})
}
return element
})
})
afterEach(() => {
document.createElement.mockRestore()
})
it('imports QTI files with an in-app success dialog', async () => {
api.post.mockResolvedValueOnce({ data: { imported: 3, total_items: 4, errors: ['Skipped duplicate'] } })
renderPage()
await userEvent.click(await screen.findByRole('button', { name: 'Import QTI' }))
expect(fileInput).toBeTruthy()
expect(fileInput.accept).toBe('.xml')
const file = new File(['<questestinterop />'], 'questions.xml', { type: 'text/xml' })
Object.defineProperty(fileInput, 'files', { value: [file], configurable: true })
fireEvent.change(fileInput)
await waitFor(() => {
expect(api.post).toHaveBeenCalledWith('/questions/import/qti', expect.any(FormData))
})
expect(await screen.findByText('QTI Import Complete')).toBeInTheDocument()
expect(screen.getByText('Imported 3 of 4 questions. 1 error(s).')).toBeInTheDocument()
})
it('shows QTI export failures instead of swallowing them', async () => {
api.get.mockImplementation((url) => {
if (url.startsWith('/questions/export/qti')) {
return Promise.reject({ response: { data: { detail: 'Export unavailable' } } })
}
if (url === '/question-categories/') return Promise.resolve({ data: [] })
if (url === '/favorites') return Promise.resolve({ data: [] })
if (url === '/tags') return Promise.resolve({ data: { subjects: [], diseases: [], keywords: [] } })
if (url === '/questions/bank') return Promise.resolve({ data: { questions: [], total: 0 } })
return Promise.resolve({ data: [] })
})
renderPage()
await userEvent.click(await screen.findByRole('button', { name: 'Export QTI' }))
expect(await screen.findByText('Export Failed')).toBeInTheDocument()
expect(screen.getByText('Export unavailable')).toBeInTheDocument()
})
})

View file

@ -5,21 +5,102 @@ import api from '../api/client'
const TeachChat = lazy(() => import('../components/TeachChat'))
function TTSButton({ text, voice, onActiveChange }) {
const OPTION_LETTERS = ['A', 'B', 'C', 'D', 'E', 'F']
const QUESTION_HIGHLIGHT_WORDS = 8
const OPTION_HIGHLIGHT_WORDS = 7
const TTS_PRELOAD_AHEAD = 5
function splitSpeechChunks(text, maxWords) {
const words = (text || '').trim().split(/\s+/).filter(Boolean)
if (!words.length) return []
const chunks = []
for (let i = 0; i < words.length; i += maxWords) {
chunks.push(words.slice(i, i + maxWords).join(' '))
}
return chunks
}
function questionStem(question) {
return (question?.question_text || '').replace('[IMAGE]', '').trim()
}
function getQuestionSpeechSegments(question, index) {
if (!question) return []
const segments = []
splitSpeechChunks(questionStem(question), QUESTION_HIGHLIGHT_WORDS).forEach((chunk, chunkIndex) => {
segments.push({
type: 'question',
chunkIndex,
text: chunkIndex === 0 ? `Question ${index + 1}. ${chunk}` : chunk,
})
})
if (question.options?.length) segments.push({ type: 'meta', text: 'Options.' })
;(question.options || []).forEach((option, i) => {
splitSpeechChunks(option, OPTION_HIGHLIGHT_WORDS).forEach((chunk, chunkIndex) => {
segments.push({
type: 'option',
index: i,
chunkIndex,
text: chunkIndex === 0 ? `${OPTION_LETTERS[i] || i + 1}. ${chunk}` : chunk,
})
})
})
return segments
}
function buildQuestionSpeechText(question, index) {
const segments = getQuestionSpeechSegments(question, index)
return segments.map(s => s.text).join(' ')
}
function HighlightedSpeechText({ text, activeChunk, maxWords }) {
const chunks = splitSpeechChunks(text, maxWords)
return chunks.map((chunk, i) => (
<span key={i} style={{
background: activeChunk === i ? '#fef08a' : 'transparent',
borderRadius: 4,
padding: activeChunk === i ? '1px 3px' : 0,
transition: 'background 0.15s ease',
}}>
{chunk}{i < chunks.length - 1 ? ' ' : ''}
</span>
))
}
function getActiveSpeechSegment(audio, segments) {
if (!segments.length) return null
if (!audio || !Number.isFinite(audio.duration) || audio.duration <= 0) return 0
const weights = segments.map(segment => Math.max(12, segment.text.length))
const total = weights.reduce((sum, weight) => sum + weight, 0)
const target = (audio.currentTime / audio.duration) * total
let cursor = 0
for (let i = 0; i < weights.length; i += 1) {
cursor += weights[i]
if (target <= cursor) return i
}
return segments.length - 1
}
function TTSButton({ text, voice, segments = [], onActiveChange, onSegmentChange, getAudio, autoPlay, onEnded }) {
const [state, setState] = useState('idle') // idle | loading | playing
const audioRef = useRef(null)
// Stop audio when question changes (component unmounts)
useEffect(() => {
return () => {
audioRef.current?.pause()
onActiveChange?.(false)
onSegmentChange?.(null)
}
}, [])
useEffect(() => {
if (autoPlay && state === 'idle') speak()
}, [autoPlay])
const setStateAndNotify = (s) => {
setState(s)
onActiveChange?.(s !== 'idle')
if (s === 'idle') onSegmentChange?.(null)
}
const speak = async () => {
@ -31,14 +112,29 @@ function TTSButton({ text, voice, onActiveChange }) {
if (state === 'loading') return
try {
setStateAndNotify('loading')
const res = await api.post('/tts/speak', { text, voice: voice || null }, { responseType: 'blob' })
const url = URL.createObjectURL(res.data)
let url
let revokeOnDone = false
if (getAudio) {
url = (await getAudio(text, voice))?.url
} else {
const res = await api.post('/tts/speak', { text, voice: voice || null }, { responseType: 'blob' })
url = URL.createObjectURL(res.data)
revokeOnDone = true
}
if (!url) throw new Error('No audio returned')
const audio = new Audio(url)
audioRef.current = audio
audio.onended = () => { setStateAndNotify('idle'); URL.revokeObjectURL(url) }
audio.onerror = () => setStateAndNotify('idle')
audio.play()
const updateSegment = () => {
const activeIndex = getActiveSpeechSegment(audio, segments)
onSegmentChange?.(activeIndex === null ? null : segments[activeIndex] || null)
}
audio.onloadedmetadata = updateSegment
audio.ontimeupdate = updateSegment
audio.onended = () => { setStateAndNotify('idle'); onEnded?.(); if (revokeOnDone) URL.revokeObjectURL(url) }
audio.onerror = () => { setStateAndNotify('idle'); if (revokeOnDone) URL.revokeObjectURL(url) }
await audio.play()
setStateAndNotify('playing')
updateSegment()
} catch { setStateAndNotify('idle') }
}
@ -99,6 +195,21 @@ function CourseQuizStart({ quiz, onStart }) {
function ModeSelectScreen({ quiz, voices, onStart }) {
const [selectedVoice, setSelectedVoice] = useState(voices.find(v => v.is_default)?.id || voices[0]?.id || '')
const [customTimer, setCustomTimer] = useState(quiz.time_limit_minutes || '')
const [startError, setStartError] = useState('')
const [startingMode, setStartingMode] = useState('')
const handleStart = async (mode) => {
const timerMinutes = mode === 'exam' && customTimer ? parseInt(customTimer) : null
setStartError('')
setStartingMode(mode)
try {
await onStart(mode, selectedVoice, timerMinutes)
} catch {
setStartError('Could not start the quiz. Try again.')
} finally {
setStartingMode('')
}
}
return (
<div style={{ maxWidth: 520, margin: '40px auto' }}>
@ -115,17 +226,22 @@ function ModeSelectScreen({ quiz, voices, onStart }) {
{ mode: 'study', icon: '📖', label: 'Study Mode', desc: 'Answers & explanations shown as you go', color: '#22c55e', bg: '#f0fdf4' },
{ mode: 'exam', icon: '🎯', label: 'Exam Mode', desc: 'Answers hidden until submitted', color: '#3b82f6', bg: '#eff6ff' },
].map(({ mode, icon, label, desc, color, bg }) => (
<div key={mode} onClick={() => onStart(mode, selectedVoice, mode === 'exam' && customTimer ? parseInt(customTimer) : null)}
style={{ flex: 1, border: `2px solid ${color}`, borderRadius: 12, padding: '18px 12px', cursor: 'pointer', background: bg, transition: 'transform 0.1s' }}
<div key={mode} onClick={() => !startingMode && handleStart(mode)}
style={{ flex: 1, border: `2px solid ${color}`, borderRadius: 12, padding: '18px 12px', cursor: startingMode ? 'wait' : 'pointer', background: bg, transition: 'transform 0.1s', opacity: startingMode && startingMode !== mode ? 0.55 : 1 }}
onMouseEnter={e => e.currentTarget.style.transform = 'scale(1.03)'}
onMouseLeave={e => e.currentTarget.style.transform = 'none'}
>
<div style={{ fontSize: '1.8rem', marginBottom: 6 }}>{icon}</div>
<div style={{ fontWeight: 700, color, marginBottom: 4 }}>{label}</div>
<div style={{ fontSize: '0.8rem', color }}>{desc}</div>
<div style={{ fontSize: '0.8rem', color }}>{startingMode === mode ? 'Starting...' : desc}</div>
</div>
))}
</div>
{startError && (
<div style={{ background: '#fef2f2', color: '#991b1b', border: '1px solid #fecaca', borderRadius: 8, padding: '8px 10px', fontSize: '0.82rem', marginBottom: 14 }}>
{startError}
</div>
)}
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 14, marginBottom: 14 }}>
<label style={{ fontSize: '0.85rem', color: 'var(--text-muted)', display: 'block', marginBottom: 6 }}> Timer for Exam Mode <span style={{ fontWeight: 400 }}>(minutes, optional)</span></label>
@ -165,6 +281,7 @@ export default function QuizPage() {
const [voices, setVoices] = useState([])
const [selectedVoice, setSelectedVoice] = useState('')
const [ttsActive, setTtsActive] = useState(false)
const [readThrough, setReadThrough] = useState(false)
const [quizMode, setQuizMode] = useState(null)
const [answers, setAnswers] = useState({})
const [currentIdx, setCurrentIdx] = useState(0)
@ -178,9 +295,12 @@ export default function QuizPage() {
const [navOpen, setNavOpen] = useState(false)
const [startedAt, setStartedAt] = useState(null)
const [favorites, setFavorites] = useState([])
const [activeReadSegment, setActiveReadSegment] = useState(null)
const timerRef = useRef(null)
const toastRef = useRef(null)
const hasStarted = useRef(false)
const ttsCacheRef = useRef(new Map())
const autoAdvanceRef = useRef(null)
const showToast = (msg) => {
setToast(msg)
@ -189,14 +309,64 @@ export default function QuizPage() {
}
const [leaveTarget, setLeaveTarget] = useState(null)
const questions = quiz?.questions || []
const current = questions[currentIdx]
const isStudy = quizMode === 'study'
const fetchTtsAudio = useCallback(async (text, voice) => {
const cleanText = (text || '').trim()
if (!cleanText) return null
const key = `${voice || 'default'}::${cleanText}`
const cached = ttsCacheRef.current.get(key)
if (cached?.url) return cached
if (cached?.promise) return cached.promise
const promise = api.post('/tts/speak', { text: cleanText, voice: voice || null }, { responseType: 'blob' })
.then(res => {
const entry = { url: URL.createObjectURL(res.data) }
ttsCacheRef.current.set(key, entry)
return entry
})
.catch(err => {
if (ttsCacheRef.current.get(key)?.promise === promise) ttsCacheRef.current.delete(key)
throw err
})
ttsCacheRef.current.set(key, { promise })
return promise
}, [])
useEffect(() => {
return () => {
clearTimeout(autoAdvanceRef.current)
ttsCacheRef.current.forEach(entry => { if (entry.url) URL.revokeObjectURL(entry.url) })
ttsCacheRef.current.clear()
}
}, [])
useEffect(() => {
setActiveReadSegment(null)
setTtsActive(false)
if (!readThrough) setActiveReadSegment(null)
clearTimeout(autoAdvanceRef.current)
}, [currentIdx, readThrough])
useEffect(() => {
if (!quizMode || !questions.length || !voices.length) return
for (let index = currentIdx; index <= Math.min(currentIdx + TTS_PRELOAD_AHEAD, questions.length - 1); index += 1) {
const q = questions[index]
if (!q) return
fetchTtsAudio(buildQuestionSpeechText(q, index), selectedVoice).catch(() => {})
}
}, [quizMode, questions, currentIdx, selectedVoice, voices.length, fetchTtsAudio])
const resumeQuiz = useCallback(async (saved) => {
const mode = saved.mode || saved.quizMode
const savedMode = saved.mode || saved.quizMode
const mode = savedMode === 'exam' ? 'exam' : 'study'
const savedIdx = saved.current_idx ?? saved.currentIdx ?? 0
const savedAnswers = saved.answers || {}
const aid = saved.attempt_id || saved.attemptId || ''
// For study mode, load quiz with correct answers BEFORE showing
// For feedback modes, load quiz with correct answers BEFORE showing.
if (mode === 'study') {
try {
const quizRes = await api.get(`/quizzes/${id}?study=true${aid ? `&attempt_id=${aid}` : ''}`)
@ -296,7 +466,7 @@ export default function QuizPage() {
// Fetch quiz with attempt_id for question pool filtering
let quizData = quiz
const studyParam = (mode === 'study') ? '&study=true' : ''
const studyParam = mode === 'study' ? '&study=true' : ''
const quizRes = await api.get(`/quizzes/${id}?attempt_id=${aid}${studyParam}`)
quizData = quizRes.data
setQuiz(quizData)
@ -350,7 +520,10 @@ const timerStarted = timeLeft !== null
const setAnswer = (questionId, value) => setAnswers(prev => ({ ...prev, [questionId]: value }))
const safeNavigate = (targetIdx) => setCurrentIdx(targetIdx)
const safeNavigate = (targetIdx, { keepReadThrough = false } = {}) => {
if (!keepReadThrough) setReadThrough(false)
setCurrentIdx(targetIdx)
}
const handleSubmit = useCallback(async (autoSubmit = false) => {
if (!attemptId || submitting) return
@ -384,7 +557,7 @@ const timerStarted = timeLeft !== null
if (!quizMode) return (
<div>
{isModerator && (
<div style={{ textAlign: 'right', marginBottom: 8 }}>
<div style={{ textAlign: 'right', marginBottom: 8, display: 'flex', gap: 8, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
<Link to={`/quizzes/${id}/edit`} className="btn btn-secondary btn-sm"> Edit Questions</Link>
</div>
)}
@ -401,12 +574,10 @@ const timerStarted = timeLeft !== null
</div>
)
const isStudy = quizMode === 'study'
const questions = quiz.questions || []
const current = questions[currentIdx]
const answeredCount = Object.keys(answers).length
const totalCount = questions.length
const isLast = currentIdx === totalCount - 1
const activeReadForCurrent = current && activeReadSegment?.questionId === current.id
const toggleFavorite = async (questionId) => {
const isFavorited = favorites.includes(questionId)
@ -505,7 +676,8 @@ const timerStarted = timeLeft !== null
<h2 style={{ marginBottom: 2, fontSize: '1rem' }}>{quiz.title}</h2>
<div style={{ fontSize: '0.82rem', color: 'var(--text-muted)', display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{
background: isStudy ? '#d1fae5' : '#e0e7ff', color: isStudy ? '#065f46' : '#3730a3',
background: isStudy ? '#d1fae5' : '#e0e7ff',
color: isStudy ? '#065f46' : '#3730a3',
padding: '1px 8px', borderRadius: 12, fontWeight: 600,
}}>
{isStudy ? '📖 Study' : '🎯 Exam'}
@ -543,9 +715,19 @@ const timerStarted = timeLeft !== null
{/* Main content */}
<div style={{ flex: 1, minWidth: 0 }}>
{current && (
<div className="question-card">
<div className="question-card" style={{
boxShadow: activeReadForCurrent ? '0 0 0 3px rgba(250, 204, 21, 0.28)' : undefined,
borderColor: activeReadForCurrent ? '#facc15' : undefined,
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8, gap: 12 }}>
<h3 style={{ marginBottom: 0, flex: 1 }}>Q{currentIdx + 1}. {current.question_text.replace('[IMAGE]', '')}</h3>
<h3 style={{ marginBottom: 0, flex: 1 }}>
Q{currentIdx + 1}.{' '}
<HighlightedSpeechText
text={questionStem(current)}
maxWords={QUESTION_HIGHLIGHT_WORDS}
activeChunk={activeReadForCurrent && activeReadSegment.type === 'question' ? activeReadSegment.chunkIndex : null}
/>
</h3>
<button
onClick={() => toggleFavorite(current.id)}
title={favorites.includes(current.id) ? 'Remove from favorites' : 'Add to favorites'}
@ -564,16 +746,44 @@ const timerStarted = timeLeft !== null
{favorites.includes(current.id) ? '⭐' : '☆'}
</button>
</div>
{voices.length > 0 && (
<div style={{ marginBottom: 10 }}>
<div style={{ marginBottom: 10, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
{voices.length > 0 && (
<TTSButton
key={`${current.id}_${answers[current.id] || ''}`}
text={`Question ${currentIdx + 1}. ${current.question_text.replace('[IMAGE]', '')}. Options: ${(current.options || []).join(', ')}`}
key={`${current.id}_${selectedVoice || 'default'}`}
text={buildQuestionSpeechText(current, currentIdx)}
voice={selectedVoice}
segments={getQuestionSpeechSegments(current, currentIdx)}
getAudio={fetchTtsAudio}
autoPlay={readThrough}
onEnded={() => {
if (readThrough) {
if (currentIdx < totalCount - 1) {
safeNavigate(currentIdx + 1, { keepReadThrough: true })
} else {
setReadThrough(false)
}
return
}
}}
onActiveChange={setTtsActive}
onSegmentChange={segment => setActiveReadSegment(segment === null ? null : { questionId: current.id, ...segment })}
/>
</div>
)}
)}
{voices.length > 0 && (
<button
className={`btn btn-sm ${readThrough ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setReadThrough(v => !v)}
title="Read each question aloud and advance automatically"
>
{readThrough ? 'Stop listen-through' : 'Listen through'}
</button>
)}
{voices.length > 0 && (
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
Preloading next {Math.min(TTS_PRELOAD_AHEAD, Math.max(totalCount - currentIdx - 1, 0))} question{Math.min(TTS_PRELOAD_AHEAD, Math.max(totalCount - currentIdx - 1, 0)) === 1 ? '' : 's'}
</span>
)}
</div>
<span className="badge" style={{ background: '#e0e7ff', color: '#3730a3', margin: '6px 0 12px', display: 'inline-block' }}>
{current.question_type === 'mcq' ? 'Multiple Choice' : current.question_type === 'true_false' ? 'True / False' : 'Fill in the Blank'}
</span>
@ -593,13 +803,23 @@ const timerStarted = timeLeft !== null
const showCorrect = hasAnswered && isCorrectOpt
const showWrong = hasAnswered && isSelected && !isCorrectOpt
const letter = String.fromCharCode(65 + i)
const activeOptionChunk = activeReadForCurrent && activeReadSegment.type === 'option' && activeReadSegment.index === i
? activeReadSegment.chunkIndex
: null
return (
<div key={i}
className={`option ${isSelected && !hasAnswered ? 'selected' : ''} ${showCorrect ? 'correct' : ''} ${showWrong ? 'incorrect' : ''}`}
onClick={() => !hasAnswered && setAnswer(current.id, opt)}
style={{ cursor: hasAnswered ? 'default' : 'pointer' }}>
style={{
cursor: hasAnswered ? 'default' : 'pointer',
borderColor: activeOptionChunk !== null ? '#facc15' : undefined,
boxShadow: activeOptionChunk !== null ? '0 0 0 3px rgba(250, 204, 21, 0.25)' : undefined,
transition: 'background 0.15s ease, box-shadow 0.15s ease',
}}>
<span className="option-letter">{letter}</span>
<span style={{ flex: 1 }}>{opt}</span>
<span style={{ flex: 1 }}>
<HighlightedSpeechText text={opt} maxWords={OPTION_HIGHLIGHT_WORDS} activeChunk={activeOptionChunk} />
</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>}
</div>
@ -646,7 +866,7 @@ const timerStarted = timeLeft !== null
{submitting ? 'Submitting...' : 'Submit Quiz'}
</button>
) : (
<button className="btn btn-primary" onClick={() => safeNavigate(Math.min(totalCount - 1, currentIdx + 1))}>Next </button>
<button className="btn btn-primary" onClick={() => safeNavigate(Math.min(totalCount - 1, currentIdx + 1))}>Next </button>
)}
</div>

View file

@ -1,5 +1,5 @@
import { useState, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { Link, useNavigate } from 'react-router-dom'
import api from '../api/client'
function NextcloudBrowser({ onFile }) {
@ -49,7 +49,7 @@ function NextcloudBrowser({ onFile }) {
<div style={{ padding: '20px', textAlign: 'center', color: 'var(--text-muted)', fontSize: '0.875rem' }}>
<div style={{ fontSize: '1.5rem', marginBottom: 8 }}></div>
No Nextcloud account configured.{' '}
<a href="/settings" style={{ color: 'var(--primary)' }}>Go to Settings</a> to add one.
<Link to="/settings" style={{ color: 'var(--primary)' }}>Go to Settings</Link> to add one.
</div>
)
}

View file

@ -0,0 +1,31 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import UploadPage from './UploadPage'
vi.mock('../api/client', () => ({
default: {
post: vi.fn(),
},
}))
describe('UploadPage', () => {
beforeEach(() => {
localStorage.clear()
})
it('uses client-side navigation for the settings link', async () => {
render(
<MemoryRouter>
<UploadPage />
</MemoryRouter>
)
await userEvent.click(screen.getByRole('button', { name: '☁️ Nextcloud (not set up)' }))
const settingsLink = screen.getByRole('link', { name: 'Go to Settings' })
expect(settingsLink).toHaveAttribute('href', '/settings')
})
})

View file

@ -0,0 +1,7 @@
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
afterEach(() => {
cleanup()
})

View file

@ -7,5 +7,9 @@ export default defineConfig({
proxy: {
'/api': 'http://localhost:8000'
}
},
test: {
environment: 'jsdom',
setupFiles: './src/test/setup.js'
}
})

View file

@ -20,4 +20,4 @@
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
</style>
</resources>
</resources>

View file

@ -2,4 +2,4 @@ include ':app'
include ':capacitor-cordova-android-plugins'
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
apply from: 'capacitor.settings.gradle'
apply from: 'capacitor.settings.gradle'