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 UPLOAD_DIR=/app/uploads
MAX_UPLOAD_SIZE=524288000 MAX_UPLOAD_SIZE=524288000
CHROMA_PERSIST_DIR=/app/chroma_data 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`) ### Frontend (`frontend/.env`)

View file

@ -4,6 +4,10 @@ SECRET_KEY=change-me-to-a-random-secret-key-in-production
ALGORITHM=HS256 ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=1440 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 (use service name in Docker)
REDIS_URL=redis://redis:6379/0 REDIS_URL=redis://redis:6379/0
@ -11,6 +15,9 @@ REDIS_URL=redis://redis:6379/0
LITELLM_MODEL=gpt-4o-mini LITELLM_MODEL=gpt-4o-mini
LITELLM_API_KEY=your-api-key-here 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 # Vector store
CHROMA_PERSIST_DIR=/app/chroma_data CHROMA_PERSIST_DIR=/app/chroma_data

View file

@ -18,6 +18,7 @@ class Settings(BaseSettings):
OPENAI_API_KEY: str = "" OPENAI_API_KEY: str = ""
ELEVENLABS_API_KEY: str = "" ELEVENLABS_API_KEY: str = ""
GOOGLE_TTS_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_ACCESS_KEY_ID: str = ""
AWS_SECRET_ACCESS_KEY: str = "" AWS_SECRET_ACCESS_KEY: str = ""
AWS_REGION: str = "us-east-1" AWS_REGION: str = "us-east-1"
@ -41,6 +42,9 @@ class Settings(BaseSettings):
TURNSTILE_SECRET_KEY: str = "" # Cloudflare Turnstile — leave blank to disable captcha TURNSTILE_SECRET_KEY: str = "" # Cloudflare Turnstile — leave blank to disable captcha
ADMIN_EMAIL: str = "" # Where contact form submissions are emailed 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_SERVER_URL: str = "" # BigBlueButton server URL (e.g. https://bbb.example.com/bigbluebutton)
BBB_SECRET: str = "" # BigBlueButton shared secret BBB_SECRET: str = "" # BigBlueButton shared secret
@ -53,5 +57,4 @@ class Settings(BaseSettings):
LOG_LEVEL: str = "INFO" # DEBUG, INFO, WARNING, ERROR LOG_LEVEL: str = "INFO" # DEBUG, INFO, WARNING, ERROR
settings = Settings() settings = Settings()

View file

@ -11,30 +11,39 @@ from app.logging_config import setup_logging
# Configure structured JSON logging before anything else # Configure structured JSON logging before anything else
setup_logging(settings.LOG_LEVEL) setup_logging(settings.LOG_LEVEL)
from app.database import engine, Base, SessionLocal 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.auth import get_password_hash
from app.utils.scheduler import start_scheduler, stop_scheduler from app.utils.scheduler import start_scheduler, stop_scheduler
def seed_admin(): 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.user import User
from app.models.email_verification import EmailVerification from app.models.email_verification import EmailVerification
from app.models.password_reset import PasswordReset
log = logging.getLogger(__name__)
db = SessionLocal() db = SessionLocal()
try: try:
admin_exists = db.query(User).filter(User.role == "admin").first() admin_exists = db.query(User).filter(User.role == "admin").first()
if not admin_exists: 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( admin_user = User(
email="admin@quizapp.com", email=settings.DEFAULT_ADMIN_EMAIL.lower().strip(),
hashed_password=get_password_hash("admin123"), hashed_password=get_password_hash(settings.DEFAULT_ADMIN_PASSWORD),
name="Admin", name="Admin",
role="admin", role="admin",
) )
db.add(admin_user) db.add(admin_user)
db.flush() db.flush()
# Auto-verify seeded admin # Auto-verify seeded admin
from datetime import datetime
db.add(EmailVerification( db.add(EmailVerification(
user_id=admin_user.id, user_id=admin_user.id,
token="seeded", token="seeded",
@ -44,8 +53,6 @@ def seed_admin():
db.commit() db.commit()
else: else:
# Ensure existing admin has a verified email record # 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() existing_v = db.query(EmailVerification).filter(EmailVerification.user_id == admin_exists.id).first()
if not existing_v: if not existing_v:
db.add(EmailVerification( db.add(EmailVerification(
@ -82,35 +89,22 @@ def seed_default_models():
db.delete(titan) db.delete(titan)
db.commit() db.commit()
# Always ensure OpenAI TTS voice models exist (idempotent) # Always ensure LiteLLM-routed local speech models exist (idempotent).
tts_voices = [ tts_voices = [
# OpenAI (work with OPENAI_API_KEY) ("Kokoro TTS (LiteLLM)", "local-kokoro-tts", True),
("OpenAI Alloy", "tts-1:alloy", True), ("Qwen3 TTS (LiteLLM)", "local-qwen3-tts", False),
("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),
] ]
# Deactivate old generic tts-1 / tts-1-hd entries (no voice encoded) stt_models = [
for old_id in ("tts-1", "tts-1-hd"): ("Parakeet STT (LiteLLM)", "local-parakeet-v3", True),
old = db.query(AIModelConfig).filter(AIModelConfig.model_id == old_id).first() ("Groq Whisper Turbo", "groq-whisper-large-v3-turbo", False),
if old: ("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_active = False
old.is_default = 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)) db.add(AIModelConfig(name=name, model_id=model_id, task="tts", is_active=True, is_default=is_def))
if is_def: if is_def:
has_default_tts = True 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() db.commit()
finally: finally:
db.close() 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(tags.router, prefix="/api/tags", tags=["tags"])
app.include_router(flashcards.router, prefix="/api/flashcards", tags=["flashcards"]) app.include_router(flashcards.router, prefix="/api/flashcards", tags=["flashcards"])
app.include_router(courses.router, prefix="/api/courses", tags=["courses"]) app.include_router(courses.router, prefix="/api/courses", tags=["courses"])
app.include_router(mobile.router, prefix="/api/mobile", tags=["mobile"])
@app.get("/api/health") @app.get("/api/health")

View file

@ -187,8 +187,9 @@ def create_model(
db: Session = Depends(get_db), db: Session = Depends(get_db),
admin: User = Depends(require_admin), admin: User = Depends(require_admin),
): ):
if data.task not in ("extraction", "tts", "teach", "keyword", "flashcard"): valid_tasks = ("extraction", "tts", "stt", "teach", "keyword", "flashcard")
raise HTTPException(status_code=400, detail="Task must be extraction, tts, teach, or keyword") 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: if data.is_default:
db.query(AIModelConfig).filter( db.query(AIModelConfig).filter(
@ -276,6 +277,27 @@ def test_model(
if model.task == "tts": if model.task == "tts":
raise HTTPException(status_code=400, detail="Use the Preview button to test TTS voices — it plays audio directly.") 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: try:
import litellm import litellm
from app.services.ai_service import _proxy_model from app.services.ai_service import _proxy_model
@ -309,85 +331,42 @@ def search_tts_voices(
data: TTSVoiceSearchRequest, data: TTSVoiceSearchRequest,
admin: User = Depends(require_admin), 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 import logging
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
provider = data.provider provider = data.provider
api_key = data.api_key api_key = data.api_key
region = data.region region = data.region
if provider == "elevenlabs": if provider != "litellm":
key = api_key or settings.ELEVENLABS_API_KEY raise HTTPException(status_code=400, detail="TTS discovery is routed through LiteLLM only")
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":
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: try:
resp = httpx.get( headers = {"Authorization": f"Bearer {key}"} if key else {}
"https://api.elevenlabs.io/v1/voices", resp = httpx.get(f"{base}/model/info", headers=headers, timeout=10)
headers={"xi-api-key": key},
timeout=15,
)
resp.raise_for_status() resp.raise_for_status()
voices = resp.json().get("voices", []) models = resp.json().get("data", [])
return {"voices": [ return {"voices": [
{ {
"model_id": f"elevenlabs/{v['voice_id']}", "model_id": m.get("model_name"),
"name": v["name"], "name": m.get("model_name"),
"labels": v.get("labels", {}), "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: except HTTPException:
raise raise
except Exception as e: except Exception as e:
log.warning(f"ElevenLabs voice discovery failed: {e}") log.warning(f"LiteLLM local TTS discovery failed: {e}")
raise HTTPException(status_code=400, detail=f"ElevenLabs API error: {e}") raise HTTPException(status_code=400, detail=f"LiteLLM TTS discovery error: {e}")
elif provider == "polly": raise HTTPException(status_code=400, detail=f"Unknown provider '{provider}'. Valid: litellm")
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")
# --- System Settings --- # --- System Settings ---

View file

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

View file

@ -18,6 +18,8 @@ router = APIRouter()
# ── Schemas ────────────────────────────────────────────────────────── # ── Schemas ──────────────────────────────────────────────────────────
class FlashcardDeckCreate(BaseModel): class FlashcardDeckCreate(BaseModel):
model_config = {"protected_namespaces": ()}
section_id: int section_id: int
title: str title: str
model_id: str | None = None 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): class ChatRequest(BaseModel):
model_config = {"protected_namespaces": ()}
question_id: int question_id: int
messages: list[ChatMessage] messages: list[ChatMessage]
model_id: int | None = None # AIModelConfig.id — if None, use default 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 fastapi.responses import Response
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -18,6 +18,20 @@ class TTSRequest(BaseModel):
voice: str | None = None # model_id override 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: def _polly_enabled() -> bool:
try: try:
import redis as redis_lib import redis as redis_lib
@ -28,15 +42,28 @@ def _polly_enabled() -> bool:
return True 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") @router.get("/voices")
def get_voices( def get_voices(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), 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( query = db.query(AIModelConfig).filter(
AIModelConfig.task == "tts", AIModelConfig.task == "tts",
AIModelConfig.is_active == True, AIModelConfig.is_active == True,
AIModelConfig.model_id.like("local-%"),
) )
if not _polly_enabled(): if not _polly_enabled():
query = query.filter(~AIModelConfig.model_id.like("polly/%")) 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) # Rate limit: 60 TTS requests per user per hour (admins/unthrottled users exempt)
check_rate_limit( check_rate_limit(
key=f"tts_speak:{current_user.id}", key=f"tts_speak:{current_user.id}",
max_calls=60, max_calls=240,
window_seconds=3600, 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, user=current_user,
) )
if not request.text.strip(): if not request.text.strip():
@ -64,23 +91,50 @@ def text_to_speech(
text = request.text[:2000] text = request.text[:2000]
if request.voice: if request.voice and request.voice.startswith("local-"):
if request.voice.startswith("polly/") and not _polly_enabled(): if request.voice.startswith("polly/") and not _polly_enabled():
raise HTTPException(status_code=400, detail="AWS Polly is currently disabled") raise HTTPException(status_code=400, detail="AWS Polly is currently disabled")
config = db.query(AIModelConfig).filter(AIModelConfig.model_id == request.voice).first() config = db.query(AIModelConfig).filter(AIModelConfig.model_id == request.voice).first()
model_id = config.model_id if config else request.voice model_id = config.model_id if config else request.voice
api_key = config.api_key if (config and config.api_key) else None api_key = config.api_key if (config and config.api_key) else None
else: else:
config = db.query(AIModelConfig).filter( model_id, api_key = _default_tts_model(db)
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
audio = ai_service.generate_tts_audio(text, model_id=model_id, api_key=api_key) audio = ai_service.generate_tts_audio(text, model_id=model_id, api_key=api_key)
if audio is None: if audio is None:
raise HTTPException(status_code=500, detail="TTS generation failed. Check model configuration.") raise HTTPException(status_code=500, detail="TTS generation failed. Check model configuration.")
return Response(content=audio, media_type="audio/mpeg") 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": ()} model_config = {"protected_namespaces": ()}
name: str name: str
model_id: str model_id: str
task: str # extraction, tts, general task: str # extraction, tts, stt, teach, keyword, flashcard
api_key: str | None = None api_key: str | None = None
is_active: bool = True is_active: bool = True
is_default: bool = False is_default: bool = False

View file

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

View file

@ -4,6 +4,8 @@ from pydantic import BaseModel
class QuizCreate(BaseModel): class QuizCreate(BaseModel):
model_config = {"protected_namespaces": ()}
section_id: int | None = None section_id: int | None = None
title: str title: str
mode: str = "timed" # timed, learning 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 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( def extract_questions_no_answers(
content: str, content: str,
page_info: str = "unknown", page_info: str = "unknown",
@ -330,7 +380,7 @@ def generate_tts_audio(
model_id: str | None = None, model_id: str | None = None,
api_key: str | None = None, api_key: str | None = None,
) -> bytes | 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: model_id conventions:
tts-1:alloy OpenAI TTS (voice after colon) tts-1:alloy OpenAI TTS (voice after colon)
@ -338,11 +388,52 @@ def generate_tts_audio(
elevenlabs/<voice> ElevenLabs elevenlabs/<voice> ElevenLabs
google/<voice_name> Google Cloud TTS (e.g. google/en-US-Wavenet-D) google/<voice_name> Google Cloud TTS (e.g. google/en-US-Wavenet-D)
polly/<VoiceId> AWS Polly Neural (e.g. polly/Joanna) 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 import httpx, base64
use_model = model_id or "tts-1:alloy" 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 ───────────────────────────────────────────── # ── ElevenLabs ─────────────────────────────────────────────
if use_model.startswith("elevenlabs/") or use_model.startswith("eleven_labs/"): if use_model.startswith("elevenlabs/") or use_model.startswith("eleven_labs/"):
voice = use_model.split("/", 1)[1] 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.result_serializer = "json"
celery_app.conf.accept_content = ["json"] celery_app.conf.accept_content = ["json"]
celery_app.conf.worker_hijack_root_logger = False # Don't override our JSON logging 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: except Exception:
pass pass
finally: finally:
db.close() db.close()

View file

@ -1,5 +1,3 @@
version: "3.8"
services: services:
postgres: postgres:
image: pgvector/pgvector:pg16 image: pgvector/pgvector:pg16
@ -37,6 +35,9 @@ services:
volumes: volumes:
- uploads_data:/app/uploads - uploads_data:/app/uploads
- chroma_data:/app/chroma_data - chroma_data:/app/chroma_data
networks:
- default
- speech_net
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@ -132,3 +133,7 @@ volumes:
loki_data: loki_data:
grafana_data: grafana_data:
promtail_positions: 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 WORKDIR /app
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./

File diff suppressed because it is too large Load diff

View file

@ -6,33 +6,39 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest run"
}, },
"dependencies": { "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", "axios": "^1.6.7",
"katex": "^0.16.11",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^6.22.0", "react-router-dom": "^6.22.0",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"@milkdown/core": "^7.6.2", "remark-math": "^6.0.0"
"@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"
}, },
"devDependencies": { "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": "^18.2.55",
"@types/react-dom": "^18.2.19", "@types/react-dom": "^18.2.19",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^6.0.1",
"vite": "^5.1.0" "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 { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext' import { AuthProvider, useAuth } from './context/AuthContext'
import { ThemeProvider } from './context/ThemeContext' import { ThemeProvider } from './context/ThemeContext'
import Navbar from './components/Navbar' import Navbar from './components/Navbar'
import LoginPage from './pages/LoginPage'
import RegisterPage from './pages/RegisterPage' const LoginPage = lazy(() => import('./pages/LoginPage'))
import DashboardPage from './pages/DashboardPage' const RegisterPage = lazy(() => import('./pages/RegisterPage'))
import UploadPage from './pages/UploadPage' const DashboardPage = lazy(() => import('./pages/DashboardPage'))
import DocumentDetailPage from './pages/DocumentDetailPage' const UploadPage = lazy(() => import('./pages/UploadPage'))
import QuizPage from './pages/QuizPage' const DocumentDetailPage = lazy(() => import('./pages/DocumentDetailPage'))
import QuizzesPage from './pages/QuizzesPage' const QuizPage = lazy(() => import('./pages/QuizPage'))
import ResultsPage from './pages/ResultsPage' const QuizzesPage = lazy(() => import('./pages/QuizzesPage'))
import AdminPage from './pages/AdminPage' const ResultsPage = lazy(() => import('./pages/ResultsPage'))
import AccountPage from './pages/AccountPage' const AdminPage = lazy(() => import('./pages/AdminPage'))
import SettingsPage from './pages/SettingsPage' const AccountPage = lazy(() => import('./pages/AccountPage'))
import QuestionBankPage from './pages/QuestionBankPage' const SettingsPage = lazy(() => import('./pages/SettingsPage'))
import JobsPage from './pages/JobsPage' const QuestionBankPage = lazy(() => import('./pages/QuestionBankPage'))
import TrashPage from './pages/TrashPage' const JobsPage = lazy(() => import('./pages/JobsPage'))
import QuizEditPage from './pages/QuizEditPage' const TrashPage = lazy(() => import('./pages/TrashPage'))
import VerifyEmailPage from './pages/VerifyEmailPage' const QuizEditPage = lazy(() => import('./pages/QuizEditPage'))
import ForgotPasswordPage from './pages/ForgotPasswordPage' const VerifyEmailPage = lazy(() => import('./pages/VerifyEmailPage'))
import ResetPasswordPage from './pages/ResetPasswordPage' const ForgotPasswordPage = lazy(() => import('./pages/ForgotPasswordPage'))
import NotFoundPage from './pages/NotFoundPage' const ResetPasswordPage = lazy(() => import('./pages/ResetPasswordPage'))
import LandingPage from './pages/LandingPage' const NotFoundPage = lazy(() => import('./pages/NotFoundPage'))
import FlashcardsPage from './pages/FlashcardsPage' const LandingPage = lazy(() => import('./pages/LandingPage'))
import FlashcardStudyPage from './pages/FlashcardStudyPage' const FlashcardsPage = lazy(() => import('./pages/FlashcardsPage'))
import CoursesPage from './pages/CoursesPage' const FlashcardStudyPage = lazy(() => import('./pages/FlashcardStudyPage'))
import CourseDetailPage from './pages/CourseDetailPage' const CoursesPage = lazy(() => import('./pages/CoursesPage'))
import CourseEditorPage from './pages/CourseEditorPage' const CourseDetailPage = lazy(() => import('./pages/CourseDetailPage'))
import SsoCallbackPage from './pages/SsoCallbackPage' 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) // Layout wrapper for authenticated app pages (Navbar + container + footer)
function AppLayout() { function AppLayout() {
@ -47,7 +53,7 @@ function AppLayout() {
// Guard: redirect to /home if not logged in, or to / if not moderator // Guard: redirect to /home if not logged in, or to / if not moderator
function RequireAuth({ moderator = false }) { function RequireAuth({ moderator = false }) {
const { user, loading } = useAuth() 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 (!user) return <Navigate to="/home" replace />
if (moderator && user.role !== 'admin' && user.role !== 'moderator') return <Navigate to="/" replace /> if (moderator && user.role !== 'admin' && user.role !== 'moderator') return <Navigate to="/" replace />
return <Outlet /> return <Outlet />
@ -55,52 +61,54 @@ function RequireAuth({ moderator = false }) {
function AppRoutes() { function AppRoutes() {
const { user, loading } = useAuth() const { user, loading } = useAuth()
if (loading) return <div className="loading"><div className="spinner" /></div> if (loading) return <LoadingFallback />
return ( return (
<Routes> <Suspense fallback={<LoadingFallback />}>
{/* Always public */} <Routes>
<Route path="/home" element={<LandingPage />} /> {/* Always public */}
<Route path="/login" element={user ? <Navigate to="/" replace /> : <LoginPage />} /> <Route path="/home" element={<LandingPage />} />
<Route path="/register" element={user ? <Navigate to="/" replace /> : <RegisterPage />} /> <Route path="/login" element={user ? <Navigate to="/" replace /> : <LoginPage />} />
<Route path="/verify-email" element={<VerifyEmailPage />} /> <Route path="/register" element={user ? <Navigate to="/" replace /> : <RegisterPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} /> <Route path="/verify-email" element={<VerifyEmailPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} /> <Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/sso-callback" element={<SsoCallbackPage />} /> <Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/sso-callback" element={<SsoCallbackPage />} />
{/* Authenticated app — wrapped in AppLayout */} {/* Authenticated app — wrapped in AppLayout */}
<Route element={<RequireAuth />}> <Route element={<RequireAuth />}>
<Route element={<AppLayout />}> <Route element={<AppLayout />}>
<Route path="/" element={<DashboardPage />} /> <Route path="/" element={<DashboardPage />} />
<Route path="/quizzes" element={<QuizzesPage />} /> <Route path="/quizzes" element={<QuizzesPage />} />
<Route path="/quizzes/:id" element={<QuizPage />} /> <Route path="/quizzes/:id" element={<QuizPage />} />
<Route path="/results/:id" element={<ResultsPage />} /> <Route path="/results/:id" element={<ResultsPage />} />
<Route path="/documents/:id" element={<DocumentDetailPage />} /> <Route path="/documents/:id" element={<DocumentDetailPage />} />
<Route path="/account" element={<AccountPage />} /> <Route path="/account" element={<AccountPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="/question-bank" element={<QuestionBankPage />} /> <Route path="/question-bank" element={<QuestionBankPage />} />
<Route path="/flashcards" element={<FlashcardsPage />} /> <Route path="/flashcards" element={<FlashcardsPage />} />
<Route path="/flashcards/:deckId/study" element={<FlashcardStudyPage />} /> <Route path="/flashcards/:deckId/study" element={<FlashcardStudyPage />} />
<Route path="/courses" element={<CoursesPage />} /> <Route path="/courses" element={<CoursesPage />} />
<Route path="/courses/:courseId" element={<CourseDetailPage />} /> <Route path="/courses/:courseId" element={<CourseDetailPage />} />
<Route path="/courses/:courseId/edit" element={<CourseEditorPage />} /> <Route path="/courses/:courseId/edit" element={<CourseEditorPage />} />
<Route path="/admin" element={<AdminPage />} /> <Route path="/admin" element={<AdminPage />} />
</Route>
</Route> </Route>
</Route>
{/* Moderator-only */} {/* Moderator-only */}
<Route element={<RequireAuth moderator />}> <Route element={<RequireAuth moderator />}>
<Route element={<AppLayout />}> <Route element={<AppLayout />}>
<Route path="/upload" element={<UploadPage />} /> <Route path="/upload" element={<UploadPage />} />
<Route path="/quizzes/:id/edit" element={<QuizEditPage />} /> <Route path="/quizzes/:id/edit" element={<QuizEditPage />} />
<Route path="/jobs" element={<JobsPage />} /> <Route path="/jobs" element={<JobsPage />} />
<Route path="/trash" element={<TrashPage />} /> <Route path="/trash" element={<TrashPage />} />
</Route>
</Route> </Route>
</Route>
{/* Catch-all */} {/* Catch-all */}
<Route path="*" element={user ? <NotFoundPage /> : <Navigate to="/home" replace />} /> <Route path="*" element={user ? <NotFoundPage /> : <Navigate to="/home" replace />} />
</Routes> </Routes>
</Suspense>
) )
} }

View file

@ -5,7 +5,7 @@ import api from '../api/client'
import Dialog from '../components/Dialog' import Dialog from '../components/Dialog'
import { useDialog } from '../hooks/useDialog' import { useDialog } from '../hooks/useDialog'
const TASKS = ['extraction', 'tts', 'teach', 'keyword', 'flashcard'] const TASKS = ['extraction', 'tts', 'stt', 'teach', 'keyword', 'flashcard']
export default function AdminPage() { export default function AdminPage() {
const { user } = useAuth() const { user } = useAuth()
@ -19,12 +19,10 @@ export default function AdminPage() {
const [error, setError] = useState('') const [error, setError] = useState('')
const [success, setSuccess] = 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: '' }) const [newUser, setNewUser] = useState({ name: '', email: '', password: '' })
// LiteLLM search state // LiteLLM search state
const [searchApiKey, setSearchApiKey] = useState('')
const [searchApiBase, setSearchApiBase] = useState('')
const [searchResults, setSearchResults] = useState([]) const [searchResults, setSearchResults] = useState([])
const [searchLoading, setSearchLoading] = useState(false) const [searchLoading, setSearchLoading] = useState(false)
const [searchError, setSearchError] = useState('') const [searchError, setSearchError] = useState('')
@ -32,8 +30,7 @@ export default function AdminPage() {
const [searchTaskHint, setSearchTaskHint] = useState('extraction') const [searchTaskHint, setSearchTaskHint] = useState('extraction')
// TTS voice discovery state // TTS voice discovery state
const [ttsProvider, setTtsProvider] = useState('elevenlabs') const [ttsProvider, setTtsProvider] = useState('litellm')
const [ttsSearchKey, setTtsSearchKey] = useState('')
const [ttsVoices, setTtsVoices] = useState([]) const [ttsVoices, setTtsVoices] = useState([])
const [ttsVoicesLoading, setTtsVoicesLoading] = useState(false) const [ttsVoicesLoading, setTtsVoicesLoading] = useState(false)
const [ttsVoicesError, setTtsVoicesError] = useState('') const [ttsVoicesError, setTtsVoicesError] = useState('')
@ -115,7 +112,7 @@ export default function AdminPage() {
try { try {
await api.post('/admin/models', newModel) await api.post('/admin/models', newModel)
setSuccess('Model added') 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) loadData(false)
} catch (err) { } catch (err) {
setError(err.response?.data?.detail || 'Failed to add model') setError(err.response?.data?.detail || 'Failed to add model')
@ -173,10 +170,7 @@ export default function AdminPage() {
setSearchResults([]) setSearchResults([])
setSearchLoading(true) setSearchLoading(true)
try { try {
const res = await api.post('/admin/litellm/models', { const res = await api.post('/admin/litellm/models', {})
api_key: searchApiKey || null,
api_base: searchApiBase || null,
})
setSearchResults(res.data.models) setSearchResults(res.data.models)
} catch (err) { } catch (err) {
setSearchError(err.response?.data?.detail || 'Failed to query models') setSearchError(err.response?.data?.detail || 'Failed to query models')
@ -255,7 +249,6 @@ export default function AdminPage() {
try { try {
const res = await api.post('/admin/tts/voices', { const res = await api.post('/admin/tts/voices', {
provider: ttsProvider, provider: ttsProvider,
api_key: ttsSearchKey || null,
}) })
setTtsVoices(res.data.voices) setTtsVoices(res.data.voices)
} catch (err) { } catch (err) {
@ -325,27 +318,9 @@ export default function AdminPage() {
<div className="card"> <div className="card">
<h2>Search LLM Models</h2> <h2>Search LLM Models</h2>
<p style={{ color: '#64748b', fontSize: '0.85rem', marginBottom: 16 }}> <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> </p>
<div className="grid-2"> <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"> <div className="form-group">
<label>Adding model for task</label> <label>Adding model for task</label>
<select value={searchTaskHint} onChange={e => setSearchTaskHint(e.target.value)}> <select value={searchTaskHint} onChange={e => setSearchTaskHint(e.target.value)}>
@ -403,9 +378,11 @@ export default function AdminPage() {
{task} Models {task} Models
<span style={{ fontSize: '0.75rem', color: '#64748b', marginLeft: 8, fontWeight: 400 }}> <span style={{ fontSize: '0.75rem', color: '#64748b', marginLeft: 8, fontWeight: 400 }}>
{task === 'extraction' ? '— AI that extracts questions from PDFs' : {task === 'extraction' ? '— AI that extracts questions from PDFs' :
task === 'tts' ? '— Text-to-speech voices' : task === 'tts' ? '— Text-to-speech voices' :
task === 'teach' ? '— AI tutor shown in study mode chat' : task === 'stt' ? '— Speech-to-text models' :
'— General purpose AI'} task === 'teach' ? '— AI tutor shown in study mode chat' :
task === 'keyword' ? '— Keyword and topic classification' :
'— Flashcard generation'}
</span> </span>
</h2> </h2>
@ -416,13 +393,8 @@ export default function AdminPage() {
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 8 }}> <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 8 }}>
<select value={ttsProvider} onChange={e => { setTtsProvider(e.target.value); setTtsVoices([]) }} <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)' }}> 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="litellm">LiteLLM Local</option>
<option value="polly">AWS Polly (Neural)</option>
<option value="openai">OpenAI TTS</option>
</select> </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}> <button className="btn btn-primary btn-sm" onClick={searchTtsVoices} disabled={ttsVoicesLoading}>
{ttsVoicesLoading ? 'Searching...' : 'Search'} {ttsVoicesLoading ? 'Searching...' : 'Search'}
</button> </button>
@ -546,10 +518,6 @@ export default function AdminPage() {
<label>Model ID (LiteLLM format)</label> <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 /> <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>
<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 }}> <div className="form-group" style={{ display: 'flex', gap: 16, alignItems: 'center', marginTop: 24 }}>
<label style={{ display: 'flex', gap: 6, alignItems: 'center' }}> <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 }))} /> <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 [importFile, setImportFile] = useState(null)
const [importing, setImporting] = useState(false) const [importing, setImporting] = useState(false)
const [importResult, setImportResult] = useState(null) const [importResult, setImportResult] = useState(null)
const [qtiImporting, setQtiImporting] = useState(false)
const [qtiExporting, setQtiExporting] = useState(false)
const debounceRef = useRef(null) const debounceRef = useRef(null)
const classifyPollRef = useRef(null) const classifyPollRef = useRef(null)
const { user } = useAuth() 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 toggleFavorite = async (questionId) => {
const isFavorited = favorites.includes(questionId) const isFavorited = favorites.includes(questionId)
try { 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={() => 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={() => { setShowImport(true); setImportFile(null); setImportResult(null) }}>Import CSV/Excel</button>
<button className="btn btn-secondary btn-sm" onClick={async () => { <button className="btn btn-secondary btn-sm" onClick={handleQtiImport} disabled={qtiImporting}>
const input = document.createElement('input'); input.type = 'file'; input.accept = '.xml' {qtiImporting ? 'Importing QTI...' : 'Import QTI'}
input.onchange = async (e) => { </button>
const f = e.target.files[0]; if (!f) return <button className="btn btn-secondary btn-sm" onClick={handleQtiExport} disabled={qtiExporting}>
const fd = new FormData(); fd.append('file', f) {qtiExporting ? 'Exporting QTI...' : `Export QTI${selectedIds.size > 0 ? ` (${selectedIds.size})` : ''}`}
try { </button>
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>
{isModerator && <button className="btn btn-secondary btn-sm" onClick={() => setShowCatForm(v => !v)}>+ Category</button>} {isModerator && <button className="btn btn-secondary btn-sm" onClick={() => setShowCatForm(v => !v)}>+ Category</button>}
</div> </div>
</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')) 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 [state, setState] = useState('idle') // idle | loading | playing
const audioRef = useRef(null) const audioRef = useRef(null)
// Stop audio when question changes (component unmounts)
useEffect(() => { useEffect(() => {
return () => { return () => {
audioRef.current?.pause() audioRef.current?.pause()
onActiveChange?.(false) onActiveChange?.(false)
onSegmentChange?.(null)
} }
}, []) }, [])
useEffect(() => {
if (autoPlay && state === 'idle') speak()
}, [autoPlay])
const setStateAndNotify = (s) => { const setStateAndNotify = (s) => {
setState(s) setState(s)
onActiveChange?.(s !== 'idle') onActiveChange?.(s !== 'idle')
if (s === 'idle') onSegmentChange?.(null)
} }
const speak = async () => { const speak = async () => {
@ -31,14 +112,29 @@ function TTSButton({ text, voice, onActiveChange }) {
if (state === 'loading') return if (state === 'loading') return
try { try {
setStateAndNotify('loading') setStateAndNotify('loading')
const res = await api.post('/tts/speak', { text, voice: voice || null }, { responseType: 'blob' }) let url
const url = URL.createObjectURL(res.data) 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) const audio = new Audio(url)
audioRef.current = audio audioRef.current = audio
audio.onended = () => { setStateAndNotify('idle'); URL.revokeObjectURL(url) } const updateSegment = () => {
audio.onerror = () => setStateAndNotify('idle') const activeIndex = getActiveSpeechSegment(audio, segments)
audio.play() 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') setStateAndNotify('playing')
updateSegment()
} catch { setStateAndNotify('idle') } } catch { setStateAndNotify('idle') }
} }
@ -99,6 +195,21 @@ function CourseQuizStart({ quiz, onStart }) {
function ModeSelectScreen({ quiz, voices, onStart }) { function ModeSelectScreen({ quiz, voices, onStart }) {
const [selectedVoice, setSelectedVoice] = useState(voices.find(v => v.is_default)?.id || voices[0]?.id || '') const [selectedVoice, setSelectedVoice] = useState(voices.find(v => v.is_default)?.id || voices[0]?.id || '')
const [customTimer, setCustomTimer] = useState(quiz.time_limit_minutes || '') 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 ( return (
<div style={{ maxWidth: 520, margin: '40px auto' }}> <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: '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' }, { mode: 'exam', icon: '🎯', label: 'Exam Mode', desc: 'Answers hidden until submitted', color: '#3b82f6', bg: '#eff6ff' },
].map(({ mode, icon, label, desc, color, bg }) => ( ].map(({ mode, icon, label, desc, color, bg }) => (
<div key={mode} onClick={() => onStart(mode, selectedVoice, mode === 'exam' && customTimer ? parseInt(customTimer) : null)} <div key={mode} onClick={() => !startingMode && handleStart(mode)}
style={{ flex: 1, border: `2px solid ${color}`, borderRadius: 12, padding: '18px 12px', cursor: 'pointer', background: bg, transition: 'transform 0.1s' }} 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)'} onMouseEnter={e => e.currentTarget.style.transform = 'scale(1.03)'}
onMouseLeave={e => e.currentTarget.style.transform = 'none'} onMouseLeave={e => e.currentTarget.style.transform = 'none'}
> >
<div style={{ fontSize: '1.8rem', marginBottom: 6 }}>{icon}</div> <div style={{ fontSize: '1.8rem', marginBottom: 6 }}>{icon}</div>
<div style={{ fontWeight: 700, color, marginBottom: 4 }}>{label}</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>
))} ))}
</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 }}> <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> <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 [voices, setVoices] = useState([])
const [selectedVoice, setSelectedVoice] = useState('') const [selectedVoice, setSelectedVoice] = useState('')
const [ttsActive, setTtsActive] = useState(false) const [ttsActive, setTtsActive] = useState(false)
const [readThrough, setReadThrough] = useState(false)
const [quizMode, setQuizMode] = useState(null) const [quizMode, setQuizMode] = useState(null)
const [answers, setAnswers] = useState({}) const [answers, setAnswers] = useState({})
const [currentIdx, setCurrentIdx] = useState(0) const [currentIdx, setCurrentIdx] = useState(0)
@ -178,9 +295,12 @@ export default function QuizPage() {
const [navOpen, setNavOpen] = useState(false) const [navOpen, setNavOpen] = useState(false)
const [startedAt, setStartedAt] = useState(null) const [startedAt, setStartedAt] = useState(null)
const [favorites, setFavorites] = useState([]) const [favorites, setFavorites] = useState([])
const [activeReadSegment, setActiveReadSegment] = useState(null)
const timerRef = useRef(null) const timerRef = useRef(null)
const toastRef = useRef(null) const toastRef = useRef(null)
const hasStarted = useRef(false) const hasStarted = useRef(false)
const ttsCacheRef = useRef(new Map())
const autoAdvanceRef = useRef(null)
const showToast = (msg) => { const showToast = (msg) => {
setToast(msg) setToast(msg)
@ -189,14 +309,64 @@ export default function QuizPage() {
} }
const [leaveTarget, setLeaveTarget] = useState(null) 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 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 savedIdx = saved.current_idx ?? saved.currentIdx ?? 0
const savedAnswers = saved.answers || {} const savedAnswers = saved.answers || {}
const aid = saved.attempt_id || saved.attemptId || '' 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') { if (mode === 'study') {
try { try {
const quizRes = await api.get(`/quizzes/${id}?study=true${aid ? `&attempt_id=${aid}` : ''}`) 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 // Fetch quiz with attempt_id for question pool filtering
let quizData = quiz 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}`) const quizRes = await api.get(`/quizzes/${id}?attempt_id=${aid}${studyParam}`)
quizData = quizRes.data quizData = quizRes.data
setQuiz(quizData) setQuiz(quizData)
@ -350,7 +520,10 @@ const timerStarted = timeLeft !== null
const setAnswer = (questionId, value) => setAnswers(prev => ({ ...prev, [questionId]: value })) 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) => { const handleSubmit = useCallback(async (autoSubmit = false) => {
if (!attemptId || submitting) return if (!attemptId || submitting) return
@ -384,7 +557,7 @@ const timerStarted = timeLeft !== null
if (!quizMode) return ( if (!quizMode) return (
<div> <div>
{isModerator && ( {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> <Link to={`/quizzes/${id}/edit`} className="btn btn-secondary btn-sm"> Edit Questions</Link>
</div> </div>
)} )}
@ -401,12 +574,10 @@ const timerStarted = timeLeft !== null
</div> </div>
) )
const isStudy = quizMode === 'study'
const questions = quiz.questions || []
const current = questions[currentIdx]
const answeredCount = Object.keys(answers).length const answeredCount = Object.keys(answers).length
const totalCount = questions.length const totalCount = questions.length
const isLast = currentIdx === totalCount - 1 const isLast = currentIdx === totalCount - 1
const activeReadForCurrent = current && activeReadSegment?.questionId === current.id
const toggleFavorite = async (questionId) => { const toggleFavorite = async (questionId) => {
const isFavorited = favorites.includes(questionId) const isFavorited = favorites.includes(questionId)
@ -505,7 +676,8 @@ const timerStarted = timeLeft !== null
<h2 style={{ marginBottom: 2, fontSize: '1rem' }}>{quiz.title}</h2> <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' }}> <div style={{ fontSize: '0.82rem', color: 'var(--text-muted)', display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ <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, padding: '1px 8px', borderRadius: 12, fontWeight: 600,
}}> }}>
{isStudy ? '📖 Study' : '🎯 Exam'} {isStudy ? '📖 Study' : '🎯 Exam'}
@ -543,9 +715,19 @@ const timerStarted = timeLeft !== null
{/* Main content */} {/* Main content */}
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
{current && ( {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 }}> <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 <button
onClick={() => toggleFavorite(current.id)} onClick={() => toggleFavorite(current.id)}
title={favorites.includes(current.id) ? 'Remove from favorites' : 'Add to favorites'} title={favorites.includes(current.id) ? 'Remove from favorites' : 'Add to favorites'}
@ -564,16 +746,44 @@ const timerStarted = timeLeft !== null
{favorites.includes(current.id) ? '⭐' : '☆'} {favorites.includes(current.id) ? '⭐' : '☆'}
</button> </button>
</div> </div>
{voices.length > 0 && ( <div style={{ marginBottom: 10, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<div style={{ marginBottom: 10 }}> {voices.length > 0 && (
<TTSButton <TTSButton
key={`${current.id}_${answers[current.id] || ''}`} key={`${current.id}_${selectedVoice || 'default'}`}
text={`Question ${currentIdx + 1}. ${current.question_text.replace('[IMAGE]', '')}. Options: ${(current.options || []).join(', ')}`} text={buildQuestionSpeechText(current, currentIdx)}
voice={selectedVoice} 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} 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' }}> <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'} {current.question_type === 'mcq' ? 'Multiple Choice' : current.question_type === 'true_false' ? 'True / False' : 'Fill in the Blank'}
</span> </span>
@ -593,13 +803,23 @@ const timerStarted = timeLeft !== null
const showCorrect = hasAnswered && isCorrectOpt const showCorrect = hasAnswered && isCorrectOpt
const showWrong = hasAnswered && isSelected && !isCorrectOpt const showWrong = hasAnswered && isSelected && !isCorrectOpt
const letter = String.fromCharCode(65 + i) const letter = String.fromCharCode(65 + i)
const activeOptionChunk = activeReadForCurrent && activeReadSegment.type === 'option' && activeReadSegment.index === i
? activeReadSegment.chunkIndex
: null
return ( return (
<div key={i} <div key={i}
className={`option ${isSelected && !hasAnswered ? 'selected' : ''} ${showCorrect ? 'correct' : ''} ${showWrong ? 'incorrect' : ''}`} className={`option ${isSelected && !hasAnswered ? 'selected' : ''} ${showCorrect ? 'correct' : ''} ${showWrong ? 'incorrect' : ''}`}
onClick={() => !hasAnswered && setAnswer(current.id, opt)} 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 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>} {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>} {showWrong && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--wrong-fg)' }}> Wrong</span>}
</div> </div>
@ -646,7 +866,7 @@ const timerStarted = timeLeft !== null
{submitting ? 'Submitting...' : 'Submit Quiz'} {submitting ? 'Submitting...' : 'Submit Quiz'}
</button> </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> </div>

View file

@ -1,5 +1,5 @@
import { useState, useRef } from 'react' import { useState, useRef } from 'react'
import { useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import api from '../api/client' import api from '../api/client'
function NextcloudBrowser({ onFile }) { 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={{ padding: '20px', textAlign: 'center', color: 'var(--text-muted)', fontSize: '0.875rem' }}>
<div style={{ fontSize: '1.5rem', marginBottom: 8 }}></div> <div style={{ fontSize: '1.5rem', marginBottom: 8 }}></div>
No Nextcloud account configured.{' '} 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> </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: { proxy: {
'/api': 'http://localhost:8000' '/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"> <style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item> <item name="android:background">@drawable/splash</item>
</style> </style>
</resources> </resources>

View file

@ -2,4 +2,4 @@ include ':app'
include ':capacitor-cordova-android-plugins' include ':capacitor-cordova-android-plugins'
project(':capacitor-cordova-android-plugins').projectDir = new File('./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'