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
|
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`)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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 ---
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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):
|
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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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* ./
|
||||||
|
|
|
||||||
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": {
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 }))} />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
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'))
|
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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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: {
|
proxy: {
|
||||||
'/api': 'http://localhost:8000'
|
'/api': 'http://localhost:8000'
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: './src/test/setup.js'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue