Remove coach modes and improve quiz audio
This commit is contained in:
parent
67a90f73f5
commit
d59c8bed6f
31 changed files with 4571 additions and 1474 deletions
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ router = APIRouter()
|
|||
# ── Schemas ──────────────────────────────────────────────────────────
|
||||
|
||||
class FlashcardDeckCreate(BaseModel):
|
||||
model_config = {"protected_namespaces": ()}
|
||||
|
||||
section_id: int
|
||||
title: str
|
||||
model_id: str | None = None
|
||||
|
|
|
|||
244
backend/app/routers/mobile.py
Normal file
244
backend/app/routers/mobile.py
Normal 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,
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
FROM node:18-alpine AS build
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
|
|
|
|||
4652
frontend/package-lock.json
generated
4652
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }))} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
101
frontend/src/pages/QuestionBankPage.test.jsx
Normal file
101
frontend/src/pages/QuestionBankPage.test.jsx
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
31
frontend/src/pages/UploadPage.test.jsx
Normal file
31
frontend/src/pages/UploadPage.test.jsx
Normal 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')
|
||||
})
|
||||
})
|
||||
7
frontend/src/test/setup.js
Normal file
7
frontend/src/test/setup.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import '@testing-library/jest-dom/vitest'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { afterEach } from 'vitest'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
|
@ -7,5 +7,9 @@ export default defineConfig({
|
|||
proxy: {
|
||||
'/api': 'http://localhost:8000'
|
||||
}
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test/setup.js'
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -20,4 +20,4 @@
|
|||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||
<item name="android:background">@drawable/splash</item>
|
||||
</style>
|
||||
</resources>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in a new issue