Compare commits
16 commits
pre-ky-ope
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7f91e5e5b | ||
|
|
95de56d81b | ||
|
|
55713902ed | ||
|
|
9f97218f39 | ||
|
|
4b75edef4c | ||
|
|
e2c070b70b | ||
|
|
dd959371d1 | ||
|
|
d4ef94a117 | ||
|
|
17b0f06037 | ||
|
|
4f347a18a3 | ||
|
|
ffeb35922a | ||
|
|
dcab250223 | ||
|
|
a80effe9c6 | ||
|
|
9b691eb2fd | ||
|
|
fdebda993c | ||
|
|
d59c8bed6f |
62 changed files with 6592 additions and 1725 deletions
126
.forgejo/workflows/mobile-android.yml
Normal file
126
.forgejo/workflows/mobile-android.yml
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
name: Mobile Android Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_name:
|
||||
description: 'Forgejo release name'
|
||||
required: false
|
||||
default: 'Manual Android Build'
|
||||
release_tag:
|
||||
description: 'Forgejo release tag'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
jobs:
|
||||
android-release:
|
||||
runs-on: forgejo-local
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: https://github.com/actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Set up Java
|
||||
uses: https://github.com/actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
|
||||
- name: Set up Android SDK
|
||||
uses: https://github.com/android-actions/setup-android@v3
|
||||
|
||||
- name: Validate Android signing secrets
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: |
|
||||
test -n "$ANDROID_KEYSTORE_BASE64"
|
||||
test -n "$ANDROID_KEYSTORE_PASSWORD"
|
||||
test -n "$ANDROID_KEY_ALIAS"
|
||||
test -n "$ANDROID_KEY_PASSWORD"
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: frontend
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Install mobile dependencies
|
||||
working-directory: mobile
|
||||
run: npm ci
|
||||
|
||||
- name: Decode Android keystore
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
mkdir -p mobile/android/keystore
|
||||
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > mobile/android/keystore/release.jks
|
||||
|
||||
- name: Sync Capacitor Android
|
||||
working-directory: mobile
|
||||
run: npx cap sync android
|
||||
|
||||
- name: Build signed release APK
|
||||
working-directory: mobile/android
|
||||
env:
|
||||
ANDROID_KEYSTORE_FILE: ${{ github.workspace }}/mobile/android/keystore/release.jks
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
RELEASE_TAG: ${{ inputs.release_tag }}
|
||||
run: |
|
||||
chmod +x gradlew
|
||||
./gradlew assembleRelease
|
||||
tag="${RELEASE_TAG:-${GITHUB_REF_NAME:-manual}}"
|
||||
mkdir -p ../../dist
|
||||
cp app/build/outputs/apk/release/app-release.apk ../../dist/pedshub-${tag}.apk
|
||||
|
||||
- name: Create Forgejo release and upload APK
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_NAME: ${{ inputs.release_name }}
|
||||
RELEASE_TAG: ${{ inputs.release_tag }}
|
||||
run: |
|
||||
set -eu
|
||||
tag="${RELEASE_TAG:-${GITHUB_REF_NAME:-manual-${GITHUB_RUN_NUMBER}}}"
|
||||
apk="dist/pedshub-${tag}.apk"
|
||||
name="${RELEASE_NAME:-PedsHub Android ${tag}}"
|
||||
body="Automated signed Android APK build for ${tag}."
|
||||
api="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}"
|
||||
|
||||
release_json=$(curl -fsS "${api}/releases/tags/${tag}" \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" || true)
|
||||
if [ -z "$release_json" ]; then
|
||||
payload=$(python3 - <<PY
|
||||
import json
|
||||
print(json.dumps({
|
||||
"tag_name": "${tag}",
|
||||
"target_commitish": "${GITHUB_SHA}",
|
||||
"name": "${name}",
|
||||
"body": "${body}",
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
}))
|
||||
PY
|
||||
)
|
||||
release_json=$(curl -fsS -X POST "${api}/releases" \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$payload")
|
||||
fi
|
||||
|
||||
release_id=$(printf '%s' "$release_json" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
|
||||
curl -fsS -X POST "${api}/releases/${release_id}/assets?name=$(basename "$apk")" \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-H "Content-Type: application/vnd.android.package-archive" \
|
||||
--data-binary "@${apk}"
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -25,3 +25,4 @@ backend/.env.save
|
|||
|
||||
# Database backups
|
||||
backups/
|
||||
.firecrawl/
|
||||
|
|
|
|||
20
README.md
20
README.md
|
|
@ -10,7 +10,7 @@ AI-powered pediatric learning platform. Upload PDF study materials, automaticall
|
|||
- **AI Tutor (TeachChat)**: Ask follow-up questions mid-study — AI knows the current question, correct answer, and related content. Renders markdown tables, code, and follow-up suggestion chips.
|
||||
- **Tag Classification**: AI classifies questions with subjects, diseases, and keywords — filter your question bank by any combination of tags
|
||||
- **Multi-Category Filtering**: Filter questions by question category, tags, or quiz source — combine multiple filters for precise study sets
|
||||
- **Text-to-Speech**: OpenAI TTS, AWS Polly, ElevenLabs, Google Cloud — voice selection per quiz
|
||||
- **Text-to-Speech**: LiteLLM-routed local TTS, OpenAI TTS, ElevenLabs, Google Cloud — voice selection per quiz
|
||||
- **Semantic Search**: pgvector embeddings — finds questions by meaning, not just keywords
|
||||
- **Question Bank**: All questions searchable, filterable by category and tags, with inline study mode
|
||||
- **Image Validation**: AI `has_figure` gating — only links extracted images to questions the AI flagged as having a figure, preventing mismatched images
|
||||
|
|
@ -36,7 +36,7 @@ AI-powered pediatric learning platform. Upload PDF study materials, automaticall
|
|||
| AI/LLM | LiteLLM proxy (Claude, Gemini, GPT, Bedrock, and more) |
|
||||
| Embeddings | Configurable — any LiteLLM proxy model or direct AWS Bedrock (1024-dim) |
|
||||
| Document vectors | ChromaDB |
|
||||
| TTS | OpenAI (direct), AWS Polly, ElevenLabs, Google Cloud TTS |
|
||||
| TTS | LiteLLM-routed local TTS, OpenAI (direct), ElevenLabs, Google Cloud TTS |
|
||||
| Queue | Celery + Redis (4 fork workers) |
|
||||
| Email | SMTP (smtp2go or any SMTP server) |
|
||||
| Bot protection | Cloudflare Turnstile (runtime-configurable, no rebuild needed) |
|
||||
|
|
@ -46,7 +46,7 @@ For detailed architecture documentation, see [docs/architecture.md](docs/archite
|
|||
## Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/ifedan-ed/pdf-quiz-generator.git
|
||||
git clone ssh://git.danvics.com:2222/danvics/pdf-quiz-generator.git
|
||||
cd pdf-quiz-generator
|
||||
|
||||
# Configure environment
|
||||
|
|
@ -112,6 +112,10 @@ APP_URL=https://your-domain.com
|
|||
UPLOAD_DIR=/app/uploads
|
||||
MAX_UPLOAD_SIZE=524288000
|
||||
CHROMA_PERSIST_DIR=/app/chroma_data
|
||||
|
||||
# Optional bootstrap admin. Leave blank to let the first registered user become admin.
|
||||
DEFAULT_ADMIN_EMAIL=
|
||||
DEFAULT_ADMIN_PASSWORD=
|
||||
```
|
||||
|
||||
### Frontend (`frontend/.env`)
|
||||
|
|
@ -307,7 +311,6 @@ Accessible at `/admin` for admin users. Three tabs:
|
|||
|
||||
### More Settings
|
||||
- **Public Registration** — enable/disable new user sign-ups
|
||||
- **AWS Polly** — global enable/disable toggle for all Polly voices
|
||||
- **Classify Questions** — trigger AI tag classification for all untagged questions (runs as background task)
|
||||
- **Embedding Model** — set the model used for semantic search vectors:
|
||||
- Type a model name and click **Save**, or click **Search LiteLLM** to browse proxy models
|
||||
|
|
@ -457,9 +460,8 @@ The landing page at `/home` uses the shared `Navbar` component. When not logged
|
|||
|
||||
## TTS Providers
|
||||
|
||||
| Provider | Model ID format | Key needed |
|
||||
| Provider | Model/voice ID format | Key needed |
|
||||
|---|---|---|
|
||||
| OpenAI | `tts-1:alloy`, `tts-1:nova`, `tts-1-hd:*` | `OPENAI_API_KEY` |
|
||||
| AWS Polly | `polly/Joanna`, `polly/Matthew`, `polly/Amy` | `AWS_ACCESS_KEY_ID` + IAM `polly:SynthesizeSpeech` |
|
||||
| ElevenLabs | `elevenlabs/<voice-id>` | `ELEVENLABS_API_KEY` |
|
||||
| Google Cloud | `google/<voice-name>` | `GOOGLE_TTS_API_KEY` |
|
||||
| Kokoro via LiteLLM | `local-kokoro-tts:am_adam`, `local-kokoro-tts:af_bella` | `LITELLM_API_KEY` |
|
||||
|
||||
The part before `:` is the LiteLLM speech model route. The part after `:` is the Kokoro speaker voice.
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ SECRET_KEY=change-me-to-a-random-secret-key-in-production
|
|||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=1440
|
||||
|
||||
# Optional bootstrap admin. Leave blank to use first-user-becomes-admin registration.
|
||||
DEFAULT_ADMIN_EMAIL=
|
||||
DEFAULT_ADMIN_PASSWORD=
|
||||
|
||||
# Redis (use service name in Docker)
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
|
|
@ -11,6 +15,9 @@ REDIS_URL=redis://redis:6379/0
|
|||
LITELLM_MODEL=gpt-4o-mini
|
||||
LITELLM_API_KEY=your-api-key-here
|
||||
|
||||
# Local Sherpa speech gateway for self-hosted TTS/STT
|
||||
LOCAL_SPEECH_GATEWAY_URL=http://local-speech-gateway:8110
|
||||
|
||||
# Vector store
|
||||
CHROMA_PERSIST_DIR=/app/chroma_data
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ if _db_url:
|
|||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
from app.models import User, PDFDocument, Section, Quiz, Question, QuizAttempt, AttemptAnswer, ReminderSchedule # noqa
|
||||
from app.models import User, PDFDocument, Section, Quiz, Question, QuizAttempt, AttemptAnswer, ReminderSchedule, UserNote # noqa
|
||||
from app.database import Base
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
|
|
|||
38
backend/alembic/versions/e4c7b2a9d6f1_add_user_notes.py
Normal file
38
backend/alembic/versions/e4c7b2a9d6f1_add_user_notes.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"""add user notes
|
||||
|
||||
Revision ID: e4c7b2a9d6f1
|
||||
Revises: 9bac7bf02e38
|
||||
Create Date: 2026-05-12 17:10:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'e4c7b2a9d6f1'
|
||||
down_revision: Union[str, None] = '9bac7bf02e38'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS user_notes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL
|
||||
)
|
||||
"""))
|
||||
conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_user_notes_id ON user_notes (id)"))
|
||||
conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_user_notes_user_id ON user_notes (user_id)"))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f('ix_user_notes_user_id'), table_name='user_notes')
|
||||
op.drop_index(op.f('ix_user_notes_id'), table_name='user_notes')
|
||||
op.drop_table('user_notes')
|
||||
|
|
@ -18,6 +18,7 @@ class Settings(BaseSettings):
|
|||
OPENAI_API_KEY: str = ""
|
||||
ELEVENLABS_API_KEY: str = ""
|
||||
GOOGLE_TTS_API_KEY: str = ""
|
||||
LOCAL_SPEECH_GATEWAY_URL: str = "http://127.0.0.1:8110"
|
||||
AWS_ACCESS_KEY_ID: str = ""
|
||||
AWS_SECRET_ACCESS_KEY: str = ""
|
||||
AWS_REGION: str = "us-east-1"
|
||||
|
|
@ -41,6 +42,9 @@ class Settings(BaseSettings):
|
|||
TURNSTILE_SECRET_KEY: str = "" # Cloudflare Turnstile — leave blank to disable captcha
|
||||
ADMIN_EMAIL: str = "" # Where contact form submissions are emailed
|
||||
|
||||
DEFAULT_ADMIN_EMAIL: str = "" # Optional explicit bootstrap admin email
|
||||
DEFAULT_ADMIN_PASSWORD: str = "" # Optional explicit bootstrap admin password
|
||||
|
||||
BBB_SERVER_URL: str = "" # BigBlueButton server URL (e.g. https://bbb.example.com/bigbluebutton)
|
||||
BBB_SECRET: str = "" # BigBlueButton shared secret
|
||||
|
||||
|
|
@ -53,5 +57,4 @@ class Settings(BaseSettings):
|
|||
|
||||
LOG_LEVEL: str = "INFO" # DEBUG, INFO, WARNING, ERROR
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
|
|
|||
|
|
@ -11,30 +11,39 @@ from app.logging_config import setup_logging
|
|||
# Configure structured JSON logging before anything else
|
||||
setup_logging(settings.LOG_LEVEL)
|
||||
from app.database import engine, Base, SessionLocal
|
||||
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses
|
||||
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses, mobile, mynote
|
||||
from app.utils.auth import get_password_hash
|
||||
from app.utils.scheduler import start_scheduler, stop_scheduler
|
||||
|
||||
|
||||
def seed_admin():
|
||||
"""Create default admin user if none exists."""
|
||||
"""Optionally create a configured bootstrap admin user if none exists."""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from app.models.user import User
|
||||
from app.models.email_verification import EmailVerification
|
||||
from app.models.password_reset import PasswordReset
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
admin_exists = db.query(User).filter(User.role == "admin").first()
|
||||
if not admin_exists:
|
||||
if not settings.DEFAULT_ADMIN_EMAIL or not settings.DEFAULT_ADMIN_PASSWORD:
|
||||
log.info("No admin exists; skipping bootstrap admin seed. First registered user will become admin.")
|
||||
return
|
||||
if len(settings.DEFAULT_ADMIN_PASSWORD) < 8:
|
||||
log.warning("DEFAULT_ADMIN_PASSWORD is too short; skipping bootstrap admin seed.")
|
||||
return
|
||||
admin_user = User(
|
||||
email="admin@quizapp.com",
|
||||
hashed_password=get_password_hash("admin123"),
|
||||
email=settings.DEFAULT_ADMIN_EMAIL.lower().strip(),
|
||||
hashed_password=get_password_hash(settings.DEFAULT_ADMIN_PASSWORD),
|
||||
name="Admin",
|
||||
role="admin",
|
||||
)
|
||||
db.add(admin_user)
|
||||
db.flush()
|
||||
# Auto-verify seeded admin
|
||||
from datetime import datetime
|
||||
db.add(EmailVerification(
|
||||
user_id=admin_user.id,
|
||||
token="seeded",
|
||||
|
|
@ -44,8 +53,6 @@ def seed_admin():
|
|||
db.commit()
|
||||
else:
|
||||
# Ensure existing admin has a verified email record
|
||||
from app.models.email_verification import EmailVerification
|
||||
from datetime import datetime
|
||||
existing_v = db.query(EmailVerification).filter(EmailVerification.user_id == admin_exists.id).first()
|
||||
if not existing_v:
|
||||
db.add(EmailVerification(
|
||||
|
|
@ -61,6 +68,8 @@ def seed_admin():
|
|||
|
||||
def seed_default_models():
|
||||
"""Seed default AI model configs if none exist."""
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.models.ai_model_config import AIModelConfig
|
||||
db = SessionLocal()
|
||||
try:
|
||||
|
|
@ -82,49 +91,72 @@ def seed_default_models():
|
|||
db.delete(titan)
|
||||
db.commit()
|
||||
|
||||
# Always ensure OpenAI TTS voice models exist (idempotent)
|
||||
# Always ensure LiteLLM-routed local speech models exist (idempotent).
|
||||
tts_voices = [
|
||||
# OpenAI (work with OPENAI_API_KEY)
|
||||
("OpenAI Alloy", "tts-1:alloy", True),
|
||||
("OpenAI Nova", "tts-1:nova", False),
|
||||
("OpenAI Echo", "tts-1:echo", False),
|
||||
("OpenAI Shimmer", "tts-1:shimmer", False),
|
||||
("OpenAI Onyx", "tts-1:onyx", False),
|
||||
("OpenAI Fable", "tts-1:fable", False),
|
||||
("OpenAI Alloy HD", "tts-1-hd:alloy", False),
|
||||
("OpenAI Nova HD", "tts-1-hd:nova", False),
|
||||
# ElevenLabs (work with ELEVENLABS_API_KEY)
|
||||
("ElevenLabs Adam", "elevenlabs/adam", False),
|
||||
# Google Cloud TTS (work with GOOGLE_TTS_API_KEY)
|
||||
("Google Wavenet F (en-US)", "google/en-US-Wavenet-F", False),
|
||||
("Google Wavenet D (en-US)", "google/en-US-Wavenet-D", False),
|
||||
("Google Studio O (en-US)", "google/en-US-Studio-O", False),
|
||||
("Google Studio Q (en-US)", "google/en-US-Studio-Q", False),
|
||||
("Google Chirp 3 HD (en-US)", "google/en-US-Chirp3-HD-Aoede", False),
|
||||
# AWS Polly Neural (work with AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY)
|
||||
("AWS Polly Joanna (en-US)", "polly/Joanna", False),
|
||||
("AWS Polly Matthew (en-US)", "polly/Matthew", False),
|
||||
("AWS Polly Amy (en-GB)", "polly/Amy", False),
|
||||
("AWS Polly Brian (en-GB)", "polly/Brian", False),
|
||||
("Kokoro Adam", "local-kokoro-tts:am_adam", True),
|
||||
("Kokoro Michael", "local-kokoro-tts:am_michael", False),
|
||||
("Kokoro Bella", "local-kokoro-tts:af_bella", False),
|
||||
("Kokoro Nicole", "local-kokoro-tts:af_nicole", False),
|
||||
("Kokoro Emma", "local-kokoro-tts:bf_emma", False),
|
||||
("Kokoro Lewis", "local-kokoro-tts:bm_lewis", False),
|
||||
]
|
||||
# Deactivate old generic tts-1 / tts-1-hd entries (no voice encoded)
|
||||
for old_id in ("tts-1", "tts-1-hd"):
|
||||
old = db.query(AIModelConfig).filter(AIModelConfig.model_id == old_id).first()
|
||||
if old:
|
||||
stt_models = [
|
||||
("Parakeet STT (LiteLLM)", "local-parakeet-v3", True),
|
||||
("Groq Whisper Turbo", "groq-whisper-large-v3-turbo", False),
|
||||
("Groq Whisper Large v3", "groq-whisper-large-v3", False),
|
||||
]
|
||||
# Deactivate external/direct legacy TTS entries; speech should route through LiteLLM.
|
||||
for old in db.query(AIModelConfig).filter(AIModelConfig.task == "tts").all():
|
||||
if old.model_id and not old.model_id.startswith("local-"):
|
||||
old.is_active = False
|
||||
old.is_default = False
|
||||
if old.model_id == "local-kokoro-tts":
|
||||
old.is_active = False
|
||||
old.is_default = False
|
||||
if old.model_id == "local-chatterbox-turbo":
|
||||
old.is_active = False
|
||||
old.is_default = False
|
||||
if old.model_id == "local-qwen3-tts":
|
||||
old.is_active = False
|
||||
old.is_default = False
|
||||
|
||||
has_default_tts = db.query(AIModelConfig).filter(
|
||||
AIModelConfig.task == "tts", AIModelConfig.is_default == True, AIModelConfig.is_active == True,
|
||||
).first() is not None
|
||||
|
||||
for name, model_id, _ in tts_voices:
|
||||
exists = db.query(AIModelConfig).filter(AIModelConfig.model_id == model_id).first()
|
||||
if not exists:
|
||||
is_def = not has_default_tts
|
||||
db.add(AIModelConfig(name=name, model_id=model_id, task="tts", is_active=True, is_default=is_def))
|
||||
if is_def:
|
||||
has_default_tts = True
|
||||
db.execute(text("""
|
||||
INSERT INTO ai_model_configs (name, model_id, task, is_active, is_default, created_at)
|
||||
VALUES (:name, :model_id, 'tts', true, false, NOW())
|
||||
ON CONFLICT (model_id, task) DO NOTHING
|
||||
"""), {"name": name, "model_id": model_id})
|
||||
|
||||
# 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:am_adam",
|
||||
).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.execute(text("""
|
||||
INSERT INTO ai_model_configs (name, model_id, task, is_active, is_default, created_at)
|
||||
VALUES (:name, :model_id, :task, true, :is_default, NOW())
|
||||
ON CONFLICT (model_id, task) DO NOTHING
|
||||
"""), {"name": name, "model_id": model_id, "task": task, "is_default": is_def})
|
||||
if is_def:
|
||||
has_default = True
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
|
@ -574,6 +606,8 @@ app.include_router(contact.router, prefix="/api/contact", tags=["contact"])
|
|||
app.include_router(tags.router, prefix="/api/tags", tags=["tags"])
|
||||
app.include_router(flashcards.router, prefix="/api/flashcards", tags=["flashcards"])
|
||||
app.include_router(courses.router, prefix="/api/courses", tags=["courses"])
|
||||
app.include_router(mobile.router, prefix="/api/mobile", tags=["mobile"])
|
||||
app.include_router(mynote.router, prefix="/api/mynote", tags=["mynote"])
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from app.models.attempt import QuizAttempt, AttemptAnswer
|
|||
from app.models.reminder import ReminderSchedule
|
||||
from app.models.ai_model_config import AIModelConfig
|
||||
from app.models.favorite import Favorite
|
||||
from app.models.user_note import UserNote
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
|
|
@ -19,4 +20,5 @@ __all__ = [
|
|||
"ReminderSchedule",
|
||||
"AIModelConfig",
|
||||
"Favorite",
|
||||
"UserNote",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ class User(Base):
|
|||
attempts = relationship("QuizAttempt", back_populates="user")
|
||||
reminders = relationship("ReminderSchedule", back_populates="user")
|
||||
favorites = relationship("Favorite", back_populates="user", cascade="all, delete-orphan")
|
||||
note = relationship("UserNote", back_populates="user", cascade="all, delete-orphan", uselist=False)
|
||||
|
||||
@property
|
||||
def is_admin(self):
|
||||
|
|
|
|||
18
backend/app/models/user_note.py
Normal file
18
backend/app/models/user_note.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, Integer, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class UserNote(Base):
|
||||
__tablename__ = "user_notes"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False, index=True)
|
||||
content = Column(Text, nullable=False, default="")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
user = relationship("User", back_populates="note")
|
||||
|
|
@ -139,6 +139,7 @@ def list_available_models(
|
|||
class LiteLLMSearchRequest(BaseModel):
|
||||
api_key: str | None = None
|
||||
api_base: str | None = None
|
||||
mode: str | None = None
|
||||
|
||||
|
||||
@router.post("/litellm/models")
|
||||
|
|
@ -155,8 +156,16 @@ def search_litellm_models(
|
|||
if base:
|
||||
try:
|
||||
headers = {"Authorization": f"Bearer {key}"} if key else {}
|
||||
resp = httpx.post(f"{base}/v1/models", headers=headers, timeout=10) if False else \
|
||||
httpx.get(f"{base}/v1/models", headers=headers, timeout=10)
|
||||
if data.mode:
|
||||
info_base = base.rstrip("/").removesuffix("/v1")
|
||||
resp = httpx.get(f"{info_base}/model/info", headers=headers, timeout=10)
|
||||
resp.raise_for_status()
|
||||
models = sorted([
|
||||
m.get("model_name") for m in resp.json().get("data", [])
|
||||
if m.get("model_name") and (m.get("model_info") or {}).get("mode") == data.mode
|
||||
])
|
||||
return {"models": models, "source": info_base, "mode": data.mode}
|
||||
resp = httpx.get(f"{base}/v1/models", headers=headers, timeout=10)
|
||||
resp.raise_for_status()
|
||||
models = sorted([m["id"] for m in resp.json().get("data", [])])
|
||||
return {"models": models, "source": base}
|
||||
|
|
@ -187,8 +196,9 @@ def create_model(
|
|||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
if data.task not in ("extraction", "tts", "teach", "keyword", "flashcard"):
|
||||
raise HTTPException(status_code=400, detail="Task must be extraction, tts, teach, or keyword")
|
||||
valid_tasks = ("extraction", "tts", "stt", "teach", "keyword", "flashcard")
|
||||
if data.task not in valid_tasks:
|
||||
raise HTTPException(status_code=400, detail=f"Task must be one of: {', '.join(valid_tasks)}")
|
||||
|
||||
if data.is_default:
|
||||
db.query(AIModelConfig).filter(
|
||||
|
|
@ -276,6 +286,27 @@ def test_model(
|
|||
if model.task == "tts":
|
||||
raise HTTPException(status_code=400, detail="Use the Preview button to test TTS voices — it plays audio directly.")
|
||||
|
||||
if model.task == "stt":
|
||||
base = (settings.LITELLM_API_BASE or "").rstrip("/").removesuffix("/v1")
|
||||
key = model.api_key or settings.LITELLM_API_KEY
|
||||
if not base:
|
||||
raise HTTPException(status_code=400, detail="LiteLLM API base is not configured")
|
||||
try:
|
||||
headers = {"Authorization": f"Bearer {key}"} if key else {}
|
||||
resp = httpx.get(f"{base}/model/info", headers=headers, timeout=10)
|
||||
resp.raise_for_status()
|
||||
found = any(
|
||||
m.get("model_name") == model.model_id and (m.get("model_info") or {}).get("mode") == "audio_transcription"
|
||||
for m in resp.json().get("data", [])
|
||||
)
|
||||
if not found:
|
||||
raise HTTPException(status_code=404, detail=f"{model.model_id} was not found as an audio_transcription model in LiteLLM")
|
||||
return {"message": f"✓ {model.model_id} is available for speech transcription"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=str(e))
|
||||
|
||||
try:
|
||||
import litellm
|
||||
from app.services.ai_service import _proxy_model
|
||||
|
|
@ -304,90 +335,93 @@ class TTSVoiceSearchRequest(BaseModel):
|
|||
region: str | None = None
|
||||
|
||||
|
||||
KOKORO_VOICE_FALLBACKS = [
|
||||
("am_adam", "Kokoro Adam"),
|
||||
("am_michael", "Kokoro Michael"),
|
||||
("af_bella", "Kokoro Bella"),
|
||||
("af_nicole", "Kokoro Nicole"),
|
||||
("bf_emma", "Kokoro Emma"),
|
||||
("bm_lewis", "Kokoro Lewis"),
|
||||
]
|
||||
|
||||
|
||||
def _kokoro_voice_options(model_name: str) -> list[dict]:
|
||||
base = settings.LOCAL_SPEECH_GATEWAY_URL.rstrip("/")
|
||||
voices = []
|
||||
friendly_names = {voice_id: name for voice_id, name in KOKORO_VOICE_FALLBACKS}
|
||||
if base:
|
||||
try:
|
||||
resp = httpx.get(f"{base}/v1/audio/voices", timeout=10)
|
||||
resp.raise_for_status()
|
||||
voices = [
|
||||
v for v in resp.json().get("voices", [])
|
||||
if v.get("profile") == "kokoro" and v.get("voice")
|
||||
]
|
||||
except Exception:
|
||||
voices = []
|
||||
|
||||
if not voices:
|
||||
voices = [
|
||||
{"voice": voice_id, "name": name, "profile": "kokoro"}
|
||||
for voice_id, name in KOKORO_VOICE_FALLBACKS
|
||||
]
|
||||
|
||||
return [
|
||||
{
|
||||
"model_id": f"{model_name}:{v['voice']}",
|
||||
"name": friendly_names.get(v["voice"], v.get("name") or f"Kokoro {v['voice']}"),
|
||||
"labels": {"provider": "litellm", "model": model_name, "voice": v["voice"]},
|
||||
}
|
||||
for v in voices
|
||||
]
|
||||
|
||||
|
||||
@router.post("/tts/voices")
|
||||
def search_tts_voices(
|
||||
data: TTSVoiceSearchRequest,
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
"""Discover available TTS voices from ElevenLabs, AWS Polly, or return OpenAI hardcoded list."""
|
||||
"""Discover local TTS voices/models from LiteLLM or the local speech gateway."""
|
||||
import logging
|
||||
log = logging.getLogger(__name__)
|
||||
provider = data.provider
|
||||
api_key = data.api_key
|
||||
region = data.region
|
||||
|
||||
if provider == "elevenlabs":
|
||||
key = api_key or settings.ELEVENLABS_API_KEY
|
||||
if not key:
|
||||
raise HTTPException(status_code=400, detail="ElevenLabs API key required (set ELEVENLABS_API_KEY in .env or enter it above)")
|
||||
if provider != "litellm":
|
||||
raise HTTPException(status_code=400, detail="TTS discovery is routed through LiteLLM only")
|
||||
|
||||
if provider == "litellm":
|
||||
base = (settings.LITELLM_API_BASE or "").rstrip("/").removesuffix("/v1")
|
||||
key = api_key or settings.LITELLM_API_KEY
|
||||
if not base:
|
||||
raise HTTPException(status_code=400, detail="LiteLLM API base is not configured")
|
||||
try:
|
||||
resp = httpx.get(
|
||||
"https://api.elevenlabs.io/v1/voices",
|
||||
headers={"xi-api-key": key},
|
||||
timeout=15,
|
||||
)
|
||||
headers = {"Authorization": f"Bearer {key}"} if key else {}
|
||||
resp = httpx.get(f"{base}/model/info", headers=headers, timeout=10)
|
||||
resp.raise_for_status()
|
||||
voices = resp.json().get("voices", [])
|
||||
return {"voices": [
|
||||
{
|
||||
"model_id": f"elevenlabs/{v['voice_id']}",
|
||||
"name": v["name"],
|
||||
"labels": v.get("labels", {}),
|
||||
}
|
||||
for v in sorted(voices, key=lambda x: x["name"])
|
||||
]}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
log.warning(f"ElevenLabs voice discovery failed: {e}")
|
||||
raise HTTPException(status_code=400, detail=f"ElevenLabs API error: {e}")
|
||||
|
||||
elif provider == "polly":
|
||||
access_key = api_key or settings.AWS_ACCESS_KEY_ID
|
||||
secret_key = settings.AWS_SECRET_ACCESS_KEY
|
||||
aws_region = region or settings.AWS_REGION or "us-east-1"
|
||||
if not access_key or not secret_key:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="AWS credentials required — set AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY in .env",
|
||||
)
|
||||
try:
|
||||
import boto3
|
||||
polly = boto3.client(
|
||||
"polly",
|
||||
aws_access_key_id=access_key,
|
||||
aws_secret_access_key=secret_key,
|
||||
region_name=aws_region,
|
||||
)
|
||||
resp = polly.describe_voices(Engine="neural")
|
||||
voices = resp.get("Voices", [])
|
||||
return {"voices": [
|
||||
{
|
||||
"model_id": f"polly/{v['Id']}",
|
||||
"name": f"{v['Name']} — {v['LanguageName']} ({v.get('Gender', '')})",
|
||||
"labels": {"gender": v.get("Gender", ""), "language": v.get("LanguageName", "")},
|
||||
}
|
||||
for v in sorted(voices, key=lambda x: x["Name"])
|
||||
]}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
log.warning(f"AWS Polly voice discovery failed: {e}")
|
||||
raise HTTPException(status_code=400, detail=f"AWS Polly error: {e}")
|
||||
|
||||
elif provider == "openai":
|
||||
voices = []
|
||||
for model_name in ["tts-1", "tts-1-hd"]:
|
||||
for voice in ["alloy", "ash", "coral", "echo", "fable", "nova", "onyx", "sage", "shimmer"]:
|
||||
models = resp.json().get("data", [])
|
||||
voices = []
|
||||
for m in models:
|
||||
model_name = m.get("model_name")
|
||||
if not model_name or (m.get("model_info") or {}).get("mode") != "audio_speech":
|
||||
continue
|
||||
if model_name == "local-kokoro-tts":
|
||||
voices.extend(_kokoro_voice_options(model_name))
|
||||
continue
|
||||
voices.append({
|
||||
"model_id": f"{model_name}:{voice}",
|
||||
"name": f"{model_name} · {voice}",
|
||||
"labels": {"model": model_name, "voice": voice},
|
||||
"model_id": model_name,
|
||||
"name": model_name,
|
||||
"labels": {"provider": "litellm", "mode": "audio_speech"},
|
||||
})
|
||||
return {"voices": voices}
|
||||
return {"voices": voices}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
log.warning(f"LiteLLM local TTS discovery failed: {e}")
|
||||
raise HTTPException(status_code=400, detail=f"LiteLLM TTS discovery error: {e}")
|
||||
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown provider '{provider}'. Valid: elevenlabs, polly, openai")
|
||||
raise HTTPException(status_code=400, detail=f"Unknown provider '{provider}'. Valid: litellm")
|
||||
|
||||
|
||||
# --- System Settings ---
|
||||
|
|
@ -400,12 +434,10 @@ def get_settings(admin: User = Depends(require_admin)):
|
|||
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
|
||||
registration_enabled = r.get("settings:registration_enabled")
|
||||
embedding_model = r.get("settings:embedding_model")
|
||||
polly_enabled = r.get("settings:polly_enabled")
|
||||
sso_only = r.get("settings:sso_only")
|
||||
return {
|
||||
"registration_enabled": registration_enabled != "false",
|
||||
"embedding_model": embedding_model or settings.LITELLM_EMBEDDING_MODEL or "",
|
||||
"polly_enabled": polly_enabled != "false",
|
||||
"sso_only": sso_only == "true",
|
||||
"sso_configured": bool(settings.OIDC_PROVIDER_URL and settings.OIDC_CLIENT_ID),
|
||||
"sso_provider_name": settings.OIDC_PROVIDER_NAME,
|
||||
|
|
@ -414,7 +446,6 @@ def get_settings(admin: User = Depends(require_admin)):
|
|||
return {
|
||||
"registration_enabled": True,
|
||||
"embedding_model": settings.LITELLM_EMBEDDING_MODEL or "",
|
||||
"polly_enabled": True,
|
||||
"sso_only": False,
|
||||
"sso_configured": bool(settings.OIDC_PROVIDER_URL and settings.OIDC_CLIENT_ID),
|
||||
"sso_provider_name": settings.OIDC_PROVIDER_NAME,
|
||||
|
|
@ -438,10 +469,6 @@ def update_settings(
|
|||
if "embedding_model" in settings_data:
|
||||
r.set("settings:embedding_model", settings_data["embedding_model"])
|
||||
|
||||
if "polly_enabled" in settings_data:
|
||||
value = "true" if settings_data["polly_enabled"] else "false"
|
||||
r.set("settings:polly_enabled", value)
|
||||
|
||||
if "sso_only" in settings_data:
|
||||
value = "true" if settings_data["sso_only"] else "false"
|
||||
r.set("settings:sso_only", value)
|
||||
|
|
|
|||
|
|
@ -90,6 +90,8 @@ class ProgressUpdate(BaseModel):
|
|||
|
||||
|
||||
class AIGenerateRequest(BaseModel):
|
||||
model_config = {"protected_namespaces": ()}
|
||||
|
||||
prompt: str
|
||||
action: str = "generate" # generate, refine, summarize
|
||||
existing_content: str | None = None
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ router = APIRouter()
|
|||
# ── Schemas ──────────────────────────────────────────────────────────
|
||||
|
||||
class FlashcardDeckCreate(BaseModel):
|
||||
model_config = {"protected_namespaces": ()}
|
||||
|
||||
section_id: int
|
||||
title: str
|
||||
model_id: str | None = None
|
||||
|
|
|
|||
244
backend/app/routers/mobile.py
Normal file
244
backend/app/routers/mobile.py
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models.attempt import AttemptAnswer, QuizAttempt
|
||||
from app.models.email_verification import EmailVerification
|
||||
from app.models.quiz import Quiz
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import Token
|
||||
from app.utils.auth import create_access_token, get_current_user, verify_password
|
||||
from app.utils.quiz_questions import get_quiz_questions
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class MobileLoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class MobileAnswerSubmit(BaseModel):
|
||||
question_id: int
|
||||
user_answer: str
|
||||
transcript: str | None = None
|
||||
selected_letter: str | None = None
|
||||
confidence: float | None = None
|
||||
|
||||
|
||||
class MobileAttemptSubmit(BaseModel):
|
||||
quiz_id: int
|
||||
started_at: datetime | None = None
|
||||
completed_at: datetime | None = None
|
||||
selected_question_ids: list[int] | None = None
|
||||
answers: list[MobileAnswerSubmit]
|
||||
|
||||
|
||||
ANDROID_MODEL_MANIFEST = {
|
||||
"version": 1,
|
||||
"default_stack": {
|
||||
"stt": "android-speech-offline",
|
||||
"mapper": "deterministic-option-matcher",
|
||||
"tts": "android-system-tts",
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"id": "android-speech-offline",
|
||||
"role": "stt",
|
||||
"runtime": "android.speech.SpeechRecognizer",
|
||||
"label": "Android offline speech recognizer",
|
||||
"download_url": None,
|
||||
"required": False,
|
||||
"notes": "Uses the device speech service with offline preference while bundled STT artifacts are added.",
|
||||
},
|
||||
{
|
||||
"id": "deterministic-option-matcher",
|
||||
"role": "mapper",
|
||||
"runtime": "in-app",
|
||||
"label": "Exact option/letter matcher",
|
||||
"download_url": None,
|
||||
"required": True,
|
||||
"notes": "Grades locally against synced answer keys; no cloud model decides correctness.",
|
||||
},
|
||||
{
|
||||
"id": "android-system-tts",
|
||||
"role": "tts",
|
||||
"runtime": "android.speech.tts.TextToSpeech",
|
||||
"label": "Android system TTS",
|
||||
"download_url": None,
|
||||
"required": False,
|
||||
"notes": "Fast local read-aloud fallback before packaged neural TTS is shipped.",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _mobile_login_rate_limit(client_ip: str):
|
||||
try:
|
||||
import redis as redis_lib
|
||||
|
||||
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True, socket_connect_timeout=1)
|
||||
key = f"mobile_login_attempts:{client_ip}"
|
||||
count = r.incr(key)
|
||||
if count == 1:
|
||||
r.expire(key, 15 * 60)
|
||||
if count > 20:
|
||||
raise HTTPException(status_code=429, detail="Too many login attempts. Try again in 15 minutes.")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
# Mobile login should still work if Redis is briefly unavailable.
|
||||
return
|
||||
|
||||
|
||||
def _visible_quizzes_query(db: Session, current_user: User):
|
||||
query = db.query(Quiz).filter(Quiz.deleted_at.is_(None))
|
||||
if not current_user.is_moderator:
|
||||
query = query.filter(Quiz.is_published == 1, Quiz.course_id.is_(None))
|
||||
return query
|
||||
|
||||
|
||||
def _question_payload(question):
|
||||
return {
|
||||
"id": question.id,
|
||||
"question_text": question.question_text,
|
||||
"question_type": question.question_type,
|
||||
"options": question.options,
|
||||
"correct_answer": question.correct_answer,
|
||||
"explanation": question.explanation,
|
||||
"page_reference": question.page_reference,
|
||||
"image_path": question.image_path,
|
||||
}
|
||||
|
||||
|
||||
def _quiz_payload(db: Session, quiz: Quiz, include_questions: bool = True):
|
||||
payload = {
|
||||
"id": quiz.id,
|
||||
"title": quiz.title,
|
||||
"mode": quiz.mode,
|
||||
"questions_count": quiz.questions_count,
|
||||
"time_limit_minutes": quiz.time_limit_minutes,
|
||||
"questions_per_attempt": quiz.questions_per_attempt,
|
||||
"category_id": quiz.category_id,
|
||||
"course_id": quiz.course_id,
|
||||
"is_published": quiz.is_published,
|
||||
"is_shared": quiz.is_shared,
|
||||
"created_at": quiz.created_at.isoformat() if quiz.created_at else None,
|
||||
}
|
||||
if include_questions:
|
||||
payload["questions"] = [_question_payload(question) for question in get_quiz_questions(db, quiz.id)]
|
||||
return payload
|
||||
|
||||
|
||||
@router.post("/auth/login")
|
||||
def mobile_login(data: MobileLoginRequest, request: Request, db: Session = Depends(get_db)):
|
||||
"""Password login for native apps. Uses rate limiting instead of browser Turnstile."""
|
||||
client_ip = request.client.host if request and request.client else "unknown"
|
||||
_mobile_login_rate_limit(client_ip)
|
||||
|
||||
email = data.email.lower().strip()
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
if not user or not verify_password(data.password, user.hashed_password):
|
||||
raise HTTPException(status_code=401, detail="Invalid email or password")
|
||||
|
||||
verification = db.query(EmailVerification).filter(EmailVerification.user_id == user.id).first()
|
||||
if verification and verification.verified_at is None:
|
||||
raise HTTPException(status_code=403, detail="Email not verified")
|
||||
|
||||
token = create_access_token(data={"sub": user.email})
|
||||
return {
|
||||
**Token(access_token=token).model_dump(),
|
||||
"user": {"id": user.id, "email": user.email, "name": user.name, "role": user.role},
|
||||
"server_time": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/sync")
|
||||
def mobile_sync(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Bulk sync all visible quizzes with answer keys for offline study."""
|
||||
quizzes = _visible_quizzes_query(db, current_user).order_by(Quiz.created_at.desc()).all()
|
||||
return {
|
||||
"server_time": datetime.utcnow().isoformat(),
|
||||
"user": {"id": current_user.id, "email": current_user.email, "name": current_user.name, "role": current_user.role},
|
||||
"quizzes": [_quiz_payload(db, quiz) for quiz in quizzes],
|
||||
"deleted_quiz_ids": [],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/quizzes/{quiz_id}")
|
||||
def mobile_quiz_detail(
|
||||
quiz_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
quiz = _visible_quizzes_query(db, current_user).filter(Quiz.id == quiz_id).first()
|
||||
if not quiz:
|
||||
raise HTTPException(status_code=404, detail="Quiz not found")
|
||||
return _quiz_payload(db, quiz)
|
||||
|
||||
|
||||
@router.get("/models")
|
||||
def mobile_models(current_user: User = Depends(get_current_user)):
|
||||
_ = current_user
|
||||
return ANDROID_MODEL_MANIFEST
|
||||
|
||||
|
||||
@router.post("/attempts")
|
||||
def upload_mobile_attempt(
|
||||
data: MobileAttemptSubmit,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
quiz = _visible_quizzes_query(db, current_user).filter(Quiz.id == data.quiz_id).first()
|
||||
if not quiz:
|
||||
raise HTTPException(status_code=404, detail="Quiz not found")
|
||||
|
||||
question_map = {question.id: question for question in get_quiz_questions(db, quiz.id)}
|
||||
selected_ids = data.selected_question_ids or [answer.question_id for answer in data.answers]
|
||||
total_questions = len(selected_ids) if selected_ids else len(question_map)
|
||||
score = 0
|
||||
|
||||
attempt = QuizAttempt(
|
||||
quiz_id=quiz.id,
|
||||
user_id=current_user.id,
|
||||
total_questions=total_questions,
|
||||
started_at=data.started_at or datetime.utcnow(),
|
||||
completed_at=data.completed_at or datetime.utcnow(),
|
||||
selected_question_ids=selected_ids or None,
|
||||
)
|
||||
db.add(attempt)
|
||||
db.flush()
|
||||
|
||||
for answer in data.answers:
|
||||
question = question_map.get(answer.question_id)
|
||||
if not question:
|
||||
continue
|
||||
is_correct = bool(answer.user_answer) and answer.user_answer.strip().lower() == question.correct_answer.strip().lower()
|
||||
if is_correct:
|
||||
score += 1
|
||||
db.add(AttemptAnswer(
|
||||
attempt_id=attempt.id,
|
||||
question_id=question.id,
|
||||
user_answer=answer.user_answer,
|
||||
is_correct=is_correct,
|
||||
))
|
||||
|
||||
attempt.score = score
|
||||
db.commit()
|
||||
percentage = round((score / total_questions * 100) if total_questions else 0, 1)
|
||||
return {
|
||||
"id": attempt.id,
|
||||
"quiz_id": quiz.id,
|
||||
"score": score,
|
||||
"total_questions": total_questions,
|
||||
"percentage": percentage,
|
||||
"completed_at": attempt.completed_at.isoformat() if attempt.completed_at else None,
|
||||
}
|
||||
50
backend/app/routers/mynote.py
Normal file
50
backend/app/routers/mynote.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.user_note import UserNote
|
||||
from app.utils.auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class MyNoteResponse(BaseModel):
|
||||
content: str
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class MyNoteUpdate(BaseModel):
|
||||
content: str = Field(default="", max_length=50000)
|
||||
|
||||
|
||||
@router.get("", response_model=MyNoteResponse)
|
||||
def get_my_note(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
note = db.query(UserNote).filter(UserNote.user_id == current_user.id).first()
|
||||
if not note:
|
||||
return {"content": "", "updated_at": None}
|
||||
return {"content": note.content or "", "updated_at": note.updated_at}
|
||||
|
||||
|
||||
@router.put("", response_model=MyNoteResponse)
|
||||
def save_my_note(
|
||||
data: MyNoteUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
note = db.query(UserNote).filter(UserNote.user_id == current_user.id).first()
|
||||
if not note:
|
||||
note = UserNote(user_id=current_user.id, content=data.content)
|
||||
db.add(note)
|
||||
else:
|
||||
note.content = data.content
|
||||
note.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(note)
|
||||
return {"content": note.content or "", "updated_at": note.updated_at}
|
||||
|
|
@ -18,6 +18,8 @@ class ChatMessage(BaseModel):
|
|||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
model_config = {"protected_namespaces": ()}
|
||||
|
||||
question_id: int
|
||||
messages: list[ChatMessage]
|
||||
model_id: int | None = None # AIModelConfig.id — if None, use default
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.ai_model_config import AIModelConfig
|
||||
|
|
@ -18,14 +17,30 @@ class TTSRequest(BaseModel):
|
|||
voice: str | None = None # model_id override
|
||||
|
||||
|
||||
def _polly_enabled() -> bool:
|
||||
try:
|
||||
import redis as redis_lib
|
||||
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
|
||||
val = r.get("settings:polly_enabled")
|
||||
return val != "false"
|
||||
except Exception:
|
||||
return True
|
||||
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 _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:am_adam", None
|
||||
|
||||
|
||||
@router.get("/voices")
|
||||
|
|
@ -33,13 +48,12 @@ def get_voices(
|
|||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return available TTS voices from DB, excluding Polly if disabled."""
|
||||
"""Return LiteLLM-routed TTS models only."""
|
||||
query = db.query(AIModelConfig).filter(
|
||||
AIModelConfig.task == "tts",
|
||||
AIModelConfig.is_active == True,
|
||||
AIModelConfig.model_id.like("local-%"),
|
||||
)
|
||||
if not _polly_enabled():
|
||||
query = query.filter(~AIModelConfig.model_id.like("polly/%"))
|
||||
db_models = query.order_by(AIModelConfig.is_default.desc(), AIModelConfig.name).all()
|
||||
return [{"id": m.model_id, "name": m.name, "is_default": m.is_default} for m in db_models]
|
||||
|
||||
|
|
@ -54,9 +68,9 @@ def text_to_speech(
|
|||
# Rate limit: 60 TTS requests per user per hour (admins/unthrottled users exempt)
|
||||
check_rate_limit(
|
||||
key=f"tts_speak:{current_user.id}",
|
||||
max_calls=60,
|
||||
max_calls=240,
|
||||
window_seconds=3600,
|
||||
detail="You've reached the audio limit (60 clips/hour). The limit resets automatically — try again shortly. Contact an admin if you need this raised.",
|
||||
detail="You've reached the audio limit. The limit resets automatically — try again shortly. Contact an admin if you need this raised.",
|
||||
user=current_user,
|
||||
)
|
||||
if not request.text.strip():
|
||||
|
|
@ -64,23 +78,55 @@ def text_to_speech(
|
|||
|
||||
text = request.text[:2000]
|
||||
|
||||
if request.voice:
|
||||
if request.voice.startswith("polly/") and not _polly_enabled():
|
||||
raise HTTPException(status_code=400, detail="AWS Polly is currently disabled")
|
||||
config = db.query(AIModelConfig).filter(AIModelConfig.model_id == request.voice).first()
|
||||
model_id = config.model_id if config else request.voice
|
||||
api_key = config.api_key if (config and config.api_key) else None
|
||||
else:
|
||||
if request.voice and request.voice.startswith("local-"):
|
||||
config = db.query(AIModelConfig).filter(
|
||||
AIModelConfig.task == "tts",
|
||||
AIModelConfig.is_active == True,
|
||||
AIModelConfig.is_default == True,
|
||||
AIModelConfig.model_id == request.voice,
|
||||
).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
|
||||
if not config:
|
||||
model_id, api_key = _default_tts_model(db)
|
||||
else:
|
||||
model_id = config.model_id
|
||||
api_key = config.api_key or None
|
||||
else:
|
||||
model_id, api_key = _default_tts_model(db)
|
||||
|
||||
audio = ai_service.generate_tts_audio(text, model_id=model_id, api_key=api_key)
|
||||
if audio is None:
|
||||
raise HTTPException(status_code=500, detail="TTS generation failed. Check model configuration.")
|
||||
|
||||
return Response(content=audio, media_type="audio/mpeg")
|
||||
|
||||
|
||||
@router.post("/transcribe")
|
||||
def speech_to_text(
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Transcribe microphone audio using the configured STT model."""
|
||||
check_rate_limit(
|
||||
key=f"stt_transcribe:{current_user.id}",
|
||||
max_calls=120,
|
||||
window_seconds=3600,
|
||||
detail="You've reached the speech transcription limit. Try again shortly.",
|
||||
user=current_user,
|
||||
)
|
||||
audio = file.file.read()
|
||||
if not audio:
|
||||
raise HTTPException(status_code=400, detail="Audio file is empty")
|
||||
if len(audio) > MAX_AUDIO_UPLOAD_BYTES:
|
||||
raise HTTPException(status_code=413, detail="Audio file is too large")
|
||||
|
||||
model_id, api_key = _task_model(db, "stt", "local-parakeet-v3")
|
||||
text = ai_service.transcribe_audio(
|
||||
audio,
|
||||
filename=file.filename or "audio.webm",
|
||||
content_type=file.content_type or "audio/webm",
|
||||
model_id=model_id,
|
||||
api_key=api_key,
|
||||
)
|
||||
if text is None:
|
||||
raise HTTPException(status_code=502, detail="Speech transcription failed. Check STT model configuration.")
|
||||
return {"text": text, "model": model_id}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ class AIModelConfigCreate(BaseModel):
|
|||
model_config = {"protected_namespaces": ()}
|
||||
name: str
|
||||
model_id: str
|
||||
task: str # extraction, tts, general
|
||||
task: str # extraction, tts, stt, teach, keyword, flashcard
|
||||
api_key: str | None = None
|
||||
is_active: bool = True
|
||||
is_default: bool = False
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ from datetime import datetime
|
|||
from pydantic import BaseModel
|
||||
|
||||
class FlashcardDeckCreate(BaseModel):
|
||||
model_config = {"protected_namespaces": ()}
|
||||
|
||||
section_id: int
|
||||
title: str
|
||||
model_id: str | None = None
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ from pydantic import BaseModel
|
|||
|
||||
|
||||
class QuizCreate(BaseModel):
|
||||
model_config = {"protected_namespaces": ()}
|
||||
|
||||
section_id: int | None = None
|
||||
title: str
|
||||
mode: str = "timed" # timed, learning
|
||||
|
|
|
|||
|
|
@ -233,6 +233,56 @@ def _call_model(prompt: str, model_id: str | None, api_key: str | None) -> str:
|
|||
return response.choices[0].message.content
|
||||
|
||||
|
||||
def _parse_json_response(text: str) -> dict:
|
||||
text = text.strip()
|
||||
if text.startswith("```"):
|
||||
text = text.split("\n", 1)[1] if "\n" in text else text[3:]
|
||||
if text.endswith("```"):
|
||||
text = text[:-3]
|
||||
text = text.strip()
|
||||
return json.loads(text)
|
||||
|
||||
|
||||
def transcribe_audio(
|
||||
audio: bytes,
|
||||
filename: str = "audio.webm",
|
||||
content_type: str = "audio/webm",
|
||||
model_id: str | None = None,
|
||||
api_key: str | None = None,
|
||||
) -> str | None:
|
||||
"""Transcribe uploaded audio through LiteLLM's OpenAI-compatible audio endpoint."""
|
||||
import httpx
|
||||
|
||||
if not audio:
|
||||
return None
|
||||
|
||||
base = (settings.LITELLM_API_BASE or "").rstrip("/").removesuffix("/v1")
|
||||
if not base:
|
||||
logger.error("LiteLLM API base not configured for STT")
|
||||
return None
|
||||
|
||||
key = api_key or settings.LITELLM_API_KEY
|
||||
headers = {"Authorization": f"Bearer {key}"} if key else {}
|
||||
try:
|
||||
resp = httpx.post(
|
||||
f"{base}/v1/audio/transcriptions",
|
||||
headers=headers,
|
||||
data={"model": model_id or "local-parakeet-v3", "response_format": "json"},
|
||||
files={"file": (filename or "audio.webm", audio, content_type or "application/octet-stream")},
|
||||
timeout=120,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
try:
|
||||
data = resp.json()
|
||||
if isinstance(data, dict):
|
||||
return str(data.get("text") or data.get("transcript") or data.get("transcription") or "").strip()
|
||||
except ValueError:
|
||||
return resp.text.strip()
|
||||
except Exception as e:
|
||||
logger.error(f"LiteLLM STT failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def extract_questions_no_answers(
|
||||
content: str,
|
||||
page_info: str = "unknown",
|
||||
|
|
@ -330,19 +380,63 @@ def generate_tts_audio(
|
|||
model_id: str | None = None,
|
||||
api_key: str | None = None,
|
||||
) -> bytes | None:
|
||||
"""Generate TTS audio. Supports OpenAI, ElevenLabs, Google Cloud TTS, and AWS Polly.
|
||||
"""Generate TTS audio. Supports local LiteLLM, local Sherpa, OpenAI, ElevenLabs, and Google Cloud TTS.
|
||||
|
||||
model_id conventions:
|
||||
tts-1:alloy → OpenAI TTS (voice after colon)
|
||||
tts-1-hd:nova → OpenAI TTS HD
|
||||
elevenlabs/<voice> → ElevenLabs
|
||||
google/<voice_name> → Google Cloud TTS (e.g. google/en-US-Wavenet-D)
|
||||
polly/<VoiceId> → AWS Polly Neural (e.g. polly/Joanna)
|
||||
sherpa/<profile>:<voice> → Local speech gateway (e.g. sherpa/kokoro:am_adam)
|
||||
local-<model>:<voice> → Local LiteLLM TTS model + voice (e.g. local-kokoro-tts:am_adam)
|
||||
"""
|
||||
import httpx, base64
|
||||
|
||||
use_model = model_id or "tts-1:alloy"
|
||||
|
||||
# ── Local LiteLLM TTS models ─────────────────────────────────
|
||||
if use_model.startswith("local-"):
|
||||
local_model = use_model
|
||||
local_voice = "am_adam" if use_model.startswith("local-kokoro-tts") else "alloy"
|
||||
if ":" in use_model:
|
||||
local_model, local_voice = use_model.split(":", 1)
|
||||
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": local_model, "input": text, "voice": local_voice, "response_format": "mp3"},
|
||||
timeout=90,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
except Exception as e:
|
||||
logger.error(f"Local LiteLLM TTS failed: {e}")
|
||||
return None
|
||||
|
||||
# ── Local Sherpa gateway ───────────────────────────────────
|
||||
if use_model.startswith("sherpa/"):
|
||||
payload = use_model[len("sherpa/"):]
|
||||
profile = payload
|
||||
voice = "0"
|
||||
if ":" in payload:
|
||||
profile, voice = payload.split(":", 1)
|
||||
try:
|
||||
resp = httpx.post(
|
||||
f"{settings.LOCAL_SPEECH_GATEWAY_URL.rstrip('/')}/v1/audio/speech",
|
||||
json={"model": f"sherpa/{profile}", "input": text, "voice": voice, "response_format": "mp3"},
|
||||
timeout=90,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
except Exception as e:
|
||||
logger.error(f"Local Sherpa TTS failed: {e}")
|
||||
return None
|
||||
|
||||
# ── ElevenLabs ─────────────────────────────────────────────
|
||||
if use_model.startswith("elevenlabs/") or use_model.startswith("eleven_labs/"):
|
||||
voice = use_model.split("/", 1)[1]
|
||||
|
|
@ -389,34 +483,6 @@ def generate_tts_audio(
|
|||
logger.error(f"Google TTS failed: {e}")
|
||||
return None
|
||||
|
||||
# ── AWS Polly ───────────────────────────────────────────────
|
||||
if use_model.startswith("polly/"):
|
||||
voice_id = use_model[len("polly/"):]
|
||||
access_key = api_key or settings.AWS_ACCESS_KEY_ID
|
||||
secret_key = settings.AWS_SECRET_ACCESS_KEY
|
||||
region = settings.AWS_REGION or "us-east-1"
|
||||
if not access_key or not secret_key:
|
||||
logger.error("AWS credentials not configured (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY)")
|
||||
return None
|
||||
try:
|
||||
import boto3
|
||||
polly = boto3.client(
|
||||
"polly",
|
||||
aws_access_key_id=access_key,
|
||||
aws_secret_access_key=secret_key,
|
||||
region_name=region,
|
||||
)
|
||||
response = polly.synthesize_speech(
|
||||
Text=text,
|
||||
OutputFormat="mp3",
|
||||
VoiceId=voice_id,
|
||||
Engine="neural",
|
||||
)
|
||||
return response["AudioStream"].read()
|
||||
except Exception as e:
|
||||
logger.error(f"AWS Polly TTS failed: {e}")
|
||||
return None
|
||||
|
||||
# ── OpenAI (default) ────────────────────────────────────────
|
||||
# model_id may encode voice as "tts-1:nova", "tts-1-hd:alloy", etc.
|
||||
clean_model = use_model.replace("openai/", "")
|
||||
|
|
|
|||
|
|
@ -16,3 +16,4 @@ celery_app.conf.task_serializer = "json"
|
|||
celery_app.conf.result_serializer = "json"
|
||||
celery_app.conf.accept_content = ["json"]
|
||||
celery_app.conf.worker_hijack_root_logger = False # Don't override our JSON logging
|
||||
celery_app.conf.broker_connection_retry_on_startup = True
|
||||
|
|
|
|||
|
|
@ -630,4 +630,4 @@ def generate_flashcard_deck(self, job_id: str, section_id: int, user_id: int,
|
|||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
db.close()
|
||||
db.close()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
version: "3.8"
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
|
|
@ -37,6 +35,9 @@ services:
|
|||
volumes:
|
||||
- uploads_data:/app/uploads
|
||||
- chroma_data:/app/chroma_data
|
||||
networks:
|
||||
- default
|
||||
- danvics_speech
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
|
@ -68,6 +69,18 @@ services:
|
|||
- redis_data:/data
|
||||
restart: unless-stopped
|
||||
|
||||
quiz-telegram-bot:
|
||||
build: ./telegram-bot
|
||||
env_file:
|
||||
- ./telegram-bot/.env
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://pedquiz:${POSTGRES_PASSWORD}@postgres:5432/pedquiz
|
||||
- PUBLIC_APP_URL=${APP_URL:-https://pedshub.com}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
# ── Logging: Loki + Promtail + Grafana ──────────────────────────────
|
||||
loki:
|
||||
image: grafana/loki:3.3.2
|
||||
|
|
@ -132,3 +145,7 @@ volumes:
|
|||
loki_data:
|
||||
grafana_data:
|
||||
promtail_positions:
|
||||
|
||||
networks:
|
||||
danvics_speech:
|
||||
external: true
|
||||
|
|
|
|||
|
|
@ -705,7 +705,7 @@ Convert text to speech audio.
|
|||
}
|
||||
```
|
||||
- **Response:** Binary `audio/mpeg` data
|
||||
- **Notes:** Supports OpenAI TTS (`tts-1:alloy`), ElevenLabs (`elevenlabs/<voice_id>`), Google Cloud TTS (`google/<voice_name>`), and AWS Polly (`polly/<VoiceId>`).
|
||||
- **Notes:** Supports LiteLLM-routed local TTS voices, e.g. `local-kokoro-tts:am_adam`. The prefix before `:` is the LiteLLM model route; the suffix is the speaker voice sent to that route.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -993,14 +993,14 @@ Discover available TTS voices from a provider.
|
|||
Get system settings (registration enabled, embedding model, Polly enabled).
|
||||
|
||||
- **Auth:** Admin
|
||||
- **Response:** `{"registration_enabled": bool, "embedding_model": "string", "polly_enabled": bool}`
|
||||
- **Response:** `{"registration_enabled": bool, "embedding_model": "string"}`
|
||||
|
||||
#### PUT `/api/admin/settings`
|
||||
|
||||
Update system settings.
|
||||
|
||||
- **Auth:** Admin
|
||||
- **Request body:** `{"registration_enabled": bool, "embedding_model": "string", "polly_enabled": bool}`
|
||||
- **Request body:** `{"registration_enabled": bool, "embedding_model": "string"}`
|
||||
- **Response:** `{"success": true, "message": "Settings updated"}`
|
||||
|
||||
### Embedding Management
|
||||
|
|
|
|||
|
|
@ -84,7 +84,6 @@ Multi-provider TTS generation. Provider is determined by `model_id` convention:
|
|||
| `tts-1-hd:nova` | OpenAI TTS HD | Voice after colon |
|
||||
| `elevenlabs/<voice_id>` | ElevenLabs | Uses `eleven_turbo_v2_5` model |
|
||||
| `google/<voice_name>` | Google Cloud TTS | Parses language code from voice name |
|
||||
| `polly/<VoiceId>` | AWS Polly Neural | e.g. `polly/Joanna` |
|
||||
|
||||
**OpenAI key resolution order:** per-model API key > `OPENAI_API_KEY` (direct) > `LITELLM_API_KEY` (proxy). When using `OPENAI_API_KEY`, calls go directly to `api.openai.com`; otherwise uses `LITELLM_API_BASE`.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
FROM node:18-alpine AS build
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
|
|
|
|||
4652
frontend/package-lock.json
generated
4652
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -6,33 +6,39 @@
|
|||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@milkdown/core": "^7.6.2",
|
||||
"@milkdown/ctx": "^7.6.2",
|
||||
"@milkdown/plugin-history": "^7.6.2",
|
||||
"@milkdown/plugin-listener": "^7.6.2",
|
||||
"@milkdown/plugin-math": "^7.5.9",
|
||||
"@milkdown/preset-commonmark": "^7.6.2",
|
||||
"@milkdown/preset-gfm": "^7.6.2",
|
||||
"@milkdown/react": "^7.6.2",
|
||||
"@milkdown/theme-nord": "^7.6.2",
|
||||
"axios": "^1.6.7",
|
||||
"katex": "^0.16.11",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.22.0",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"@milkdown/core": "^7.6.2",
|
||||
"@milkdown/ctx": "^7.6.2",
|
||||
"@milkdown/preset-commonmark": "^7.6.2",
|
||||
"@milkdown/preset-gfm": "^7.6.2",
|
||||
"@milkdown/plugin-listener": "^7.6.2",
|
||||
"@milkdown/plugin-history": "^7.6.2",
|
||||
"@milkdown/react": "^7.6.2",
|
||||
"@milkdown/theme-nord": "^7.6.2",
|
||||
"@milkdown/plugin-math": "^7.5.9",
|
||||
"katex": "^0.16.11",
|
||||
"remark-math": "^6.0.0",
|
||||
"rehype-katex": "^7.0.1"
|
||||
"remark-math": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^18.2.55",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"vite": "^5.1.0"
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"vite": "^8.0.11",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,33 +1,39 @@
|
|||
import { lazy, Suspense } from 'react'
|
||||
import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom'
|
||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||
import { ThemeProvider } from './context/ThemeContext'
|
||||
import Navbar from './components/Navbar'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import RegisterPage from './pages/RegisterPage'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
import UploadPage from './pages/UploadPage'
|
||||
import DocumentDetailPage from './pages/DocumentDetailPage'
|
||||
import QuizPage from './pages/QuizPage'
|
||||
import QuizzesPage from './pages/QuizzesPage'
|
||||
import ResultsPage from './pages/ResultsPage'
|
||||
import AdminPage from './pages/AdminPage'
|
||||
import AccountPage from './pages/AccountPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import QuestionBankPage from './pages/QuestionBankPage'
|
||||
import JobsPage from './pages/JobsPage'
|
||||
import TrashPage from './pages/TrashPage'
|
||||
import QuizEditPage from './pages/QuizEditPage'
|
||||
import VerifyEmailPage from './pages/VerifyEmailPage'
|
||||
import ForgotPasswordPage from './pages/ForgotPasswordPage'
|
||||
import ResetPasswordPage from './pages/ResetPasswordPage'
|
||||
import NotFoundPage from './pages/NotFoundPage'
|
||||
import LandingPage from './pages/LandingPage'
|
||||
import FlashcardsPage from './pages/FlashcardsPage'
|
||||
import FlashcardStudyPage from './pages/FlashcardStudyPage'
|
||||
import CoursesPage from './pages/CoursesPage'
|
||||
import CourseDetailPage from './pages/CourseDetailPage'
|
||||
import CourseEditorPage from './pages/CourseEditorPage'
|
||||
import SsoCallbackPage from './pages/SsoCallbackPage'
|
||||
|
||||
const LoginPage = lazy(() => import('./pages/LoginPage'))
|
||||
const RegisterPage = lazy(() => import('./pages/RegisterPage'))
|
||||
const DashboardPage = lazy(() => import('./pages/DashboardPage'))
|
||||
const UploadPage = lazy(() => import('./pages/UploadPage'))
|
||||
const DocumentDetailPage = lazy(() => import('./pages/DocumentDetailPage'))
|
||||
const QuizPage = lazy(() => import('./pages/QuizPage'))
|
||||
const QuizzesPage = lazy(() => import('./pages/QuizzesPage'))
|
||||
const ResultsPage = lazy(() => import('./pages/ResultsPage'))
|
||||
const AdminPage = lazy(() => import('./pages/AdminPage'))
|
||||
const AccountPage = lazy(() => import('./pages/AccountPage'))
|
||||
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
|
||||
const QuestionBankPage = lazy(() => import('./pages/QuestionBankPage'))
|
||||
const JobsPage = lazy(() => import('./pages/JobsPage'))
|
||||
const TrashPage = lazy(() => import('./pages/TrashPage'))
|
||||
const QuizEditPage = lazy(() => import('./pages/QuizEditPage'))
|
||||
const VerifyEmailPage = lazy(() => import('./pages/VerifyEmailPage'))
|
||||
const ForgotPasswordPage = lazy(() => import('./pages/ForgotPasswordPage'))
|
||||
const ResetPasswordPage = lazy(() => import('./pages/ResetPasswordPage'))
|
||||
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'))
|
||||
const LandingPage = lazy(() => import('./pages/LandingPage'))
|
||||
const FlashcardsPage = lazy(() => import('./pages/FlashcardsPage'))
|
||||
const FlashcardStudyPage = lazy(() => import('./pages/FlashcardStudyPage'))
|
||||
const CoursesPage = lazy(() => import('./pages/CoursesPage'))
|
||||
const CourseDetailPage = lazy(() => import('./pages/CourseDetailPage'))
|
||||
const CourseEditorPage = lazy(() => import('./pages/CourseEditorPage'))
|
||||
const SsoCallbackPage = lazy(() => import('./pages/SsoCallbackPage'))
|
||||
|
||||
function LoadingFallback() {
|
||||
return <div className="loading"><div className="spinner" /></div>
|
||||
}
|
||||
|
||||
// Layout wrapper for authenticated app pages (Navbar + container + footer)
|
||||
function AppLayout() {
|
||||
|
|
@ -47,7 +53,7 @@ function AppLayout() {
|
|||
// Guard: redirect to /home if not logged in, or to / if not moderator
|
||||
function RequireAuth({ moderator = false }) {
|
||||
const { user, loading } = useAuth()
|
||||
if (loading) return <div className="loading"><div className="spinner" /></div>
|
||||
if (loading) return <LoadingFallback />
|
||||
if (!user) return <Navigate to="/home" replace />
|
||||
if (moderator && user.role !== 'admin' && user.role !== 'moderator') return <Navigate to="/" replace />
|
||||
return <Outlet />
|
||||
|
|
@ -55,52 +61,54 @@ function RequireAuth({ moderator = false }) {
|
|||
|
||||
function AppRoutes() {
|
||||
const { user, loading } = useAuth()
|
||||
if (loading) return <div className="loading"><div className="spinner" /></div>
|
||||
if (loading) return <LoadingFallback />
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
{/* Always public */}
|
||||
<Route path="/home" element={<LandingPage />} />
|
||||
<Route path="/login" element={user ? <Navigate to="/" replace /> : <LoginPage />} />
|
||||
<Route path="/register" element={user ? <Navigate to="/" replace /> : <RegisterPage />} />
|
||||
<Route path="/verify-email" element={<VerifyEmailPage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
<Route path="/sso-callback" element={<SsoCallbackPage />} />
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<Routes>
|
||||
{/* Always public */}
|
||||
<Route path="/home" element={<LandingPage />} />
|
||||
<Route path="/login" element={user ? <Navigate to="/" replace /> : <LoginPage />} />
|
||||
<Route path="/register" element={user ? <Navigate to="/" replace /> : <RegisterPage />} />
|
||||
<Route path="/verify-email" element={<VerifyEmailPage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
<Route path="/sso-callback" element={<SsoCallbackPage />} />
|
||||
|
||||
{/* Authenticated app — wrapped in AppLayout */}
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/quizzes" element={<QuizzesPage />} />
|
||||
<Route path="/quizzes/:id" element={<QuizPage />} />
|
||||
<Route path="/results/:id" element={<ResultsPage />} />
|
||||
<Route path="/documents/:id" element={<DocumentDetailPage />} />
|
||||
<Route path="/account" element={<AccountPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/question-bank" element={<QuestionBankPage />} />
|
||||
<Route path="/flashcards" element={<FlashcardsPage />} />
|
||||
<Route path="/flashcards/:deckId/study" element={<FlashcardStudyPage />} />
|
||||
<Route path="/courses" element={<CoursesPage />} />
|
||||
<Route path="/courses/:courseId" element={<CourseDetailPage />} />
|
||||
<Route path="/courses/:courseId/edit" element={<CourseEditorPage />} />
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
{/* Authenticated app — wrapped in AppLayout */}
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/quizzes" element={<QuizzesPage />} />
|
||||
<Route path="/quizzes/:id" element={<QuizPage />} />
|
||||
<Route path="/results/:id" element={<ResultsPage />} />
|
||||
<Route path="/documents/:id" element={<DocumentDetailPage />} />
|
||||
<Route path="/account" element={<AccountPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/question-bank" element={<QuestionBankPage />} />
|
||||
<Route path="/flashcards" element={<FlashcardsPage />} />
|
||||
<Route path="/flashcards/:deckId/study" element={<FlashcardStudyPage />} />
|
||||
<Route path="/courses" element={<CoursesPage />} />
|
||||
<Route path="/courses/:courseId" element={<CourseDetailPage />} />
|
||||
<Route path="/courses/:courseId/edit" element={<CourseEditorPage />} />
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Moderator-only */}
|
||||
<Route element={<RequireAuth moderator />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/upload" element={<UploadPage />} />
|
||||
<Route path="/quizzes/:id/edit" element={<QuizEditPage />} />
|
||||
<Route path="/jobs" element={<JobsPage />} />
|
||||
<Route path="/trash" element={<TrashPage />} />
|
||||
{/* Moderator-only */}
|
||||
<Route element={<RequireAuth moderator />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/upload" element={<UploadPage />} />
|
||||
<Route path="/quizzes/:id/edit" element={<QuizEditPage />} />
|
||||
<Route path="/jobs" element={<JobsPage />} />
|
||||
<Route path="/trash" element={<TrashPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Catch-all */}
|
||||
<Route path="*" element={user ? <NotFoundPage /> : <Navigate to="/home" replace />} />
|
||||
</Routes>
|
||||
{/* Catch-all */}
|
||||
<Route path="*" element={user ? <NotFoundPage /> : <Navigate to="/home" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
50
frontend/src/components/InProgressQuizzes.jsx
Normal file
50
frontend/src/components/InProgressQuizzes.jsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
import ConfirmButton from './ConfirmButton'
|
||||
|
||||
export default function InProgressQuizzes() {
|
||||
const [inProgress, setInProgress] = useState([])
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/attempts/in-progress').then(res => setInProgress(res.data)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const deleteAttempt = async (attemptId) => {
|
||||
await api.delete(`/attempts/${attemptId}`)
|
||||
setInProgress(prev => prev.filter(a => a.attempt_id !== attemptId))
|
||||
}
|
||||
|
||||
if (inProgress.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: 16, borderLeft: '4px solid #f59e0b' }}>
|
||||
<h2 style={{ marginBottom: 12, fontSize: '1rem', color: '#92400e' }}>
|
||||
⏸ In Progress ({inProgress.length})
|
||||
</h2>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{inProgress.map(a => (
|
||||
<div key={a.attempt_id} style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
padding: '10px 14px', background: 'var(--bg)', borderRadius: 8, gap: 12,
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: '0.9rem' }}>{a.quiz_title}</div>
|
||||
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)' }}>
|
||||
Started {new Date(a.started_at).toLocaleDateString()} · {a.total_questions} questions
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => navigate(`/quizzes/${a.quiz_id}`)}>Resume</button>
|
||||
<ConfirmButton
|
||||
label="Delete" confirmLabel="Yes, delete"
|
||||
onConfirm={() => deleteAttempt(a.attempt_id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
245
frontend/src/components/MyNote.jsx
Normal file
245
frontend/src/components/MyNote.jsx
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import api from '../api/client'
|
||||
|
||||
const TAB_POSITION_KEY = 'pedshub_mynote_tab_position'
|
||||
|
||||
function formatSavedAt(value) {
|
||||
if (!value) return 'Not saved yet'
|
||||
return `Saved ${new Date(value).toLocaleString()}`
|
||||
}
|
||||
|
||||
function clampPosition(x, y, width, height) {
|
||||
const margin = 8
|
||||
return {
|
||||
x: Math.min(Math.max(margin, x), Math.max(margin, window.innerWidth - width - margin)),
|
||||
y: Math.min(Math.max(margin, y), Math.max(margin, window.innerHeight - height - margin)),
|
||||
}
|
||||
}
|
||||
|
||||
export default function MyNote({ variant = 'tab' }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [content, setContent] = useState('')
|
||||
const [updatedAt, setUpdatedAt] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [position, setPosition] = useState(null)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const tabRef = useRef(null)
|
||||
const saveRef = useRef(null)
|
||||
const loadedRef = useRef(false)
|
||||
const dragRef = useRef(null)
|
||||
const draggedRef = useRef(false)
|
||||
const textareaRef = useRef(null)
|
||||
|
||||
const loadNote = async () => {
|
||||
if (loadedRef.current) return
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.get('/mynote')
|
||||
setContent(res.data.content || '')
|
||||
setUpdatedAt(res.data.updated_at || null)
|
||||
loadedRef.current = true
|
||||
} catch {
|
||||
setError('Could not load MyNote')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (variant === 'card') loadNote()
|
||||
}, [variant])
|
||||
|
||||
useEffect(() => {
|
||||
if (variant !== 'tab') return
|
||||
const rect = tabRef.current?.getBoundingClientRect()
|
||||
let next = null
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(TAB_POSITION_KEY) || 'null')
|
||||
if (saved && Number.isFinite(saved.x) && Number.isFinite(saved.y)) {
|
||||
next = clampPosition(saved.x, saved.y, rect?.width || 120, rect?.height || 48)
|
||||
}
|
||||
} catch {
|
||||
next = null
|
||||
}
|
||||
if (!next && rect) next = { x: rect.left, y: rect.top }
|
||||
if (next) setPosition(next)
|
||||
|
||||
const handleResize = () => {
|
||||
setPosition(current => {
|
||||
if (!current) return current
|
||||
const resizedRect = tabRef.current?.getBoundingClientRect()
|
||||
const clamped = clampPosition(current.x, current.y, resizedRect?.width || 120, resizedRect?.height || 48)
|
||||
localStorage.setItem(TAB_POSITION_KEY, JSON.stringify(clamped))
|
||||
return clamped
|
||||
})
|
||||
}
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [variant])
|
||||
|
||||
useEffect(() => {
|
||||
if (variant !== 'tab') return
|
||||
setPosition(current => {
|
||||
if (!current) return current
|
||||
const rect = tabRef.current?.getBoundingClientRect()
|
||||
if (!rect) return current
|
||||
return clampPosition(current.x, current.y, rect.width, rect.height)
|
||||
})
|
||||
}, [open, variant])
|
||||
|
||||
useEffect(() => {
|
||||
if (!loadedRef.current) return
|
||||
clearTimeout(saveRef.current)
|
||||
saveRef.current = setTimeout(async () => {
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.put('/mynote', { content })
|
||||
setUpdatedAt(res.data.updated_at || null)
|
||||
} catch {
|
||||
setError('Could not save MyNote')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, 700)
|
||||
return () => clearTimeout(saveRef.current)
|
||||
}, [content])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || loading) return
|
||||
requestAnimationFrame(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
const end = textarea.value.length
|
||||
textarea.focus()
|
||||
textarea.setSelectionRange(end, end)
|
||||
textarea.scrollTop = textarea.scrollHeight
|
||||
})
|
||||
}, [open, loading])
|
||||
|
||||
const openNote = () => {
|
||||
setOpen(true)
|
||||
loadNote()
|
||||
}
|
||||
|
||||
const startDrag = event => {
|
||||
if (variant !== 'tab' || event.button !== 0) return
|
||||
if (event.target.closest('button, textarea') && event.currentTarget !== event.target) return
|
||||
const rect = tabRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
dragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
offsetX: event.clientX - rect.left,
|
||||
offsetY: event.clientY - rect.top,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
moved: false,
|
||||
position: { x: rect.left, y: rect.top },
|
||||
}
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId)
|
||||
}
|
||||
|
||||
const moveDrag = event => {
|
||||
const drag = dragRef.current
|
||||
if (!drag || drag.pointerId !== event.pointerId) return
|
||||
const rect = tabRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
const next = clampPosition(event.clientX - drag.offsetX, event.clientY - drag.offsetY, rect.width, rect.height)
|
||||
drag.position = next
|
||||
if (Math.abs(event.clientX - drag.startX) > 4 || Math.abs(event.clientY - drag.startY) > 4) {
|
||||
drag.moved = true
|
||||
setDragging(true)
|
||||
}
|
||||
setPosition(next)
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const stopDrag = event => {
|
||||
const drag = dragRef.current
|
||||
if (!drag || drag.pointerId !== event.pointerId) return
|
||||
if (drag.moved) {
|
||||
localStorage.setItem(TAB_POSITION_KEY, JSON.stringify(drag.position))
|
||||
}
|
||||
draggedRef.current = drag.moved
|
||||
dragRef.current = null
|
||||
setDragging(false)
|
||||
setTimeout(() => {
|
||||
draggedRef.current = false
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const openFromTabButton = event => {
|
||||
if (draggedRef.current) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
openNote()
|
||||
}
|
||||
|
||||
const preview = content.trim()
|
||||
? content.trim().slice(0, 150) + (content.trim().length > 150 ? '...' : '')
|
||||
: 'Your single study note will appear here once you start writing during quizzes.'
|
||||
|
||||
const editor = (
|
||||
<div className="mynote-editor">
|
||||
<div
|
||||
className={`mynote-editor-header ${variant === 'tab' ? 'mynote-drag-handle' : ''}`}
|
||||
onPointerDown={variant === 'tab' ? startDrag : undefined}
|
||||
>
|
||||
<div>
|
||||
<div className="mynote-title">MyNote</div>
|
||||
<div className="mynote-status">{variant === 'tab' ? 'Drag this header to move. ' : ''}{saving ? 'Saving...' : formatSavedAt(updatedAt)}</div>
|
||||
</div>
|
||||
{variant === 'tab' && <button className="mynote-close" type="button" onClick={() => setOpen(false)}>Collapse</button>}
|
||||
</div>
|
||||
{error && <div className="mynote-error">{error}</div>}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={e => setContent(e.target.value)}
|
||||
placeholder={loading ? 'Loading MyNote...' : 'Capture one running note across all quizzes...'}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (variant === 'card') {
|
||||
return (
|
||||
<div className="card mynote-card">
|
||||
<div className="mynote-card-copy">
|
||||
<div className="mynote-eyebrow">Saved across all quiz sessions</div>
|
||||
<h2>MyNote</h2>
|
||||
<p>{preview}</p>
|
||||
<span>{formatSavedAt(updatedAt)}</span>
|
||||
</div>
|
||||
<button className="btn btn-primary" type="button" onClick={openNote}>Open MyNote</button>
|
||||
{open && (
|
||||
<div className="mynote-modal" role="dialog" aria-modal="true" aria-label="MyNote" onClick={() => setOpen(false)}>
|
||||
<div className="mynote-modal-panel" onClick={e => e.stopPropagation()}>
|
||||
<button className="mynote-modal-close" type="button" onClick={() => setOpen(false)} aria-label="Close MyNote">×</button>
|
||||
{editor}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={tabRef}
|
||||
className={`mynote-tab ${open ? 'open' : ''} ${dragging ? 'dragging' : ''}`}
|
||||
style={position ? { left: position.x, top: position.y, right: 'auto', bottom: 'auto' } : undefined}
|
||||
onPointerMove={moveDrag}
|
||||
onPointerUp={stopDrag}
|
||||
onPointerCancel={stopDrag}
|
||||
>
|
||||
{!open ? (
|
||||
<button className="mynote-tab-button" type="button" onPointerDown={startDrag} onClick={openFromTabButton}>MyNote</button>
|
||||
) : editor}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -61,7 +61,7 @@
|
|||
--navbar-fg: #f0e8d8;
|
||||
--badge-radius: 4px;
|
||||
--font-body: 'Source Serif 4', Georgia, serif;
|
||||
--font-heading: 'Playfair Display', 'Source Serif 4', serif;
|
||||
--font-heading: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
--font-ui: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
|
|
@ -71,9 +71,9 @@ body[data-theme="markdown"] {
|
|||
font-size: 15.5px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
[data-theme="markdown"] .navbar .logo { color: #e0a84a; font-family: var(--font-heading); letter-spacing: 0; }
|
||||
[data-theme="markdown"] h1 { font-family: var(--font-heading); font-weight: 700; letter-spacing: -0.02em; }
|
||||
[data-theme="markdown"] h2, [data-theme="markdown"] .card h2 { font-family: var(--font-heading); font-weight: 600; }
|
||||
[data-theme="markdown"] .navbar .logo { color: #e0a84a; font-family: var(--font-heading); letter-spacing: -0.02em; }
|
||||
[data-theme="markdown"] h1 { font-family: var(--font-heading); font-weight: 760; letter-spacing: -0.03em; line-height: 1.12; }
|
||||
[data-theme="markdown"] h2, [data-theme="markdown"] .card h2 { font-family: var(--font-heading); font-weight: 680; letter-spacing: -0.02em; line-height: 1.2; }
|
||||
[data-theme="markdown"] h3 { font-family: var(--font-ui); font-weight: 600; }
|
||||
[data-theme="markdown"] .card h2 { border-bottom: 1px solid var(--border); padding-bottom: 10px; margin-bottom: 18px; }
|
||||
[data-theme="markdown"] .question-card { border-left: 3px solid var(--primary); }
|
||||
|
|
@ -135,6 +135,34 @@ body {
|
|||
}
|
||||
.card h2 { margin-bottom: 16px; font-size: 1.15rem; color: var(--text); font-weight: 600; }
|
||||
|
||||
.quiz-header-card .quiz-header-title,
|
||||
[data-theme="markdown"] .quiz-header-card .quiz-header-title {
|
||||
margin: 0 0 2px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
font-family: var(--font-ui, 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif);
|
||||
font-size: clamp(0.98rem, 1.8vw, 1.14rem);
|
||||
font-weight: 720;
|
||||
line-height: 1.18;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.quiz-nav-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.quiz-nav-controls-top {
|
||||
margin: -4px 0 16px;
|
||||
padding: 10px 12px;
|
||||
background: color-mix(in srgb, var(--card-bg) 92%, transparent);
|
||||
border: var(--card-border);
|
||||
border-radius: var(--card-radius);
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
/* ── Buttons ────────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
|
|
@ -182,6 +210,264 @@ body {
|
|||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
.question-card h3 { font-size: 1.05rem; line-height: 1.6; margin-bottom: 20px; color: var(--text); font-weight: 600; }
|
||||
.question-image-preview {
|
||||
display: block;
|
||||
margin: 10px 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: zoom-in;
|
||||
text-align: left;
|
||||
}
|
||||
.question-image-preview img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 280px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.image-lightbox {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
padding: 24px;
|
||||
background: rgba(15, 23, 42, 0.82);
|
||||
cursor: zoom-out;
|
||||
}
|
||||
.image-lightbox-viewport {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: auto;
|
||||
cursor: default;
|
||||
}
|
||||
.image-lightbox img {
|
||||
display: block;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
|
||||
cursor: default;
|
||||
}
|
||||
.image-lightbox-controls {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
left: 50%;
|
||||
z-index: 1001;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 9px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.82);
|
||||
color: white;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.image-lightbox-controls button {
|
||||
min-width: 34px;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
.image-lightbox-controls button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
.image-lightbox-controls span {
|
||||
min-width: 48px;
|
||||
text-align: center;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.image-lightbox-close {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mynote-tab {
|
||||
position: fixed;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
z-index: 45;
|
||||
}
|
||||
.mynote-tab.dragging {
|
||||
user-select: none;
|
||||
}
|
||||
.mynote-tab-button {
|
||||
border: 1px solid rgba(37, 99, 235, 0.22);
|
||||
border-radius: 999px;
|
||||
padding: 11px 18px;
|
||||
background: linear-gradient(135deg, #2563eb, #7c3aed);
|
||||
color: white;
|
||||
box-shadow: 0 16px 40px rgba(37, 99, 235, 0.28);
|
||||
cursor: grab;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
touch-action: none;
|
||||
}
|
||||
.mynote-editor {
|
||||
width: min(420px, calc(100vw - 28px));
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
border-radius: 18px;
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--card-bg) 96%, transparent), var(--card-bg)),
|
||||
radial-gradient(circle at top left, rgba(37, 99, 235, 0.12), transparent 42%);
|
||||
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.24);
|
||||
overflow: hidden;
|
||||
}
|
||||
.mynote-editor-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 16px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.mynote-drag-handle {
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
.mynote-tab.dragging .mynote-tab-button,
|
||||
.mynote-tab.dragging .mynote-drag-handle {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.mynote-title {
|
||||
font-weight: 850;
|
||||
font-size: 1rem;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--text);
|
||||
}
|
||||
.mynote-status {
|
||||
margin-top: 2px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.mynote-close,
|
||||
.mynote-modal-close {
|
||||
border: 0;
|
||||
background: var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mynote-close { padding: 6px 10px; font-size: 0.76rem; }
|
||||
.mynote-error {
|
||||
margin: 10px 16px 0;
|
||||
color: var(--wrong-fg);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.mynote-editor textarea {
|
||||
width: 100%;
|
||||
min-height: 260px;
|
||||
resize: vertical;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
padding: 16px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.mynote-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
border-left: 4px solid #7c3aed;
|
||||
}
|
||||
.mynote-card-copy { min-width: 0; }
|
||||
.mynote-eyebrow {
|
||||
margin-bottom: 4px;
|
||||
color: #7c3aed;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.mynote-card h2 { margin: 0 0 6px; }
|
||||
.mynote-card p {
|
||||
margin: 0 0 6px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.mynote-card span {
|
||||
color: var(--text-subtle);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
.mynote-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 18px;
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
}
|
||||
.mynote-modal-panel {
|
||||
position: relative;
|
||||
width: min(720px, 100%);
|
||||
}
|
||||
.mynote-modal-panel .mynote-editor { width: 100%; }
|
||||
.mynote-modal-panel .mynote-editor textarea { min-height: min(58vh, 520px); }
|
||||
.mynote-modal-close {
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
right: -12px;
|
||||
z-index: 1;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
background: var(--navbar-bg);
|
||||
color: white;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.manual-highlight-toolbar {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
padding: 4px;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--card-bg) 88%, transparent);
|
||||
}
|
||||
.manual-highlight-segment { user-select: text; -webkit-user-select: text; touch-action: auto; }
|
||||
.manual-highlight-active {
|
||||
background: linear-gradient(transparent 38%, rgba(253, 224, 71, 0.72) 38%);
|
||||
border-radius: 2px;
|
||||
cursor: context-menu;
|
||||
box-decoration-break: clone;
|
||||
-webkit-box-decoration-break: clone;
|
||||
}
|
||||
.speech-highlight-active {
|
||||
background: rgba(96, 165, 250, 0.18);
|
||||
box-shadow: inset 0 -2px 0 rgba(59, 130, 246, 0.72);
|
||||
border-radius: 3px;
|
||||
padding: 0 2px;
|
||||
box-decoration-break: clone;
|
||||
-webkit-box-decoration-break: clone;
|
||||
}
|
||||
.manual-highlight-active.speech-highlight-active {
|
||||
background:
|
||||
linear-gradient(transparent 38%, rgba(253, 224, 71, 0.72) 38%),
|
||||
rgba(96, 165, 250, 0.18);
|
||||
}
|
||||
|
||||
/* Options */
|
||||
.question-card .options { display: flex; flex-direction: column; gap: 10px; }
|
||||
|
|
@ -189,7 +475,7 @@ body {
|
|||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 13px 16px; border: 1.5px solid var(--border); border-radius: 8px;
|
||||
cursor: pointer; transition: border-color 0.12s, background 0.12s;
|
||||
background: var(--option-bg); font-size: 0.9rem; color: var(--text); user-select: none;
|
||||
background: var(--option-bg); font-size: 0.9rem; color: var(--text); user-select: text;
|
||||
-webkit-tap-highlight-color: transparent; touch-action: manipulation;
|
||||
}
|
||||
.question-card .option:hover { background: var(--option-hover); border-color: var(--text-subtle); }
|
||||
|
|
@ -349,6 +635,43 @@ body {
|
|||
/* Mobile quiz: hide sidebar, show toggle */
|
||||
.quiz-sidebar { display: none; }
|
||||
.quiz-nav-toggle { display: inline-flex; }
|
||||
.quiz-nav-controls { gap: 6px; }
|
||||
.quiz-nav-controls .btn { flex: 1; justify-content: center; padding-left: 10px; padding-right: 10px; }
|
||||
.quiz-nav-controls .quiz-nav-toggle { flex: 0 0 auto; }
|
||||
.manual-highlight-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: auto;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
position: sticky;
|
||||
top: 54px;
|
||||
z-index: 5;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
.manual-highlight-toolbar .btn {
|
||||
justify-content: center;
|
||||
min-height: 36px;
|
||||
white-space: normal;
|
||||
}
|
||||
.mynote-tab {
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
}
|
||||
.mynote-tab.open {
|
||||
left: 10px;
|
||||
}
|
||||
.mynote-editor textarea {
|
||||
min-height: 220px;
|
||||
}
|
||||
.mynote-card {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
.mynote-card .btn {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Lesson content (markdown/HTML) ───────────────────────────── */
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import api from '../api/client'
|
|||
import Dialog from '../components/Dialog'
|
||||
import { useDialog } from '../hooks/useDialog'
|
||||
|
||||
const TASKS = ['extraction', 'tts', 'teach', 'keyword', 'flashcard']
|
||||
const TASKS = ['extraction', 'tts', 'stt', 'teach', 'keyword', 'flashcard']
|
||||
|
||||
export default function AdminPage() {
|
||||
const { user } = useAuth()
|
||||
|
|
@ -14,17 +14,15 @@ export default function AdminPage() {
|
|||
const [tab, setTab] = useState('models')
|
||||
const [users, setUsers] = useState([])
|
||||
const [models, setModels] = useState([])
|
||||
const [settings, setSettings] = useState({ registration_enabled: true, embedding_model: '', polly_enabled: true })
|
||||
const [settings, setSettings] = useState({ registration_enabled: true, embedding_model: '' })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
|
||||
const [newModel, setNewModel] = useState({ name: '', model_id: '', task: 'extraction', api_key: '', is_active: true, is_default: false })
|
||||
const [newModel, setNewModel] = useState({ name: '', model_id: '', task: 'extraction', is_active: true, is_default: false })
|
||||
const [newUser, setNewUser] = useState({ name: '', email: '', password: '' })
|
||||
|
||||
// LiteLLM search state
|
||||
const [searchApiKey, setSearchApiKey] = useState('')
|
||||
const [searchApiBase, setSearchApiBase] = useState('')
|
||||
const [searchResults, setSearchResults] = useState([])
|
||||
const [searchLoading, setSearchLoading] = useState(false)
|
||||
const [searchError, setSearchError] = useState('')
|
||||
|
|
@ -32,8 +30,7 @@ export default function AdminPage() {
|
|||
const [searchTaskHint, setSearchTaskHint] = useState('extraction')
|
||||
|
||||
// TTS voice discovery state
|
||||
const [ttsProvider, setTtsProvider] = useState('elevenlabs')
|
||||
const [ttsSearchKey, setTtsSearchKey] = useState('')
|
||||
const [ttsProvider, setTtsProvider] = useState('litellm')
|
||||
const [ttsVoices, setTtsVoices] = useState([])
|
||||
const [ttsVoicesLoading, setTtsVoicesLoading] = useState(false)
|
||||
const [ttsVoicesError, setTtsVoicesError] = useState('')
|
||||
|
|
@ -115,7 +112,7 @@ export default function AdminPage() {
|
|||
try {
|
||||
await api.post('/admin/models', newModel)
|
||||
setSuccess('Model added')
|
||||
setNewModel({ name: '', model_id: '', task: 'extraction', api_key: '', is_active: true, is_default: false })
|
||||
setNewModel({ name: '', model_id: '', task: 'extraction', is_active: true, is_default: false })
|
||||
loadData(false)
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Failed to add model')
|
||||
|
|
@ -173,10 +170,7 @@ export default function AdminPage() {
|
|||
setSearchResults([])
|
||||
setSearchLoading(true)
|
||||
try {
|
||||
const res = await api.post('/admin/litellm/models', {
|
||||
api_key: searchApiKey || null,
|
||||
api_base: searchApiBase || null,
|
||||
})
|
||||
const res = await api.post('/admin/litellm/models', {})
|
||||
setSearchResults(res.data.models)
|
||||
} catch (err) {
|
||||
setSearchError(err.response?.data?.detail || 'Failed to query models')
|
||||
|
|
@ -188,12 +182,11 @@ export default function AdminPage() {
|
|||
const searchEmbeddingModels = async () => {
|
||||
setEmbedSearchError('')
|
||||
setEmbedSearchResults([])
|
||||
setEmbedSearchFilter('')
|
||||
setEmbedSearchLoading(true)
|
||||
try {
|
||||
const res = await api.post('/admin/litellm/models', {})
|
||||
const all = res.data.models || []
|
||||
// Filter to likely embedding models
|
||||
setEmbedSearchResults(all)
|
||||
const res = await api.post('/admin/litellm/models', { mode: 'embedding' })
|
||||
setEmbedSearchResults(res.data.models || [])
|
||||
} catch (err) {
|
||||
setEmbedSearchError(err.response?.data?.detail || 'Failed to query models')
|
||||
} finally {
|
||||
|
|
@ -255,7 +248,6 @@ export default function AdminPage() {
|
|||
try {
|
||||
const res = await api.post('/admin/tts/voices', {
|
||||
provider: ttsProvider,
|
||||
api_key: ttsSearchKey || null,
|
||||
})
|
||||
setTtsVoices(res.data.voices)
|
||||
} catch (err) {
|
||||
|
|
@ -325,27 +317,9 @@ export default function AdminPage() {
|
|||
<div className="card">
|
||||
<h2>Search LLM Models</h2>
|
||||
<p style={{ color: '#64748b', fontSize: '0.85rem', marginBottom: 16 }}>
|
||||
Query your LiteLLM proxy or any OpenAI-compatible API. Works for <strong>extraction</strong>, <strong>teach</strong>, and <strong>general</strong> tasks.
|
||||
Query the configured LiteLLM proxy from the server environment. Works for chat, extraction, STT, and other configured tasks.
|
||||
</p>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>API Base URL (optional — uses .env if blank)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={searchApiBase}
|
||||
onChange={e => setSearchApiBase(e.target.value)}
|
||||
placeholder="e.g. https://litellm.myserver.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>API Key (optional — uses .env if blank)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={searchApiKey}
|
||||
onChange={e => setSearchApiKey(e.target.value)}
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Adding model for task</label>
|
||||
<select value={searchTaskHint} onChange={e => setSearchTaskHint(e.target.value)}>
|
||||
|
|
@ -403,9 +377,11 @@ export default function AdminPage() {
|
|||
{task} Models
|
||||
<span style={{ fontSize: '0.75rem', color: '#64748b', marginLeft: 8, fontWeight: 400 }}>
|
||||
{task === 'extraction' ? '— AI that extracts questions from PDFs' :
|
||||
task === 'tts' ? '— Text-to-speech voices' :
|
||||
task === 'teach' ? '— AI tutor shown in study mode chat' :
|
||||
'— General purpose AI'}
|
||||
task === 'tts' ? '— Text-to-speech voices' :
|
||||
task === 'stt' ? '— Speech-to-text models' :
|
||||
task === 'teach' ? '— AI tutor shown in study mode chat' :
|
||||
task === 'keyword' ? '— Keyword and topic classification' :
|
||||
'— Flashcard generation'}
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
|
|
@ -416,13 +392,8 @@ export default function AdminPage() {
|
|||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 8 }}>
|
||||
<select value={ttsProvider} onChange={e => { setTtsProvider(e.target.value); setTtsVoices([]) }}
|
||||
style={{ padding: '5px 10px', borderRadius: 6, border: '1px solid var(--border)', fontSize: '0.85rem', background: 'var(--input-bg)', color: 'var(--text)' }}>
|
||||
<option value="elevenlabs">ElevenLabs</option>
|
||||
<option value="polly">AWS Polly (Neural)</option>
|
||||
<option value="openai">OpenAI TTS</option>
|
||||
<option value="litellm">LiteLLM Local</option>
|
||||
</select>
|
||||
<input type="password" value={ttsSearchKey} onChange={e => setTtsSearchKey(e.target.value)}
|
||||
placeholder="API key (uses .env if blank)"
|
||||
style={{ flex: 1, minWidth: 140, padding: '5px 10px', borderRadius: 6, border: '1px solid var(--border)', fontSize: '0.85rem', background: 'var(--input-bg)', color: 'var(--text)' }} />
|
||||
<button className="btn btn-primary btn-sm" onClick={searchTtsVoices} disabled={ttsVoicesLoading}>
|
||||
{ttsVoicesLoading ? 'Searching...' : 'Search'}
|
||||
</button>
|
||||
|
|
@ -546,10 +517,6 @@ export default function AdminPage() {
|
|||
<label>Model ID (LiteLLM format)</label>
|
||||
<input type="text" value={newModel.model_id} onChange={e => setNewModel(m => ({ ...m, model_id: e.target.value }))} placeholder="e.g. gpt-4o-mini" required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>API Key (optional override)</label>
|
||||
<input type="password" value={newModel.api_key} onChange={e => setNewModel(m => ({ ...m, api_key: e.target.value }))} placeholder="Leave blank to use .env key" />
|
||||
</div>
|
||||
<div className="form-group" style={{ display: 'flex', gap: 16, alignItems: 'center', marginTop: 24 }}>
|
||||
<label style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<input type="checkbox" checked={newModel.is_default} onChange={e => setNewModel(m => ({ ...m, is_default: e.target.checked }))} />
|
||||
|
|
@ -679,37 +646,6 @@ export default function AdminPage() {
|
|||
</label>
|
||||
</div>
|
||||
|
||||
{/* AWS Polly */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 0', borderBottom: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<strong style={{ display: 'block', marginBottom: 4 }}>AWS Polly Voices</strong>
|
||||
<span style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
|
||||
Enable or disable AWS Polly TTS voices globally. Individual voices can still be toggled in the AI Models tab.
|
||||
</span>
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.polly_enabled}
|
||||
onChange={async (e) => {
|
||||
const enabled = e.target.checked
|
||||
setSettings(s => ({ ...s, polly_enabled: enabled }))
|
||||
try {
|
||||
await api.put('/admin/settings', { polly_enabled: enabled })
|
||||
setSuccess(`AWS Polly ${enabled ? 'enabled' : 'disabled'}`)
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Failed to update setting')
|
||||
setSettings(s => ({ ...s, polly_enabled: !enabled }))
|
||||
}
|
||||
}}
|
||||
style={{ width: 'auto', accentColor: 'var(--primary)' }}
|
||||
/>
|
||||
<span style={{ fontSize: '0.9rem', fontWeight: 600 }}>
|
||||
{settings.polly_enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* SSO Only */}
|
||||
{settings.sso_configured && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 0', borderBottom: '1px solid var(--border)' }}>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { useState, useEffect } from 'react'
|
|||
import { Link } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
import LineChart from '../components/LineChart'
|
||||
import InProgressQuizzes from '../components/InProgressQuizzes'
|
||||
import MyNote from '../components/MyNote'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
function greeting(name) {
|
||||
|
|
@ -13,7 +15,6 @@ function greeting(name) {
|
|||
|
||||
export default function DashboardPage() {
|
||||
const { user } = useAuth()
|
||||
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
|
||||
const [stats, setStats] = useState(null)
|
||||
const [history, setHistory] = useState([])
|
||||
const [selectedQuizId, setSelectedQuizId] = useState(null)
|
||||
|
|
@ -47,7 +48,7 @@ export default function DashboardPage() {
|
|||
if (histRes.data.length > 0) setSelectedQuizId(histRes.data[0].quiz_id)
|
||||
}).catch(console.error)
|
||||
.finally(() => setLoading(false))
|
||||
}, [isModerator])
|
||||
}, [])
|
||||
|
||||
if (loading) return <div className="loading"><div className="spinner"></div> Loading...</div>
|
||||
|
||||
|
|
@ -75,6 +76,10 @@ export default function DashboardPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<InProgressQuizzes />
|
||||
|
||||
<MyNote variant="card" />
|
||||
|
||||
{/* Performance graph with dropdown */}
|
||||
{history.length > 0 && (
|
||||
<div className="card">
|
||||
|
|
@ -153,12 +158,6 @@ export default function DashboardPage() {
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isModerator && (
|
||||
<div style={{ textAlign: 'center', marginTop: 8 }}>
|
||||
<Link to="/upload" className="btn btn-secondary">Manage Documents</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,11 @@ const FEATURES = [
|
|||
title: 'AI Tutor Built In',
|
||||
desc: 'Ask anything mid-study. The AI tutor knows the current question, the correct answer, and pulls in related questions from your bank for context.',
|
||||
},
|
||||
{
|
||||
icon: '🩺',
|
||||
title: 'Clinical Assistant',
|
||||
desc: 'Access pediatric workflows, bedside calculators, dosing support, and clinical references alongside your study tools.',
|
||||
},
|
||||
{
|
||||
icon: '🔊',
|
||||
title: 'Audio Mode',
|
||||
|
|
@ -430,7 +435,7 @@ export default function LandingPage() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── AI Scribe section ──────────────────────────────────────────────── */}
|
||||
{/* ── Clinical tools section ─────────────────────────────────────────── */}
|
||||
<section style={{ background: 'var(--navbar-bg)', color: 'var(--navbar-fg)', padding: '80px 24px' }}>
|
||||
<div style={{ maxWidth: 1000, margin: '0 auto', display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 320px), 1fr))', gap: 40, alignItems: 'center' }}>
|
||||
<div>
|
||||
|
|
@ -438,22 +443,23 @@ export default function LandingPage() {
|
|||
Also from PedsHub
|
||||
</div>
|
||||
<h2 style={{ fontSize: 'clamp(1.6rem, 3vw, 2rem)', fontWeight: 800, letterSpacing: '-0.02em', margin: '0 0 16px', color: '#f1f5f9' }}>
|
||||
Pediatric AI Scribe
|
||||
Pediatric Clinical Tools
|
||||
</h2>
|
||||
<p style={{ color: '#94a3b8', lineHeight: 1.7, marginBottom: 28, fontSize: '0.95rem' }}>
|
||||
A clinical assistant built for pediatric providers. Voice-to-note documentation,
|
||||
age-specific well visit workflows, developmental milestones, vaccine schedules,
|
||||
catch-up planners, automatic ICD-10 billing codes, and a full pediatric
|
||||
calculators & bedside emergency reference — all in one tool.
|
||||
A pediatric clinical assistant for daily practice: well visit workflows,
|
||||
developmental milestones, vaccine schedules, catch-up planning,
|
||||
bedside calculators, dosing support, emergency pathways, and concise
|
||||
clinical references in one place, powered by Peds-AI clinical assistant tools.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 32, overflow: 'hidden' }}>
|
||||
{[
|
||||
{ text: '🩺 Well visit planner from newborn through adolescence' },
|
||||
{ text: '📋 Developmental milestone tracking across 4 domains' },
|
||||
{ text: '💉 Full AAP/ACIP vaccine schedule with catch-up planner' },
|
||||
{ text: '🎙 AI scribe — speak, get a structured note' },
|
||||
{ text: '💊 Automatic ICD-10 and CPT billing codes' },
|
||||
{ text: '🚨 Bedside calculators — weight-based dosing, emergency pathways (sepsis, status epilepticus, RSI, burns, anaphylaxis)', badge: 'NEW' },
|
||||
{ text: '💊 Weight-based dosing and pediatric calculators' },
|
||||
{ text: '🤖 Peds-AI clinical assistant for quick pediatric guidance' },
|
||||
{ text: '🚨 Emergency pathways for sepsis, status epilepticus, RSI, burns, and anaphylaxis', badge: 'NEW' },
|
||||
{ text: '🎙 Optional voice-to-note support for structured documentation' },
|
||||
].map((item, i) => (
|
||||
<div key={i} style={{ fontSize: '0.88rem', color: '#cbd5e1', display: 'flex', gap: 8, alignItems: 'flex-start', overflowWrap: 'break-word', wordBreak: 'break-word', minWidth: 0 }}>
|
||||
<span>{item.text}</span>
|
||||
|
|
@ -472,7 +478,7 @@ export default function LandingPage() {
|
|||
className="btn btn-primary"
|
||||
style={{ display: 'inline-block', textDecoration: 'none', padding: '11px 24px', borderRadius: 10 }}
|
||||
>
|
||||
Open AI Scribe ↗
|
||||
Open Clinical Tools ↗
|
||||
</a>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 130px), 1fr))', gap: 10 }}>
|
||||
|
|
@ -480,9 +486,8 @@ export default function LandingPage() {
|
|||
{ icon: '📅', label: 'Well Visits', sub: '2wk → 18yr' },
|
||||
{ icon: '🧠', label: 'Milestones', sub: '2mo → 5yr' },
|
||||
{ icon: '💉', label: 'Vaccines', sub: 'Full schedule' },
|
||||
{ icon: '🎙', label: 'AI Scribe', sub: 'Voice-to-note' },
|
||||
{ icon: '💊', label: 'ICD-10', sub: 'Auto-suggested' },
|
||||
{ icon: '📋', label: 'SOAP Notes', sub: 'Structured' },
|
||||
{ icon: '💊', label: 'Dosing', sub: 'Weight-based' },
|
||||
{ icon: '📋', label: 'Clinical Assistant', sub: 'References + plans' },
|
||||
{ icon: '🚨', label: 'Bedside', sub: 'Dosing + pathways' },
|
||||
].map((item, i) => (
|
||||
<div key={i} style={{
|
||||
|
|
@ -532,7 +537,7 @@ export default function LandingPage() {
|
|||
<div style={{ display: 'flex', gap: 24, fontSize: '0.85rem' }}>
|
||||
<button onClick={() => setAuthModal('login')} style={{ color: 'rgba(226,232,240,0.45)', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'inherit' }}>Sign In</button>
|
||||
<a href="#contact" style={{ color: 'rgba(226,232,240,0.45)', textDecoration: 'none' }}>Contact</a>
|
||||
<a href="https://app.pedshub.com" target="_blank" rel="noopener noreferrer" style={{ color: 'rgba(226,232,240,0.45)', textDecoration: 'none' }}>AI Scribe</a>
|
||||
<a href="https://app.pedshub.com" target="_blank" rel="noopener noreferrer" style={{ color: 'rgba(226,232,240,0.45)', textDecoration: 'none' }}>Clinical Tools</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
|
|
|||
|
|
@ -532,6 +532,8 @@ export default function QuestionBankPage() {
|
|||
const [importFile, setImportFile] = useState(null)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [importResult, setImportResult] = useState(null)
|
||||
const [qtiImporting, setQtiImporting] = useState(false)
|
||||
const [qtiExporting, setQtiExporting] = useState(false)
|
||||
const debounceRef = useRef(null)
|
||||
const classifyPollRef = useRef(null)
|
||||
const { user } = useAuth()
|
||||
|
|
@ -700,6 +702,49 @@ export default function QuestionBankPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const handleQtiImport = () => {
|
||||
if (qtiImporting) return
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = '.xml'
|
||||
input.onchange = async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setQtiImporting(true)
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
const res = await api.post('/questions/import/qti', fd)
|
||||
const errors = res.data.errors?.length ? ` ${res.data.errors.length} error(s).` : ''
|
||||
openAlert(`Imported ${res.data.imported} of ${res.data.total_items} questions.${errors}`, { title: 'QTI Import Complete' })
|
||||
loadQuestions()
|
||||
} catch (err) {
|
||||
openAlert(err.response?.data?.detail || 'QTI import failed', { title: 'Import Failed' })
|
||||
} finally {
|
||||
setQtiImporting(false)
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
|
||||
const handleQtiExport = async () => {
|
||||
setQtiExporting(true)
|
||||
try {
|
||||
const ids = selectedIds.size > 0 ? [...selectedIds].join(',') : ''
|
||||
const res = await api.get(`/questions/export/qti${ids ? `?question_ids=${ids}` : ''}`, { responseType: 'blob' })
|
||||
const url = URL.createObjectURL(res.data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'questions_qti.xml'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (err) {
|
||||
openAlert(err.response?.data?.detail || 'QTI export failed', { title: 'Export Failed' })
|
||||
} finally {
|
||||
setQtiExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleFavorite = async (questionId) => {
|
||||
const isFavorited = favorites.includes(questionId)
|
||||
try {
|
||||
|
|
@ -808,27 +853,12 @@ export default function QuestionBankPage() {
|
|||
)}
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setShowCreateQuestion(true)}>+ Question</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => { setShowImport(true); setImportFile(null); setImportResult(null) }}>Import CSV/Excel</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={async () => {
|
||||
const input = document.createElement('input'); input.type = 'file'; input.accept = '.xml'
|
||||
input.onchange = async (e) => {
|
||||
const f = e.target.files[0]; if (!f) return
|
||||
const fd = new FormData(); fd.append('file', f)
|
||||
try {
|
||||
const res = await api.post('/questions/import/qti', fd)
|
||||
alert(`Imported ${res.data.imported} of ${res.data.total_items} questions${res.data.errors?.length ? `. ${res.data.errors.length} error(s).` : ''}`)
|
||||
loadQuestions()
|
||||
} catch (err) { alert(err.response?.data?.detail || 'QTI import failed') }
|
||||
}; input.click()
|
||||
}}>Import QTI</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={async () => {
|
||||
try {
|
||||
const ids = selectedIds.size > 0 ? [...selectedIds].join(',') : ''
|
||||
const res = await api.get(`/questions/export/qti${ids ? `?question_ids=${ids}` : ''}`, { responseType: 'blob' })
|
||||
const url = URL.createObjectURL(res.data)
|
||||
const a = document.createElement('a'); a.href = url; a.download = 'questions_qti.xml'; a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch { }
|
||||
}}>Export QTI{selectedIds.size > 0 ? ` (${selectedIds.size})` : ''}</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={handleQtiImport} disabled={qtiImporting}>
|
||||
{qtiImporting ? 'Importing QTI...' : 'Import QTI'}
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={handleQtiExport} disabled={qtiExporting}>
|
||||
{qtiExporting ? 'Exporting QTI...' : `Export QTI${selectedIds.size > 0 ? ` (${selectedIds.size})` : ''}`}
|
||||
</button>
|
||||
{isModerator && <button className="btn btn-secondary btn-sm" onClick={() => setShowCatForm(v => !v)}>+ Category</button>}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
101
frontend/src/pages/QuestionBankPage.test.jsx
Normal file
101
frontend/src/pages/QuestionBankPage.test.jsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import QuestionBankPage from './QuestionBankPage'
|
||||
import api from '../api/client'
|
||||
|
||||
vi.mock('../api/client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../context/AuthContext', () => ({
|
||||
useAuth: () => ({ user: { role: 'admin' } }),
|
||||
}))
|
||||
|
||||
function mockInitialRequests() {
|
||||
api.get.mockImplementation((url) => {
|
||||
if (url === '/question-categories/') return Promise.resolve({ data: [] })
|
||||
if (url === '/favorites') return Promise.resolve({ data: [] })
|
||||
if (url === '/tags') return Promise.resolve({ data: { subjects: [], diseases: [], keywords: [] } })
|
||||
if (url === '/questions/bank') return Promise.resolve({ data: { questions: [], total: 0 } })
|
||||
return Promise.resolve({ data: [] })
|
||||
})
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<QuestionBankPage />
|
||||
</MemoryRouter>
|
||||
)
|
||||
}
|
||||
|
||||
describe('QuestionBankPage QTI actions', () => {
|
||||
let originalCreateElement
|
||||
let fileInput
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockInitialRequests()
|
||||
originalCreateElement = document.createElement.bind(document)
|
||||
fileInput = null
|
||||
|
||||
vi.spyOn(document, 'createElement').mockImplementation((tagName, options) => {
|
||||
const element = originalCreateElement(tagName, options)
|
||||
if (tagName === 'input') {
|
||||
fileInput = element
|
||||
vi.spyOn(element, 'click').mockImplementation(() => {})
|
||||
}
|
||||
return element
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.createElement.mockRestore()
|
||||
})
|
||||
|
||||
it('imports QTI files with an in-app success dialog', async () => {
|
||||
api.post.mockResolvedValueOnce({ data: { imported: 3, total_items: 4, errors: ['Skipped duplicate'] } })
|
||||
|
||||
renderPage()
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Import QTI' }))
|
||||
|
||||
expect(fileInput).toBeTruthy()
|
||||
expect(fileInput.accept).toBe('.xml')
|
||||
|
||||
const file = new File(['<questestinterop />'], 'questions.xml', { type: 'text/xml' })
|
||||
Object.defineProperty(fileInput, 'files', { value: [file], configurable: true })
|
||||
fireEvent.change(fileInput)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.post).toHaveBeenCalledWith('/questions/import/qti', expect.any(FormData))
|
||||
})
|
||||
expect(await screen.findByText('QTI Import Complete')).toBeInTheDocument()
|
||||
expect(screen.getByText('Imported 3 of 4 questions. 1 error(s).')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows QTI export failures instead of swallowing them', async () => {
|
||||
api.get.mockImplementation((url) => {
|
||||
if (url.startsWith('/questions/export/qti')) {
|
||||
return Promise.reject({ response: { data: { detail: 'Export unavailable' } } })
|
||||
}
|
||||
if (url === '/question-categories/') return Promise.resolve({ data: [] })
|
||||
if (url === '/favorites') return Promise.resolve({ data: [] })
|
||||
if (url === '/tags') return Promise.resolve({ data: { subjects: [], diseases: [], keywords: [] } })
|
||||
if (url === '/questions/bank') return Promise.resolve({ data: { questions: [], total: 0 } })
|
||||
return Promise.resolve({ data: [] })
|
||||
})
|
||||
|
||||
renderPage()
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Export QTI' }))
|
||||
|
||||
expect(await screen.findByText('Export Failed')).toBeInTheDocument()
|
||||
expect(screen.getByText('Export unavailable')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
@ -2,24 +2,197 @@ import { useState, useEffect, useRef, useCallback, lazy, Suspense } from 'react'
|
|||
import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import api from '../api/client'
|
||||
import MyNote from '../components/MyNote'
|
||||
|
||||
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 mergeTextRanges(ranges) {
|
||||
const sorted = ranges
|
||||
.filter(r => Number.isFinite(r.start) && Number.isFinite(r.end) && r.end > r.start)
|
||||
.sort((a, b) => a.start - b.start || a.end - b.end)
|
||||
const merged = []
|
||||
sorted.forEach(range => {
|
||||
const last = merged[merged.length - 1]
|
||||
if (!last || range.start > last.end) {
|
||||
merged.push({ start: range.start, end: range.end })
|
||||
} else {
|
||||
last.end = Math.max(last.end, range.end)
|
||||
}
|
||||
})
|
||||
return merged
|
||||
}
|
||||
|
||||
function removeTextRange(ranges, removeRange) {
|
||||
const next = []
|
||||
ranges.forEach(range => {
|
||||
if (removeRange.end <= range.start || removeRange.start >= range.end) {
|
||||
next.push(range)
|
||||
return
|
||||
}
|
||||
if (removeRange.start > range.start) next.push({ start: range.start, end: removeRange.start })
|
||||
if (removeRange.end < range.end) next.push({ start: removeRange.end, end: range.end })
|
||||
})
|
||||
return mergeTextRanges(next)
|
||||
}
|
||||
|
||||
function getSpeechChunkRange(text, maxWords, activeChunk) {
|
||||
if (activeChunk === null || activeChunk === undefined) return null
|
||||
const words = []
|
||||
const re = /\S+/g
|
||||
let match
|
||||
while ((match = re.exec(text || ''))) words.push({ start: match.index, end: match.index + match[0].length })
|
||||
const startWord = activeChunk * maxWords
|
||||
if (!words[startWord]) return null
|
||||
const endWord = Math.min(startWord + maxWords - 1, words.length - 1)
|
||||
return { start: words[startWord].start, end: words[endWord].end }
|
||||
}
|
||||
|
||||
function getManualHighlightSelection(selection = window.getSelection?.()) {
|
||||
if (!selection || selection.rangeCount === 0 || !selection.toString().trim()) return null
|
||||
|
||||
const offsetFromNode = (node, offset) => {
|
||||
const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : node
|
||||
const span = element?.closest?.('[data-manual-highlight-id]')
|
||||
if (!span) return null
|
||||
let charOffset = offset
|
||||
if (node.nodeType !== Node.TEXT_NODE) {
|
||||
charOffset = offset <= 0 ? 0 : Number(span.dataset.end || span.dataset.start || 0) - Number(span.dataset.start || 0)
|
||||
}
|
||||
return {
|
||||
id: span.dataset.manualHighlightId,
|
||||
offset: Number(span.dataset.start || 0) + charOffset,
|
||||
}
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
const start = offsetFromNode(range.startContainer, range.startOffset)
|
||||
const end = offsetFromNode(range.endContainer, range.endOffset)
|
||||
if (!start || !end || start.id !== end.id) return null
|
||||
const ordered = start.offset <= end.offset ? { start: start.offset, end: end.offset } : { start: end.offset, end: start.offset }
|
||||
if (ordered.end <= ordered.start) return null
|
||||
return { id: start.id, ...ordered }
|
||||
}
|
||||
|
||||
function ManualHighlightText({ text, textId, highlights = [], speechRange = null, onRemoveHighlight = null }) {
|
||||
const ranges = mergeTextRanges(highlights)
|
||||
const boundaries = new Set([0, (text || '').length])
|
||||
ranges.forEach(range => { boundaries.add(range.start); boundaries.add(range.end) })
|
||||
if (speechRange) { boundaries.add(speechRange.start); boundaries.add(speechRange.end) }
|
||||
const points = [...boundaries]
|
||||
.filter(point => point >= 0 && point <= (text || '').length)
|
||||
.sort((a, b) => a - b)
|
||||
|
||||
return points.slice(0, -1).map((start, i) => {
|
||||
const end = points[i + 1]
|
||||
if (end <= start) return null
|
||||
const manuallyHighlighted = ranges.some(range => start >= range.start && end <= range.end)
|
||||
const speechHighlighted = speechRange && start >= speechRange.start && end <= speechRange.end
|
||||
const className = [
|
||||
'manual-highlight-segment',
|
||||
manuallyHighlighted ? 'manual-highlight-active' : '',
|
||||
speechHighlighted ? 'speech-highlight-active' : '',
|
||||
].filter(Boolean).join(' ')
|
||||
return (
|
||||
<span
|
||||
key={`${start}-${end}`}
|
||||
className={className}
|
||||
data-manual-highlight-id={textId}
|
||||
data-start={start}
|
||||
data-end={end}
|
||||
onContextMenu={manuallyHighlighted ? (event) => {
|
||||
event.preventDefault()
|
||||
onRemoveHighlight?.(textId, start)
|
||||
} : undefined}
|
||||
title={manuallyHighlighted ? 'Right-click to remove this highlight' : undefined}
|
||||
>
|
||||
{text.slice(start, end)}
|
||||
</span>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
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 getActiveSpeechSegment(audio, segments) {
|
||||
if (!segments.length) return null
|
||||
if (!audio || !Number.isFinite(audio.duration) || audio.duration <= 0) return 0
|
||||
const weights = segments.map(segment => Math.max(12, segment.text.length))
|
||||
const total = weights.reduce((sum, weight) => sum + weight, 0)
|
||||
const target = (audio.currentTime / audio.duration) * total
|
||||
let cursor = 0
|
||||
for (let i = 0; i < weights.length; i += 1) {
|
||||
cursor += weights[i]
|
||||
if (target <= cursor) return i
|
||||
}
|
||||
return segments.length - 1
|
||||
}
|
||||
|
||||
function TTSButton({ text, voice, segments = [], onActiveChange, onSegmentChange, getAudio, autoPlay, onEnded }) {
|
||||
const [state, setState] = useState('idle') // idle | loading | playing
|
||||
const audioRef = useRef(null)
|
||||
|
||||
// Stop audio when question changes (component unmounts)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
audioRef.current?.pause()
|
||||
onActiveChange?.(false)
|
||||
onSegmentChange?.(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (autoPlay && state === 'idle') speak()
|
||||
}, [autoPlay])
|
||||
|
||||
const setStateAndNotify = (s) => {
|
||||
setState(s)
|
||||
onActiveChange?.(s !== 'idle')
|
||||
if (s === 'idle') onSegmentChange?.(null)
|
||||
}
|
||||
|
||||
const speak = async () => {
|
||||
|
|
@ -31,14 +204,29 @@ function TTSButton({ text, voice, onActiveChange }) {
|
|||
if (state === 'loading') return
|
||||
try {
|
||||
setStateAndNotify('loading')
|
||||
const res = await api.post('/tts/speak', { text, voice: voice || null }, { responseType: 'blob' })
|
||||
const url = URL.createObjectURL(res.data)
|
||||
let url
|
||||
let revokeOnDone = false
|
||||
if (getAudio) {
|
||||
url = (await getAudio(text, voice))?.url
|
||||
} else {
|
||||
const res = await api.post('/tts/speak', { text, voice: voice || null }, { responseType: 'blob' })
|
||||
url = URL.createObjectURL(res.data)
|
||||
revokeOnDone = true
|
||||
}
|
||||
if (!url) throw new Error('No audio returned')
|
||||
const audio = new Audio(url)
|
||||
audioRef.current = audio
|
||||
audio.onended = () => { setStateAndNotify('idle'); URL.revokeObjectURL(url) }
|
||||
audio.onerror = () => setStateAndNotify('idle')
|
||||
audio.play()
|
||||
const updateSegment = () => {
|
||||
const activeIndex = getActiveSpeechSegment(audio, segments)
|
||||
onSegmentChange?.(activeIndex === null ? null : segments[activeIndex] || null)
|
||||
}
|
||||
audio.onloadedmetadata = updateSegment
|
||||
audio.ontimeupdate = updateSegment
|
||||
audio.onended = () => { setStateAndNotify('idle'); onEnded?.(); if (revokeOnDone) URL.revokeObjectURL(url) }
|
||||
audio.onerror = () => { setStateAndNotify('idle'); if (revokeOnDone) URL.revokeObjectURL(url) }
|
||||
await audio.play()
|
||||
setStateAndNotify('playing')
|
||||
updateSegment()
|
||||
} catch { setStateAndNotify('idle') }
|
||||
}
|
||||
|
||||
|
|
@ -99,6 +287,21 @@ function CourseQuizStart({ quiz, onStart }) {
|
|||
function ModeSelectScreen({ quiz, voices, onStart }) {
|
||||
const [selectedVoice, setSelectedVoice] = useState(voices.find(v => v.is_default)?.id || voices[0]?.id || '')
|
||||
const [customTimer, setCustomTimer] = useState(quiz.time_limit_minutes || '')
|
||||
const [startError, setStartError] = useState('')
|
||||
const [startingMode, setStartingMode] = useState('')
|
||||
|
||||
const handleStart = async (mode) => {
|
||||
const timerMinutes = mode === 'exam' && customTimer ? parseInt(customTimer) : null
|
||||
setStartError('')
|
||||
setStartingMode(mode)
|
||||
try {
|
||||
await onStart(mode, selectedVoice, timerMinutes)
|
||||
} catch {
|
||||
setStartError('Could not start the quiz. Try again.')
|
||||
} finally {
|
||||
setStartingMode('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 520, margin: '40px auto' }}>
|
||||
|
|
@ -115,17 +318,22 @@ function ModeSelectScreen({ quiz, voices, onStart }) {
|
|||
{ mode: 'study', icon: '📖', label: 'Study Mode', desc: 'Answers & explanations shown as you go', color: '#22c55e', bg: '#f0fdf4' },
|
||||
{ mode: 'exam', icon: '🎯', label: 'Exam Mode', desc: 'Answers hidden until submitted', color: '#3b82f6', bg: '#eff6ff' },
|
||||
].map(({ mode, icon, label, desc, color, bg }) => (
|
||||
<div key={mode} onClick={() => onStart(mode, selectedVoice, mode === 'exam' && customTimer ? parseInt(customTimer) : null)}
|
||||
style={{ flex: 1, border: `2px solid ${color}`, borderRadius: 12, padding: '18px 12px', cursor: 'pointer', background: bg, transition: 'transform 0.1s' }}
|
||||
<div key={mode} onClick={() => !startingMode && handleStart(mode)}
|
||||
style={{ flex: 1, border: `2px solid ${color}`, borderRadius: 12, padding: '18px 12px', cursor: startingMode ? 'wait' : 'pointer', background: bg, transition: 'transform 0.1s', opacity: startingMode && startingMode !== mode ? 0.55 : 1 }}
|
||||
onMouseEnter={e => e.currentTarget.style.transform = 'scale(1.03)'}
|
||||
onMouseLeave={e => e.currentTarget.style.transform = 'none'}
|
||||
>
|
||||
<div style={{ fontSize: '1.8rem', marginBottom: 6 }}>{icon}</div>
|
||||
<div style={{ fontWeight: 700, color, marginBottom: 4 }}>{label}</div>
|
||||
<div style={{ fontSize: '0.8rem', color }}>{desc}</div>
|
||||
<div style={{ fontSize: '0.8rem', color }}>{startingMode === mode ? 'Starting...' : desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{startError && (
|
||||
<div style={{ background: '#fef2f2', color: '#991b1b', border: '1px solid #fecaca', borderRadius: 8, padding: '8px 10px', fontSize: '0.82rem', marginBottom: 14 }}>
|
||||
{startError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 14, marginBottom: 14 }}>
|
||||
<label style={{ fontSize: '0.85rem', color: 'var(--text-muted)', display: 'block', marginBottom: 6 }}>⏱ Timer for Exam Mode <span style={{ fontWeight: 400 }}>(minutes, optional)</span></label>
|
||||
|
|
@ -151,8 +359,21 @@ function ModeSelectScreen({ quiz, voices, onStart }) {
|
|||
)
|
||||
}
|
||||
|
||||
// Unique session ID per tab — used to prevent concurrent resume on multiple devices
|
||||
const SESSION_ID = Math.random().toString(36).slice(2) + Date.now().toString(36)
|
||||
function getQuizSessionId() {
|
||||
const key = 'pedshub_quiz_session_id'
|
||||
try {
|
||||
const existing = localStorage.getItem(key)
|
||||
if (existing) return existing
|
||||
const created = window.crypto?.randomUUID?.() || `${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`
|
||||
localStorage.setItem(key, created)
|
||||
return created
|
||||
} catch {
|
||||
return `${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`
|
||||
}
|
||||
}
|
||||
|
||||
// Stable per-device session ID lets the same app/webview resume after restart.
|
||||
const SESSION_ID = getQuizSessionId()
|
||||
|
||||
export default function QuizPage() {
|
||||
const { id } = useParams()
|
||||
|
|
@ -165,6 +386,7 @@ export default function QuizPage() {
|
|||
const [voices, setVoices] = useState([])
|
||||
const [selectedVoice, setSelectedVoice] = useState('')
|
||||
const [ttsActive, setTtsActive] = useState(false)
|
||||
const [readThrough, setReadThrough] = useState(false)
|
||||
const [quizMode, setQuizMode] = useState(null)
|
||||
const [answers, setAnswers] = useState({})
|
||||
const [currentIdx, setCurrentIdx] = useState(0)
|
||||
|
|
@ -176,11 +398,19 @@ export default function QuizPage() {
|
|||
const [totalTime, setTotalTime] = useState(null)
|
||||
const [toast, setToast] = useState('')
|
||||
const [navOpen, setNavOpen] = useState(false)
|
||||
const [expandedImagePath, setExpandedImagePath] = useState('')
|
||||
const [imageZoom, setImageZoom] = useState(1)
|
||||
const [startedAt, setStartedAt] = useState(null)
|
||||
const [favorites, setFavorites] = useState([])
|
||||
const [activeReadSegment, setActiveReadSegment] = useState(null)
|
||||
const [manualHighlights, setManualHighlights] = useState({})
|
||||
const timerRef = useRef(null)
|
||||
const toastRef = useRef(null)
|
||||
const hasStarted = useRef(false)
|
||||
const ttsCacheRef = useRef(new Map())
|
||||
const autoAdvanceRef = useRef(null)
|
||||
const savedHighlightSelectionRef = useRef(null)
|
||||
const autoHighlightTimerRef = useRef(null)
|
||||
|
||||
const showToast = (msg) => {
|
||||
setToast(msg)
|
||||
|
|
@ -188,15 +418,133 @@ export default function QuizPage() {
|
|||
toastRef.current = setTimeout(() => setToast(''), 3000)
|
||||
}
|
||||
|
||||
const [leaveTarget, setLeaveTarget] = useState(null)
|
||||
const adjustImageZoom = (delta) => {
|
||||
setImageZoom(z => Math.max(1, Math.min(4, z + delta)))
|
||||
}
|
||||
|
||||
const resumeQuiz = useCallback(async (saved) => {
|
||||
const mode = saved.mode || saved.quizMode
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(`quiz-highlights:${id}`)
|
||||
setManualHighlights(saved ? JSON.parse(saved) : {})
|
||||
} catch {
|
||||
setManualHighlights({})
|
||||
}
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(`quiz-highlights:${id}`, JSON.stringify(manualHighlights))
|
||||
} catch { }
|
||||
}, [id, manualHighlights])
|
||||
|
||||
const [leaveTarget, setLeaveTarget] = useState(null)
|
||||
const questions = quiz?.questions || []
|
||||
const current = questions[currentIdx]
|
||||
const isStudy = quizMode === 'study'
|
||||
|
||||
const applyManualHighlightSelection = useCallback((selected = getManualHighlightSelection() || savedHighlightSelectionRef.current) => {
|
||||
if (!selected || !current) return
|
||||
const [questionKey, fieldKey] = selected.id.split('::')
|
||||
if (questionKey !== String(current.id)) return
|
||||
|
||||
const existing = manualHighlights[current.id]?.[fieldKey] || []
|
||||
const removeExisting = existing.some(range => selected.start >= range.start && selected.end <= range.end)
|
||||
|
||||
setManualHighlights(prev => {
|
||||
const questionRanges = prev[current.id] || {}
|
||||
const currentRanges = questionRanges[fieldKey] || []
|
||||
const nextRanges = removeExisting
|
||||
? removeTextRange(currentRanges, selected)
|
||||
: mergeTextRanges([...currentRanges, { start: selected.start, end: selected.end }])
|
||||
const nextQuestion = { ...questionRanges, [fieldKey]: nextRanges }
|
||||
if (!nextRanges.length) delete nextQuestion[fieldKey]
|
||||
const next = { ...prev, [current.id]: nextQuestion }
|
||||
if (!Object.keys(nextQuestion).length) delete next[current.id]
|
||||
return next
|
||||
})
|
||||
|
||||
window.getSelection?.().removeAllRanges()
|
||||
savedHighlightSelectionRef.current = null
|
||||
}, [current?.id, manualHighlights])
|
||||
|
||||
const captureHighlightSelection = useCallback(() => {
|
||||
const selected = getManualHighlightSelection()
|
||||
if (!selected || !current) return
|
||||
const [questionKey] = selected.id.split('::')
|
||||
if (questionKey !== String(current.id)) return
|
||||
savedHighlightSelectionRef.current = selected
|
||||
clearTimeout(autoHighlightTimerRef.current)
|
||||
autoHighlightTimerRef.current = setTimeout(() => applyManualHighlightSelection(selected), 450)
|
||||
}, [current?.id, applyManualHighlightSelection])
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('selectionchange', captureHighlightSelection)
|
||||
document.addEventListener('mouseup', captureHighlightSelection)
|
||||
document.addEventListener('touchend', captureHighlightSelection)
|
||||
return () => {
|
||||
clearTimeout(autoHighlightTimerRef.current)
|
||||
document.removeEventListener('selectionchange', captureHighlightSelection)
|
||||
document.removeEventListener('mouseup', captureHighlightSelection)
|
||||
document.removeEventListener('touchend', captureHighlightSelection)
|
||||
}
|
||||
}, [captureHighlightSelection])
|
||||
|
||||
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)
|
||||
savedHighlightSelectionRef.current = null
|
||||
clearTimeout(autoHighlightTimerRef.current)
|
||||
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, availableVoices = []) => {
|
||||
const savedMode = saved.mode || saved.quizMode
|
||||
const mode = savedMode === 'exam' ? 'exam' : 'study'
|
||||
const savedIdx = saved.current_idx ?? saved.currentIdx ?? 0
|
||||
const savedAnswers = saved.answers || {}
|
||||
|
||||
const aid = saved.attempt_id || saved.attemptId || ''
|
||||
// For study mode, load quiz with correct answers BEFORE showing
|
||||
// For feedback modes, load quiz with correct answers BEFORE showing.
|
||||
if (mode === 'study') {
|
||||
try {
|
||||
const quizRes = await api.get(`/quizzes/${id}?study=true${aid ? `&attempt_id=${aid}` : ''}`)
|
||||
|
|
@ -223,7 +571,7 @@ export default function QuizPage() {
|
|||
setAnswers(savedAnswers)
|
||||
setCurrentIdx(savedIdx)
|
||||
setAttemptId(saved.attempt_id || saved.attemptId)
|
||||
if (saved.voice) setSelectedVoice(saved.voice)
|
||||
if (saved.voice && availableVoices.some(v => v.id === saved.voice)) setSelectedVoice(saved.voice)
|
||||
if (saved.started_at) setStartedAt(saved.started_at)
|
||||
// Restore timer — calculate remaining from started_at + total_time
|
||||
if (saved.total_time && saved.started_at) {
|
||||
|
|
@ -266,7 +614,7 @@ export default function QuizPage() {
|
|||
headers: { 'x-quiz-session': SESSION_ID },
|
||||
})
|
||||
if (progressRes.data) {
|
||||
await resumeQuiz(progressRes.data)
|
||||
await resumeQuiz(progressRes.data, voicesRes.data)
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.response?.status === 409) {
|
||||
|
|
@ -296,7 +644,7 @@ export default function QuizPage() {
|
|||
|
||||
// Fetch quiz with attempt_id for question pool filtering
|
||||
let quizData = quiz
|
||||
const studyParam = (mode === 'study') ? '&study=true' : ''
|
||||
const studyParam = mode === 'study' ? '&study=true' : ''
|
||||
const quizRes = await api.get(`/quizzes/${id}?attempt_id=${aid}${studyParam}`)
|
||||
quizData = quizRes.data
|
||||
setQuiz(quizData)
|
||||
|
|
@ -309,6 +657,17 @@ export default function QuizPage() {
|
|||
}
|
||||
// NOW set mode — quiz data is fully loaded, safe to render
|
||||
setQuizMode(mode)
|
||||
api.post('/attempts/progress', {
|
||||
quiz_id: parseInt(id),
|
||||
attempt_id: aid,
|
||||
answers: {},
|
||||
current_idx: 0,
|
||||
mode,
|
||||
voice: voice || null,
|
||||
time_left: mode === 'exam' && mins ? mins * 60 : null,
|
||||
started_at: now,
|
||||
total_time: mode === 'exam' && mins ? mins * 60 : null,
|
||||
}, { headers: { 'x-quiz-session': SESSION_ID } }).catch(() => {})
|
||||
} catch { navigate('/') }
|
||||
finally { setStarting(false) }
|
||||
}
|
||||
|
|
@ -327,30 +686,80 @@ const timerStarted = timeLeft !== null
|
|||
if (timeLeft === 0) handleSubmit(true)
|
||||
}, [timeLeft])
|
||||
|
||||
const saveProgressNow = useCallback((overrides = {}) => {
|
||||
if (!attemptId || !quizMode) return Promise.resolve()
|
||||
return api.post('/attempts/progress', {
|
||||
quiz_id: parseInt(id),
|
||||
attempt_id: attemptId,
|
||||
answers,
|
||||
current_idx: currentIdx,
|
||||
mode: quizMode,
|
||||
voice: selectedVoice || null,
|
||||
time_left: timeLeft,
|
||||
started_at: startedAt,
|
||||
total_time: totalTime,
|
||||
...overrides,
|
||||
}, { headers: { 'x-quiz-session': SESSION_ID } }).catch(() => {})
|
||||
}, [id, answers, currentIdx, attemptId, quizMode, selectedVoice, timeLeft, startedAt, totalTime])
|
||||
|
||||
// Save progress to Redis (survives logout/browser change)
|
||||
const saveProgressRef = useRef(null)
|
||||
useEffect(() => {
|
||||
if (!attemptId || !quizMode) return
|
||||
clearTimeout(saveProgressRef.current)
|
||||
saveProgressRef.current = setTimeout(() => {
|
||||
api.post('/attempts/progress', {
|
||||
quiz_id: parseInt(id),
|
||||
attempt_id: attemptId,
|
||||
answers,
|
||||
current_idx: currentIdx,
|
||||
mode: quizMode,
|
||||
voice: selectedVoice || null,
|
||||
time_left: timeLeft,
|
||||
started_at: startedAt,
|
||||
total_time: totalTime,
|
||||
}, { headers: { 'x-quiz-session': SESSION_ID } }).catch(() => {})
|
||||
}, 1500) // debounce 1.5s
|
||||
saveProgressRef.current = setTimeout(() => { saveProgressNow() }, 500)
|
||||
return () => clearTimeout(saveProgressRef.current)
|
||||
}, [answers, currentIdx, attemptId, timeLeft, startedAt, totalTime])
|
||||
}, [saveProgressNow, attemptId, quizMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!attemptId || !quizMode) return
|
||||
const flush = () => { saveProgressNow() }
|
||||
const flushWhenHidden = () => { if (document.visibilityState === 'hidden') flush() }
|
||||
window.addEventListener('pagehide', flush)
|
||||
document.addEventListener('visibilitychange', flushWhenHidden)
|
||||
return () => {
|
||||
window.removeEventListener('pagehide', flush)
|
||||
document.removeEventListener('visibilitychange', flushWhenHidden)
|
||||
}
|
||||
}, [attemptId, quizMode, saveProgressNow])
|
||||
|
||||
const setAnswer = (questionId, value) => setAnswers(prev => ({ ...prev, [questionId]: value }))
|
||||
|
||||
const safeNavigate = (targetIdx) => setCurrentIdx(targetIdx)
|
||||
const clearCurrentHighlights = () => {
|
||||
if (!current || !manualHighlights[current.id]) return
|
||||
setManualHighlights(prev => {
|
||||
const next = { ...prev }
|
||||
delete next[current.id]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const removeJoinedHighlight = (textId, offset) => {
|
||||
if (!current) return
|
||||
const [questionKey, fieldKey] = textId.split('::')
|
||||
if (questionKey !== String(current.id)) return
|
||||
const existing = manualHighlights[current.id]?.[fieldKey] || []
|
||||
const joined = existing.find(range => offset >= range.start && offset < range.end)
|
||||
if (!joined) return
|
||||
setManualHighlights(prev => {
|
||||
const questionRanges = prev[current.id] || {}
|
||||
const nextRanges = (questionRanges[fieldKey] || []).filter(range => range.start !== joined.start || range.end !== joined.end)
|
||||
const nextQuestion = { ...questionRanges, [fieldKey]: nextRanges }
|
||||
if (!nextRanges.length) delete nextQuestion[fieldKey]
|
||||
const next = { ...prev, [current.id]: nextQuestion }
|
||||
if (!Object.keys(nextQuestion).length) delete next[current.id]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const highlightsFor = (fieldKey) => manualHighlights[current?.id]?.[fieldKey] || []
|
||||
|
||||
const hasActiveTextSelection = () => Boolean(window.getSelection?.().toString().trim())
|
||||
|
||||
const safeNavigate = (targetIdx, { keepReadThrough = false } = {}) => {
|
||||
if (!keepReadThrough) setReadThrough(false)
|
||||
setCurrentIdx(targetIdx)
|
||||
}
|
||||
|
||||
const handleSubmit = useCallback(async (autoSubmit = false) => {
|
||||
if (!attemptId || submitting) return
|
||||
|
|
@ -384,7 +793,7 @@ const timerStarted = timeLeft !== null
|
|||
if (!quizMode) return (
|
||||
<div>
|
||||
{isModerator && (
|
||||
<div style={{ textAlign: 'right', marginBottom: 8 }}>
|
||||
<div style={{ textAlign: 'right', marginBottom: 8, display: 'flex', gap: 8, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<Link to={`/quizzes/${id}/edit`} className="btn btn-secondary btn-sm">✏️ Edit Questions</Link>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -401,12 +810,37 @@ const timerStarted = timeLeft !== null
|
|||
</div>
|
||||
)
|
||||
|
||||
const isStudy = quizMode === 'study'
|
||||
const questions = quiz.questions || []
|
||||
const current = questions[currentIdx]
|
||||
const answeredCount = Object.keys(answers).length
|
||||
const totalCount = questions.length
|
||||
const isLast = currentIdx === totalCount - 1
|
||||
const quizNavigation = (position = 'bottom') => (
|
||||
<div className={`quiz-nav-controls quiz-nav-controls-${position}`}>
|
||||
<button className="btn btn-secondary"
|
||||
onClick={() => safeNavigate(Math.max(0, currentIdx - 1))}
|
||||
disabled={currentIdx === 0}>← Prev</button>
|
||||
|
||||
<button className="quiz-nav-toggle btn btn-secondary btn-sm"
|
||||
onClick={() => setNavOpen(v => !v)}>
|
||||
{currentIdx + 1} / {totalCount} {navOpen ? '▼' : '▲'}
|
||||
</button>
|
||||
|
||||
{isLast ? (
|
||||
<button className="btn btn-primary" onClick={() => handleSubmit(false)} disabled={submitting}>
|
||||
{submitting ? 'Submitting...' : 'Submit Quiz'}
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-primary" onClick={() => safeNavigate(Math.min(totalCount - 1, currentIdx + 1))}>Next →</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
const activeReadForCurrent = current && activeReadSegment?.questionId === current.id
|
||||
const questionSpeechRange = current
|
||||
? getSpeechChunkRange(
|
||||
questionStem(current),
|
||||
QUESTION_HIGHLIGHT_WORDS,
|
||||
activeReadForCurrent && activeReadSegment.type === 'question' ? activeReadSegment.chunkIndex : null,
|
||||
)
|
||||
: null
|
||||
|
||||
const toggleFavorite = async (questionId) => {
|
||||
const isFavorited = favorites.includes(questionId)
|
||||
|
|
@ -441,6 +875,7 @@ const timerStarted = timeLeft !== null
|
|||
|
||||
return (
|
||||
<div className="quiz-bottom">
|
||||
<MyNote variant="tab" />
|
||||
{/* In-app leave confirmation */}
|
||||
{leaveTarget && (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
|
||||
|
|
@ -499,13 +934,14 @@ const timerStarted = timeLeft !== null
|
|||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="card" style={{ marginBottom: 14 }}>
|
||||
<div className="card quiz-header-card" style={{ marginBottom: 14 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
|
||||
<div>
|
||||
<h2 style={{ marginBottom: 2, fontSize: '1rem' }}>{quiz.title}</h2>
|
||||
<h2 className="quiz-header-title">{quiz.title}</h2>
|
||||
<div style={{ fontSize: '0.82rem', color: 'var(--text-muted)', display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<span style={{
|
||||
background: isStudy ? '#d1fae5' : '#e0e7ff', color: isStudy ? '#065f46' : '#3730a3',
|
||||
background: isStudy ? '#d1fae5' : '#e0e7ff',
|
||||
color: isStudy ? '#065f46' : '#3730a3',
|
||||
padding: '1px 8px', borderRadius: 12, fontWeight: 600,
|
||||
}}>
|
||||
{isStudy ? '📖 Study' : '🎯 Exam'}
|
||||
|
|
@ -542,10 +978,24 @@ const timerStarted = timeLeft !== null
|
|||
<div className="quiz-layout">
|
||||
{/* Main content */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{quizNavigation('top')}
|
||||
|
||||
{current && (
|
||||
<div className="question-card">
|
||||
<div className="question-card" style={{
|
||||
boxShadow: activeReadForCurrent ? '0 0 0 3px rgba(59, 130, 246, 0.22)' : undefined,
|
||||
borderColor: activeReadForCurrent ? '#60a5fa' : undefined,
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8, gap: 12 }}>
|
||||
<h3 style={{ marginBottom: 0, flex: 1 }}>Q{currentIdx + 1}. {current.question_text.replace('[IMAGE]', '')}</h3>
|
||||
<h3 style={{ marginBottom: 0, flex: 1 }}>
|
||||
Q{currentIdx + 1}.{' '}
|
||||
<ManualHighlightText
|
||||
text={questionStem(current)}
|
||||
textId={`${current.id}::question`}
|
||||
highlights={highlightsFor('question')}
|
||||
speechRange={questionSpeechRange}
|
||||
onRemoveHighlight={removeJoinedHighlight}
|
||||
/>
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => toggleFavorite(current.id)}
|
||||
title={favorites.includes(current.id) ? 'Remove from favorites' : 'Add to favorites'}
|
||||
|
|
@ -564,24 +1014,75 @@ const timerStarted = timeLeft !== null
|
|||
{favorites.includes(current.id) ? '⭐' : '☆'}
|
||||
</button>
|
||||
</div>
|
||||
{voices.length > 0 && (
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<div style={{ marginBottom: 10, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{voices.length > 0 && (
|
||||
<TTSButton
|
||||
key={`${current.id}_${answers[current.id] || ''}`}
|
||||
text={`Question ${currentIdx + 1}. ${current.question_text.replace('[IMAGE]', '')}. Options: ${(current.options || []).join(', ')}`}
|
||||
key={`${current.id}_${selectedVoice || 'default'}`}
|
||||
text={buildQuestionSpeechText(current, currentIdx)}
|
||||
voice={selectedVoice}
|
||||
segments={getQuestionSpeechSegments(current, currentIdx)}
|
||||
getAudio={fetchTtsAudio}
|
||||
autoPlay={readThrough}
|
||||
onEnded={() => {
|
||||
if (readThrough) {
|
||||
if (currentIdx < totalCount - 1) {
|
||||
safeNavigate(currentIdx + 1, { keepReadThrough: true })
|
||||
} else {
|
||||
setReadThrough(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
}}
|
||||
onActiveChange={setTtsActive}
|
||||
onSegmentChange={segment => setActiveReadSegment(segment === null ? null : { questionId: current.id, ...segment })}
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
<div className="manual-highlight-toolbar" aria-label="Question highlight tools">
|
||||
<button className="btn btn-secondary btn-sm" onMouseDown={e => e.preventDefault()} onClick={clearCurrentHighlights} disabled={!manualHighlights[current.id]} title="Clear all highlights on this question">
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="badge" style={{ background: '#e0e7ff', color: '#3730a3', margin: '6px 0 12px', display: 'inline-block' }}>
|
||||
{current.question_type === 'mcq' ? 'Multiple Choice' : current.question_type === 'true_false' ? 'True / False' : 'Fill in the Blank'}
|
||||
</span>
|
||||
{current.image_path && (
|
||||
<div style={{ margin: '10px 0' }}>
|
||||
<button className="question-image-preview" onClick={() => { setImageZoom(1); setExpandedImagePath(current.image_path) }} title="Expand image" type="button">
|
||||
<img src={`/uploads/${current.image_path}`} alt="Question illustration"
|
||||
style={{ maxWidth: '100%', maxHeight: 280, borderRadius: 8, border: '1px solid var(--border)' }}
|
||||
onError={e => e.target.style.display = 'none'} />
|
||||
onError={e => e.currentTarget.closest('button').style.display = 'none'} />
|
||||
</button>
|
||||
)}
|
||||
{expandedImagePath && (
|
||||
<div className="image-lightbox" role="dialog" aria-modal="true" aria-label="Expanded question image" onClick={() => setExpandedImagePath('')}>
|
||||
<div className="image-lightbox-controls" onClick={e => e.stopPropagation()}>
|
||||
<button type="button" onClick={() => adjustImageZoom(-0.1)} disabled={imageZoom <= 1}>-</button>
|
||||
<span>{Math.round(imageZoom * 100)}%</span>
|
||||
<button type="button" onClick={() => adjustImageZoom(0.1)} disabled={imageZoom >= 4}>+</button>
|
||||
{[1, 2, 3, 4].map(zoom => (
|
||||
<button key={zoom} type="button" onClick={() => setImageZoom(zoom)} disabled={imageZoom === zoom}>{zoom}x</button>
|
||||
))}
|
||||
</div>
|
||||
<button className="image-lightbox-close" onClick={() => setExpandedImagePath('')} type="button" aria-label="Close expanded image">×</button>
|
||||
<div className="image-lightbox-viewport" onClick={e => e.stopPropagation()}>
|
||||
<img
|
||||
src={`/uploads/${expandedImagePath}`}
|
||||
alt="Expanded question illustration"
|
||||
style={{
|
||||
maxWidth: imageZoom === 1 ? 'min(100%, 1100px)' : 'none',
|
||||
maxHeight: imageZoom === 1 ? '92vh' : 'none',
|
||||
width: imageZoom > 1 ? `${imageZoom * 100}%` : undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(current.question_type === 'mcq' || current.question_type === 'true_false') && current.options ? (
|
||||
|
|
@ -593,13 +1094,31 @@ const timerStarted = timeLeft !== null
|
|||
const showCorrect = hasAnswered && isCorrectOpt
|
||||
const showWrong = hasAnswered && isSelected && !isCorrectOpt
|
||||
const letter = String.fromCharCode(65 + i)
|
||||
const activeOptionChunk = activeReadForCurrent && activeReadSegment.type === 'option' && activeReadSegment.index === i
|
||||
? activeReadSegment.chunkIndex
|
||||
: null
|
||||
const optionSpeechRange = getSpeechChunkRange(opt, OPTION_HIGHLIGHT_WORDS, activeOptionChunk)
|
||||
const optionFieldKey = `option-${i}`
|
||||
return (
|
||||
<div key={i}
|
||||
className={`option ${isSelected && !hasAnswered ? 'selected' : ''} ${showCorrect ? 'correct' : ''} ${showWrong ? 'incorrect' : ''}`}
|
||||
onClick={() => !hasAnswered && setAnswer(current.id, opt)}
|
||||
style={{ cursor: hasAnswered ? 'default' : 'pointer' }}>
|
||||
onClick={() => !hasAnswered && !hasActiveTextSelection() && setAnswer(current.id, opt)}
|
||||
style={{
|
||||
cursor: hasAnswered ? 'default' : 'pointer',
|
||||
borderColor: activeOptionChunk !== null ? '#60a5fa' : undefined,
|
||||
boxShadow: activeOptionChunk !== null ? '0 0 0 3px rgba(59, 130, 246, 0.2)' : undefined,
|
||||
transition: 'background 0.15s ease, box-shadow 0.15s ease',
|
||||
}}>
|
||||
<span className="option-letter">{letter}</span>
|
||||
<span style={{ flex: 1 }}>{opt}</span>
|
||||
<span style={{ flex: 1 }}>
|
||||
<ManualHighlightText
|
||||
text={opt}
|
||||
textId={`${current.id}::${optionFieldKey}`}
|
||||
highlights={highlightsFor(optionFieldKey)}
|
||||
speechRange={optionSpeechRange}
|
||||
onRemoveHighlight={removeJoinedHighlight}
|
||||
/>
|
||||
</span>
|
||||
{showCorrect && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--correct-fg)' }}>✓ Correct</span>}
|
||||
{showWrong && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--wrong-fg)' }}>✗ Wrong</span>}
|
||||
</div>
|
||||
|
|
@ -629,26 +1148,7 @@ const timerStarted = timeLeft !== null
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Prev / Next + mobile nav toggle */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 14, gap: 8 }}>
|
||||
<button className="btn btn-secondary"
|
||||
onClick={() => safeNavigate(Math.max(0, currentIdx - 1))}
|
||||
disabled={currentIdx === 0}>← Prev</button>
|
||||
|
||||
{/* Mobile-only nav toggle */}
|
||||
<button className="quiz-nav-toggle btn btn-secondary btn-sm"
|
||||
onClick={() => setNavOpen(v => !v)}>
|
||||
{currentIdx + 1} / {totalCount} {navOpen ? '▼' : '▲'}
|
||||
</button>
|
||||
|
||||
{isLast ? (
|
||||
<button className="btn btn-primary" onClick={() => handleSubmit(false)} disabled={submitting}>
|
||||
{submitting ? 'Submitting...' : 'Submit Quiz'}
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-primary" onClick={() => safeNavigate(Math.min(totalCount - 1, currentIdx + 1))}>Next →</button>
|
||||
)}
|
||||
</div>
|
||||
{quizNavigation('bottom')}
|
||||
|
||||
{/* Mobile: collapsible number grid */}
|
||||
{navOpen && (
|
||||
|
|
|
|||
|
|
@ -4,56 +4,11 @@ import { useAuth } from '../context/AuthContext'
|
|||
import api from '../api/client'
|
||||
import ConfirmButton from '../components/ConfirmButton'
|
||||
import Dialog from '../components/Dialog'
|
||||
import InProgressQuizzes from '../components/InProgressQuizzes'
|
||||
import { useDialog } from '../hooks/useDialog'
|
||||
|
||||
const TeachChat = lazy(() => import('../components/TeachChat'))
|
||||
|
||||
function InProgressSection() {
|
||||
const [inProgress, setInProgress] = useState([])
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/attempts/in-progress').then(res => setInProgress(res.data)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const deleteAttempt = async (attemptId) => {
|
||||
await api.delete(`/attempts/${attemptId}`)
|
||||
setInProgress(prev => prev.filter(a => a.attempt_id !== attemptId))
|
||||
}
|
||||
|
||||
if (inProgress.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: 16, borderLeft: '4px solid #f59e0b' }}>
|
||||
<h2 style={{ marginBottom: 12, fontSize: '1rem', color: '#92400e' }}>
|
||||
⏸ In Progress ({inProgress.length})
|
||||
</h2>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{inProgress.map(a => (
|
||||
<div key={a.attempt_id} style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
padding: '10px 14px', background: 'var(--bg)', borderRadius: 8, gap: 12,
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: '0.9rem' }}>{a.quiz_title}</div>
|
||||
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)' }}>
|
||||
Started {new Date(a.started_at).toLocaleDateString()} · {a.total_questions} questions
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => navigate(`/quizzes/${a.quiz_id}`)}>Resume</button>
|
||||
<ConfirmButton
|
||||
label="Delete" confirmLabel="Yes, delete"
|
||||
onConfirm={() => deleteAttempt(a.attempt_id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PastAttemptsSection() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [history, setHistory] = useState(null)
|
||||
|
|
@ -403,6 +358,24 @@ export default function QuizzesPage() {
|
|||
<QuestionStudyModal question={studyQuestion} query={searchQuery} onClose={() => setStudyQuestion(null)} />
|
||||
)}
|
||||
|
||||
<div className="card" style={{ marginBottom: 16, borderLeft: '4px solid #229ed9', display: 'flex', justifyContent: 'space-between', gap: 14, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, fontSize: '1rem', marginBottom: 4 }}>Use our Telegram bot</div>
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>
|
||||
Start quick random quizzes, browse categories, and study questions from Telegram.
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="https://t.me/pedshubbot"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-primary"
|
||||
style={{ textDecoration: 'none', flexShrink: 0 }}
|
||||
>
|
||||
Open @pedshubbot
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
|
|
@ -488,7 +461,7 @@ export default function QuizzesPage() {
|
|||
)}
|
||||
|
||||
{/* In-progress quizzes */}
|
||||
{!isSearching && <InProgressSection />}
|
||||
{!isSearching && <InProgressQuizzes />}
|
||||
|
||||
{/* Past attempts (loads on demand) */}
|
||||
{!isSearching && <PastAttemptsSection />}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
|
||||
function NextcloudBrowser({ onFile }) {
|
||||
|
|
@ -49,7 +49,7 @@ function NextcloudBrowser({ onFile }) {
|
|||
<div style={{ padding: '20px', textAlign: 'center', color: 'var(--text-muted)', fontSize: '0.875rem' }}>
|
||||
<div style={{ fontSize: '1.5rem', marginBottom: 8 }}>☁️</div>
|
||||
No Nextcloud account configured.{' '}
|
||||
<a href="/settings" style={{ color: 'var(--primary)' }}>Go to Settings</a> to add one.
|
||||
<Link to="/settings" style={{ color: 'var(--primary)' }}>Go to Settings</Link> to add one.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
31
frontend/src/pages/UploadPage.test.jsx
Normal file
31
frontend/src/pages/UploadPage.test.jsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import UploadPage from './UploadPage'
|
||||
|
||||
vi.mock('../api/client', () => ({
|
||||
default: {
|
||||
post: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('UploadPage', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('uses client-side navigation for the settings link', async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<UploadPage />
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: '☁️ Nextcloud (not set up)' }))
|
||||
|
||||
const settingsLink = screen.getByRole('link', { name: 'Go to Settings' })
|
||||
expect(settingsLink).toHaveAttribute('href', '/settings')
|
||||
})
|
||||
})
|
||||
7
frontend/src/test/setup.js
Normal file
7
frontend/src/test/setup.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import '@testing-library/jest-dom/vitest'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { afterEach } from 'vitest'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
|
@ -7,5 +7,9 @@ export default defineConfig({
|
|||
proxy: {
|
||||
'/api': 'http://localhost:8000'
|
||||
}
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test/setup.js'
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,6 +3,12 @@ apply plugin: 'com.android.application'
|
|||
android {
|
||||
namespace "com.pedshub.quiz"
|
||||
compileSdk rootProject.ext.compileSdkVersion
|
||||
def releaseKeystoreFile = System.getenv("ANDROID_KEYSTORE_FILE")
|
||||
def releaseKeystorePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")
|
||||
def releaseKeyAlias = System.getenv("ANDROID_KEY_ALIAS")
|
||||
def releaseKeyPassword = System.getenv("ANDROID_KEY_PASSWORD")
|
||||
def hasReleaseSigning = releaseKeystoreFile && releaseKeystorePassword && releaseKeyAlias && releaseKeyPassword
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.pedshub.quiz"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
|
|
@ -13,12 +19,25 @@ android {
|
|||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
}
|
||||
signingConfigs {
|
||||
release {
|
||||
if (hasReleaseSigning) {
|
||||
storeFile file(releaseKeystoreFile)
|
||||
storePassword releaseKeystorePassword
|
||||
keyAlias releaseKeyAlias
|
||||
keyPassword releaseKeyPassword
|
||||
}
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
if (hasReleaseSigning) {
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,9 +20,9 @@
|
|||
}
|
||||
},
|
||||
"android": {
|
||||
"allowMixedContent": true,
|
||||
"allowMixedContent": false,
|
||||
"backgroundColor": "#0f172a",
|
||||
"webContentsDebuggingEnabled": true,
|
||||
"webContentsDebuggingEnabled": false,
|
||||
"androidScheme": "https"
|
||||
},
|
||||
"ios": {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
|
||||
<div class="form-group">
|
||||
<label>Server URL</label>
|
||||
<input type="url" id="server-url" placeholder="https://pedshub.com" autocapitalize="none" autocorrect="off" spellcheck="false">
|
||||
<input type="url" id="server-url" placeholder="https://quiz.danvics.com" autocapitalize="none" autocorrect="off" spellcheck="false">
|
||||
</div>
|
||||
|
||||
<button id="btn-connect" class="btn-primary">Connect</button>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
(function() {
|
||||
var STORAGE_KEY = 'pedshub_server_url';
|
||||
var DEFAULT_URL = 'https://pedshub.com';
|
||||
var DEFAULT_URL = 'https://quiz.danvics.com';
|
||||
|
||||
var setupScreen = document.getElementById('setup-screen');
|
||||
var connectingScreen = document.getElementById('connecting-screen');
|
||||
|
|
|
|||
|
|
@ -20,4 +20,4 @@
|
|||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||
<item name="android:background">@drawable/splash</item>
|
||||
</style>
|
||||
</resources>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@ include ':app'
|
|||
include ':capacitor-cordova-android-plugins'
|
||||
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
|
||||
|
||||
apply from: 'capacitor.settings.gradle'
|
||||
apply from: 'capacitor.settings.gradle'
|
||||
|
|
|
|||
|
|
@ -18,9 +18,9 @@
|
|||
}
|
||||
},
|
||||
"android": {
|
||||
"allowMixedContent": true,
|
||||
"allowMixedContent": false,
|
||||
"backgroundColor": "#0f172a",
|
||||
"webContentsDebuggingEnabled": true,
|
||||
"webContentsDebuggingEnabled": false,
|
||||
"androidScheme": "https"
|
||||
},
|
||||
"ios": {
|
||||
|
|
|
|||
|
|
@ -20,9 +20,9 @@
|
|||
}
|
||||
},
|
||||
"android": {
|
||||
"allowMixedContent": true,
|
||||
"allowMixedContent": false,
|
||||
"backgroundColor": "#0f172a",
|
||||
"webContentsDebuggingEnabled": true,
|
||||
"webContentsDebuggingEnabled": false,
|
||||
"androidScheme": "https"
|
||||
},
|
||||
"ios": {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
|
||||
<div class="form-group">
|
||||
<label>Server URL</label>
|
||||
<input type="url" id="server-url" placeholder="https://pedshub.com" autocapitalize="none" autocorrect="off" spellcheck="false">
|
||||
<input type="url" id="server-url" placeholder="https://quiz.danvics.com" autocapitalize="none" autocorrect="off" spellcheck="false">
|
||||
</div>
|
||||
|
||||
<button id="btn-connect" class="btn-primary">Connect</button>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
(function() {
|
||||
var STORAGE_KEY = 'pedshub_server_url';
|
||||
var DEFAULT_URL = 'https://pedshub.com';
|
||||
var DEFAULT_URL = 'https://quiz.danvics.com';
|
||||
|
||||
var setupScreen = document.getElementById('setup-screen');
|
||||
var connectingScreen = document.getElementById('connecting-screen');
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@
|
|||
"sync": "npx cap sync",
|
||||
"android": "npx cap open android",
|
||||
"ios": "npx cap open ios",
|
||||
"build:android": "npx cap sync android && cd android && ./gradlew assembleRelease"
|
||||
"build:android": "npx cap sync android && cd android && ./gradlew assembleRelease",
|
||||
"build:android:release": "npx cap sync android && cd android && ./gradlew assembleRelease",
|
||||
"build:android:debug": "npx cap sync android && cd android && ./gradlew assembleDebug"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/android": "^6.0.0",
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
|
||||
<div class="form-group">
|
||||
<label>Server URL</label>
|
||||
<input type="url" id="server-url" placeholder="https://pedshub.com" autocapitalize="none" autocorrect="off" spellcheck="false">
|
||||
<input type="url" id="server-url" placeholder="https://quiz.danvics.com" autocapitalize="none" autocorrect="off" spellcheck="false">
|
||||
</div>
|
||||
|
||||
<button id="btn-connect" class="btn-primary">Connect</button>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
(function() {
|
||||
var STORAGE_KEY = 'pedshub_server_url';
|
||||
var DEFAULT_URL = 'https://pedshub.com';
|
||||
var DEFAULT_URL = 'https://quiz.danvics.com';
|
||||
|
||||
var setupScreen = document.getElementById('setup-screen');
|
||||
var connectingScreen = document.getElementById('connecting-screen');
|
||||
|
|
|
|||
|
|
@ -13,10 +13,14 @@ scrape_configs:
|
|||
docker_sd_configs:
|
||||
- host: unix:///var/run/docker.sock
|
||||
refresh_interval: 5s
|
||||
filters:
|
||||
- name: label
|
||||
values: ["com.docker.compose.project=quiz"]
|
||||
relabel_configs:
|
||||
# Keep only quiz-* containers
|
||||
- source_labels: ['__meta_docker_container_name']
|
||||
regex: '.*quiz.*'
|
||||
# Keep only this compose project; the Docker API filter above prevents
|
||||
# Promtail from opening non-readable log streams from other stacks.
|
||||
- source_labels: ['__meta_docker_container_label_com_docker_compose_project']
|
||||
regex: 'quiz'
|
||||
action: keep
|
||||
# Extract container name as label
|
||||
- source_labels: ['__meta_docker_container_name']
|
||||
|
|
|
|||
13
telegram-bot/Dockerfile
Normal file
13
telegram-bot/Dockerfile
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY bot.py .
|
||||
|
||||
CMD ["python", "bot.py"]
|
||||
615
telegram-bot/bot.py
Normal file
615
telegram-bot/bot.py
Normal file
|
|
@ -0,0 +1,615 @@
|
|||
import html
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
from telegram import BotCommand, InlineKeyboardButton, InlineKeyboardMarkup, Update
|
||||
from telegram.constants import ChatAction, ParseMode
|
||||
from telegram.ext import Application, CallbackQueryHandler, CommandHandler, ContextTypes, MessageHandler, filters
|
||||
|
||||
|
||||
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO"))
|
||||
logger = logging.getLogger("quiz-telegram-bot")
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
|
||||
TELEGRAM_BOT_TOKEN = os.environ["TELEGRAM_BOT_TOKEN"]
|
||||
DATABASE_URL = os.environ["DATABASE_URL"]
|
||||
DEFAULT_QUIZ_SIZE = int(os.getenv("DEFAULT_QUIZ_SIZE", "20"))
|
||||
MAX_QUIZ_SIZE = int(os.getenv("MAX_QUIZ_SIZE", "50"))
|
||||
PUBLIC_APP_URL = os.getenv("PUBLIC_APP_URL", "https://pedshub.com").rstrip("/")
|
||||
TELEGRAM_MESSAGE_LIMIT = 4096
|
||||
|
||||
LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
NUMBER_RE = re.compile(r"^\s*(\d{1,3})\s*$")
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuizState:
|
||||
questions: list[dict[str, Any]]
|
||||
mode: str = "study"
|
||||
index: int = 0
|
||||
score: int = 0
|
||||
answers: list[tuple[int, str, str, bool]] = field(default_factory=list)
|
||||
|
||||
|
||||
active_quizzes: dict[int, QuizState] = {}
|
||||
|
||||
|
||||
def db_query(sql: str, params: tuple[Any, ...] = ()) -> list[dict[str, Any]]:
|
||||
with psycopg.connect(DATABASE_URL, row_factory=dict_row) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
return list(cur.fetchall())
|
||||
|
||||
|
||||
def normalize_options(value: Any) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return [str(item) for item in value]
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
if isinstance(parsed, list):
|
||||
return [str(item) for item in parsed]
|
||||
return []
|
||||
|
||||
|
||||
def answer_index(question: dict[str, Any], options: list[str]) -> int | None:
|
||||
raw = str(question.get("correct_answer") or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
upper = raw.upper()
|
||||
if len(upper) == 1 and upper in LETTERS:
|
||||
idx = LETTERS.index(upper)
|
||||
return idx if idx < len(options) else None
|
||||
if raw.isdigit():
|
||||
idx = int(raw) - 1
|
||||
return idx if 0 <= idx < len(options) else None
|
||||
for idx, option in enumerate(options):
|
||||
if option.strip().lower() == raw.lower():
|
||||
return idx
|
||||
return None
|
||||
|
||||
|
||||
def question_base_sql(where: str = "") -> str:
|
||||
return f"""
|
||||
select q.id, q.question_text, q.options, q.correct_answer, q.explanation, qc.name as category
|
||||
from questions q
|
||||
left join question_categories qc on qc.id = q.question_category_id
|
||||
where q.question_type = 'mcq'
|
||||
and q.options is not null
|
||||
and q.is_shared = 1
|
||||
{where}
|
||||
order by random()
|
||||
limit %s
|
||||
"""
|
||||
|
||||
|
||||
def valid_questions(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
clean = []
|
||||
for row in rows:
|
||||
options = normalize_options(row.get("options"))
|
||||
if len(options) < 2:
|
||||
continue
|
||||
idx = answer_index(row, options)
|
||||
if idx is None:
|
||||
continue
|
||||
row["options"] = options
|
||||
row["correct_index"] = idx
|
||||
clean.append(row)
|
||||
return clean
|
||||
|
||||
|
||||
def random_questions(limit: int) -> list[dict[str, Any]]:
|
||||
rows = db_query(question_base_sql(), (limit * 2,))
|
||||
return valid_questions(rows)[:limit]
|
||||
|
||||
|
||||
def search_questions(term: str, limit: int) -> list[dict[str, Any]]:
|
||||
pattern = f"%{term}%"
|
||||
rows = db_query(
|
||||
question_base_sql("""
|
||||
and (
|
||||
q.question_text ilike %s
|
||||
or q.explanation ilike %s
|
||||
or qc.name ilike %s
|
||||
or exists (
|
||||
select 1
|
||||
from question_tag_links qtl
|
||||
join question_tags qt on qt.id = qtl.tag_id
|
||||
where qtl.question_id = q.id and qt.name ilike %s
|
||||
)
|
||||
)
|
||||
"""),
|
||||
(pattern, pattern, pattern, pattern, limit * 2),
|
||||
)
|
||||
return valid_questions(rows)[:limit]
|
||||
|
||||
|
||||
def category_questions(category_id: int, limit: int) -> list[dict[str, Any]]:
|
||||
rows = db_query(question_base_sql("and q.question_category_id = %s"), (category_id, limit * 2))
|
||||
return valid_questions(rows)[:limit]
|
||||
|
||||
|
||||
def tag_questions(tag_id: int, limit: int) -> list[dict[str, Any]]:
|
||||
rows = db_query(
|
||||
question_base_sql("and exists (select 1 from question_tag_links qtl where qtl.question_id = q.id and qtl.tag_id = %s)"),
|
||||
(tag_id, limit * 2),
|
||||
)
|
||||
return valid_questions(rows)[:limit]
|
||||
|
||||
|
||||
def list_categories() -> list[dict[str, Any]]:
|
||||
return db_query(
|
||||
"""
|
||||
select qc.id, qc.name, count(q.id)::int as count
|
||||
from question_categories qc
|
||||
join questions q on q.question_category_id = qc.id and q.question_type = 'mcq' and q.is_shared = 1
|
||||
group by qc.id, qc.name
|
||||
having count(q.id) > 0
|
||||
order by qc.name
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def search_tags(term: str | None = None, limit: int = 20) -> list[dict[str, Any]]:
|
||||
where = ""
|
||||
params: list[Any] = []
|
||||
if term:
|
||||
where = "where t.name ilike %s"
|
||||
params.append(f"%{term}%")
|
||||
params.append(limit)
|
||||
return db_query(
|
||||
f"""
|
||||
select t.id, t.name, t.type, count(qtl.question_id)::int as count
|
||||
from question_tags t
|
||||
join question_tag_links qtl on qtl.tag_id = t.id
|
||||
join questions q on q.id = qtl.question_id and q.question_type = 'mcq' and q.is_shared = 1
|
||||
{where}
|
||||
group by t.id, t.name, t.type
|
||||
order by count(qtl.question_id) desc, t.name
|
||||
limit %s
|
||||
""",
|
||||
tuple(params),
|
||||
)
|
||||
|
||||
|
||||
def clamp_count(value: int | None) -> int:
|
||||
if value is None:
|
||||
return DEFAULT_QUIZ_SIZE
|
||||
return max(1, min(MAX_QUIZ_SIZE, value))
|
||||
|
||||
|
||||
def parse_count(args: list[str]) -> int:
|
||||
for arg in args:
|
||||
if arg.isdigit():
|
||||
return clamp_count(int(arg))
|
||||
return DEFAULT_QUIZ_SIZE
|
||||
|
||||
|
||||
def parse_mode(args: list[str]) -> str:
|
||||
lowered = {arg.lower() for arg in args}
|
||||
return "exam" if "exam" in lowered else "study"
|
||||
|
||||
|
||||
async def send_text(update: Update, text: str, reply_markup: InlineKeyboardMarkup | None = None) -> None:
|
||||
if update.message:
|
||||
await update.message.reply_text(
|
||||
text,
|
||||
parse_mode=ParseMode.HTML,
|
||||
disable_web_page_preview=True,
|
||||
reply_markup=reply_markup,
|
||||
)
|
||||
|
||||
|
||||
async def respond(update: Update, context: ContextTypes.DEFAULT_TYPE | None, text: str, reply_markup: InlineKeyboardMarkup | None = None) -> None:
|
||||
if update.message:
|
||||
await send_text(update, text, reply_markup)
|
||||
elif update.callback_query and update.callback_query.message:
|
||||
await update.callback_query.edit_message_text(text, parse_mode=ParseMode.HTML, reply_markup=reply_markup, disable_web_page_preview=True)
|
||||
elif context and update.effective_chat:
|
||||
await context.bot.send_message(update.effective_chat.id, text, parse_mode=ParseMode.HTML, reply_markup=reply_markup, disable_web_page_preview=True)
|
||||
|
||||
|
||||
def main_menu() -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup([
|
||||
[InlineKeyboardButton("Random 20 study", callback_data="quiz:random:20:study")],
|
||||
[InlineKeyboardButton("Random 20 exam", callback_data="quiz:random:20:exam")],
|
||||
[InlineKeyboardButton("Categories", callback_data="list:categories:0")],
|
||||
[InlineKeyboardButton("Top keywords", callback_data="list:tags:0")],
|
||||
])
|
||||
|
||||
|
||||
def count_menu(kind: str, item_id: int) -> InlineKeyboardMarkup:
|
||||
buttons = []
|
||||
for count in (5, 10, 20, 30, 50):
|
||||
buttons.append([
|
||||
InlineKeyboardButton(f"{count} study", callback_data=f"start:{kind}:{item_id}:{count}:study"),
|
||||
InlineKeyboardButton(f"{count} exam", callback_data=f"start:{kind}:{item_id}:{count}:exam"),
|
||||
])
|
||||
return InlineKeyboardMarkup(buttons)
|
||||
|
||||
|
||||
def format_question_review(state: QuizState, question: dict[str, Any], chosen_idx: int, correct_idx: int, ok: bool) -> str:
|
||||
lines = [
|
||||
f"<b>Question {state.index + 1}/{len(state.questions)}</b>",
|
||||
html.escape(question["question_text"]),
|
||||
"",
|
||||
]
|
||||
for idx, option in enumerate(question["options"][:8]):
|
||||
marker = ""
|
||||
if idx == correct_idx:
|
||||
marker = " correct"
|
||||
elif idx == chosen_idx:
|
||||
marker = " your answer"
|
||||
lines.append(f"{LETTERS[idx]}. {html.escape(option)}{marker}")
|
||||
lines.extend([
|
||||
"",
|
||||
"Correct." if ok else f"Incorrect. Correct answer: {LETTERS[correct_idx]}",
|
||||
"",
|
||||
f"<b>Explanation:</b> {html.escape(question.get('explanation') or 'No explanation available.')}",
|
||||
])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def truncate_text(value: str, limit: int) -> str:
|
||||
value = (value or "").strip()
|
||||
if len(value) <= limit:
|
||||
return value
|
||||
return value[:limit].rsplit(" ", 1)[0].rstrip() + "..."
|
||||
|
||||
|
||||
def answer_feedback_header(chosen_idx: int, correct_idx: int, ok: bool) -> str:
|
||||
chosen = LETTERS[chosen_idx] if chosen_idx < len(LETTERS) else "?"
|
||||
correct = LETTERS[correct_idx] if correct_idx < len(LETTERS) else "?"
|
||||
return "Correct." if ok else f"Incorrect. You chose {chosen}; correct answer: {correct}."
|
||||
|
||||
|
||||
def split_plain_text(text: str, limit: int) -> list[str]:
|
||||
if len(text) <= limit:
|
||||
return [text]
|
||||
chunks = []
|
||||
remaining = text
|
||||
while remaining:
|
||||
if len(remaining) <= limit:
|
||||
chunks.append(remaining)
|
||||
break
|
||||
split_at = remaining.rfind("\n\n", 0, limit)
|
||||
if split_at < limit // 2:
|
||||
split_at = remaining.rfind("\n", 0, limit)
|
||||
if split_at < limit // 2:
|
||||
split_at = remaining.rfind(" ", 0, limit)
|
||||
if split_at < limit // 2:
|
||||
split_at = limit
|
||||
chunks.append(remaining[:split_at].rstrip())
|
||||
remaining = remaining[split_at:].lstrip()
|
||||
return chunks
|
||||
|
||||
|
||||
async def send_answer_feedback(
|
||||
context: ContextTypes.DEFAULT_TYPE,
|
||||
chat_id: int,
|
||||
question: dict[str, Any],
|
||||
chosen_idx: int,
|
||||
correct_idx: int,
|
||||
ok: bool,
|
||||
) -> None:
|
||||
header = answer_feedback_header(chosen_idx, correct_idx, ok)
|
||||
explanation = (question.get("explanation") or "No explanation available.").strip()
|
||||
first_prefix = f"{header}\n\n<b>Explanation:</b> "
|
||||
next_prefix = "<b>Explanation continued:</b> "
|
||||
first_limit = TELEGRAM_MESSAGE_LIMIT - len(first_prefix) - 200
|
||||
next_limit = TELEGRAM_MESSAGE_LIMIT - len(next_prefix) - 200
|
||||
chunks = split_plain_text(explanation, max(1000, first_limit))
|
||||
for idx, chunk in enumerate(chunks):
|
||||
prefix = first_prefix if idx == 0 else next_prefix
|
||||
if idx > 0 and len(chunk) > next_limit:
|
||||
# First chunk has a smaller budget because it includes answer feedback.
|
||||
for subchunk in split_plain_text(chunk, next_limit):
|
||||
await context.bot.send_message(chat_id, f"{next_prefix}{html.escape(subchunk)}", parse_mode=ParseMode.HTML)
|
||||
continue
|
||||
await context.bot.send_message(chat_id, f"{prefix}{html.escape(chunk)}", parse_mode=ParseMode.HTML)
|
||||
|
||||
|
||||
def answered_question_text(state: QuizState, question: dict[str, Any], chosen_idx: int, correct_idx: int) -> str:
|
||||
lines = [
|
||||
f"<b>Question {state.index + 1}/{len(state.questions)}</b>",
|
||||
html.escape(truncate_text(question["question_text"], 1600)),
|
||||
"",
|
||||
]
|
||||
for idx, option in enumerate(question["options"][:8]):
|
||||
marker = ""
|
||||
if idx == correct_idx:
|
||||
marker = " ✓ correct"
|
||||
elif idx == chosen_idx:
|
||||
marker = " ✗ your answer"
|
||||
lines.append(f"{LETTERS[idx]}. {html.escape(truncate_text(option, 420))}{marker}")
|
||||
lines.extend([
|
||||
"",
|
||||
f"Category: {html.escape(str(question.get('category') or 'Uncategorized'))}",
|
||||
])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
await send_text(update, "\n".join([
|
||||
"<b>PedQuiz bot</b>",
|
||||
"Send <code>20</code> for a random 20-question study quiz.",
|
||||
"Use /random 20 exam for exam mode.",
|
||||
"Use /categories to choose from PREP/category buckets.",
|
||||
"Use /keywords fever to search generated keywords/subjects.",
|
||||
"Use /search sepsis 20 to quiz by text search.",
|
||||
"Use /stop to end the current quiz.",
|
||||
"",
|
||||
"Study mode shows each answer immediately. Exam mode shows answers at the end.",
|
||||
]), main_menu())
|
||||
|
||||
|
||||
async def random_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
await start_quiz(update, random_questions(parse_count(context.args)), parse_mode(context.args))
|
||||
|
||||
|
||||
async def search_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
if not context.args:
|
||||
await send_text(update, "Usage: /search <topic> [number] [study|exam]")
|
||||
return
|
||||
count = parse_count(context.args)
|
||||
mode = parse_mode(context.args)
|
||||
term = " ".join(arg for arg in context.args if not arg.isdigit() and arg.lower() not in {"study", "exam"}).strip()
|
||||
if not term:
|
||||
await send_text(update, "Usage: /search <topic> [number] [study|exam]")
|
||||
return
|
||||
if update.message:
|
||||
await update.message.chat.send_action(ChatAction.TYPING)
|
||||
await start_quiz(update, search_questions(term, count), mode, label=f"Search: {term}")
|
||||
|
||||
|
||||
async def categories_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
await show_categories(update, page=0)
|
||||
|
||||
|
||||
async def keywords_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
term = " ".join(context.args).strip() if context.args else None
|
||||
rows = search_tags(term, 30)
|
||||
if not rows:
|
||||
await send_text(update, "No keywords found.")
|
||||
return
|
||||
buttons = [
|
||||
[InlineKeyboardButton(f"{row['name']} ({row['type']}, {row['count']})", callback_data=f"pick:tag:{row['id']}")]
|
||||
for row in rows[:20]
|
||||
]
|
||||
await send_text(update, "Choose a keyword/subject, then choose quiz size:", InlineKeyboardMarkup(buttons))
|
||||
|
||||
|
||||
async def stop_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
if update.effective_chat:
|
||||
active_quizzes.pop(update.effective_chat.id, None)
|
||||
await send_text(update, "Quiz stopped.", main_menu())
|
||||
|
||||
|
||||
async def show_categories(update: Update, page: int) -> None:
|
||||
rows = list_categories()
|
||||
per_page = 10
|
||||
start = page * per_page
|
||||
chunk = rows[start:start + per_page]
|
||||
if not chunk:
|
||||
await send_text(update, "No categories found.")
|
||||
return
|
||||
buttons = [
|
||||
[InlineKeyboardButton(f"{row['name']} ({row['count']})", callback_data=f"pick:category:{row['id']}")]
|
||||
for row in chunk
|
||||
]
|
||||
nav = []
|
||||
if page > 0:
|
||||
nav.append(InlineKeyboardButton("Prev", callback_data=f"list:categories:{page - 1}"))
|
||||
if start + per_page < len(rows):
|
||||
nav.append(InlineKeyboardButton("Next", callback_data=f"list:categories:{page + 1}"))
|
||||
if nav:
|
||||
buttons.append(nav)
|
||||
text = "Choose a category, then choose quiz size and mode."
|
||||
if update.callback_query:
|
||||
await update.callback_query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(buttons))
|
||||
else:
|
||||
await send_text(update, text, InlineKeyboardMarkup(buttons))
|
||||
|
||||
|
||||
async def show_tags(update: Update, page: int) -> None:
|
||||
rows = search_tags(limit=80)
|
||||
per_page = 10
|
||||
start = page * per_page
|
||||
chunk = rows[start:start + per_page]
|
||||
buttons = [
|
||||
[InlineKeyboardButton(f"{row['name']} ({row['type']}, {row['count']})", callback_data=f"pick:tag:{row['id']}")]
|
||||
for row in chunk
|
||||
]
|
||||
nav = []
|
||||
if page > 0:
|
||||
nav.append(InlineKeyboardButton("Prev", callback_data=f"list:tags:{page - 1}"))
|
||||
if start + per_page < len(rows):
|
||||
nav.append(InlineKeyboardButton("Next", callback_data=f"list:tags:{page + 1}"))
|
||||
if nav:
|
||||
buttons.append(nav)
|
||||
text = "Choose a keyword/subject, then choose quiz size and mode."
|
||||
if update.callback_query:
|
||||
await update.callback_query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(buttons))
|
||||
else:
|
||||
await send_text(update, text, InlineKeyboardMarkup(buttons))
|
||||
|
||||
|
||||
async def start_quiz(
|
||||
update: Update,
|
||||
questions: list[dict[str, Any]],
|
||||
mode: str,
|
||||
label: str = "Random",
|
||||
context: ContextTypes.DEFAULT_TYPE | None = None,
|
||||
) -> None:
|
||||
if not update.effective_chat:
|
||||
return
|
||||
if not questions:
|
||||
await respond(update, context, "No usable MCQ questions found for that selection. Try /keywords, /categories, or a broader /search term.", main_menu())
|
||||
return
|
||||
random.shuffle(questions)
|
||||
active_quizzes[update.effective_chat.id] = QuizState(questions=questions, mode=mode)
|
||||
start_text = f"{html.escape(label)} quiz started: {len(questions)} questions, {mode} mode."
|
||||
if update.message:
|
||||
await send_text(update, start_text)
|
||||
await send_current_question(update.effective_chat.id, update, None)
|
||||
elif context:
|
||||
await context.bot.send_message(update.effective_chat.id, start_text, parse_mode=ParseMode.HTML)
|
||||
await send_current_question(update.effective_chat.id, None, context)
|
||||
|
||||
|
||||
async def send_current_question(chat_id: int, update: Update | None, context: ContextTypes.DEFAULT_TYPE | None) -> None:
|
||||
state = active_quizzes.get(chat_id)
|
||||
if not state:
|
||||
return
|
||||
question = state.questions[state.index]
|
||||
options = question["options"]
|
||||
buttons = [
|
||||
[InlineKeyboardButton(LETTERS[idx], callback_data=f"answer:{idx}")]
|
||||
for idx, option in enumerate(options[:8])
|
||||
]
|
||||
lines = [
|
||||
f"<b>Question {state.index + 1}/{len(state.questions)}</b>",
|
||||
html.escape(truncate_text(question["question_text"], 1600)),
|
||||
"",
|
||||
]
|
||||
for idx, option in enumerate(options[:8]):
|
||||
lines.append(f"{LETTERS[idx]}. {html.escape(truncate_text(option, 700))}")
|
||||
lines.extend([
|
||||
"",
|
||||
f"Category: {html.escape(str(question.get('category') or 'Uncategorized'))}",
|
||||
])
|
||||
text = "\n".join(lines)
|
||||
markup = InlineKeyboardMarkup(buttons)
|
||||
if update and update.message:
|
||||
await update.message.reply_text(text, parse_mode=ParseMode.HTML, reply_markup=markup)
|
||||
elif context:
|
||||
await context.bot.send_message(chat_id, text, parse_mode=ParseMode.HTML, reply_markup=markup)
|
||||
|
||||
|
||||
async def finish_quiz(chat_id: int, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
state = active_quizzes.pop(chat_id, None)
|
||||
if not state:
|
||||
return
|
||||
total = len(state.questions)
|
||||
lines = [f"Quiz complete. Score: {state.score}/{total}"]
|
||||
if state.mode == "exam":
|
||||
lines.append("")
|
||||
lines.append("Answers:")
|
||||
for question_id, chosen, correct, ok in state.answers:
|
||||
marker = "OK" if ok else "MISS"
|
||||
lines.append(f"Q{question_id}: {marker}. You: {chosen}. Correct: {correct}")
|
||||
lines.append("")
|
||||
lines.append(f"Full question bank: {PUBLIC_APP_URL}")
|
||||
await context.bot.send_message(chat_id, "\n".join(lines), reply_markup=main_menu(), disable_web_page_preview=True)
|
||||
|
||||
|
||||
async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
query = update.callback_query
|
||||
if not query:
|
||||
return
|
||||
await query.answer()
|
||||
data = query.data or ""
|
||||
chat_id = query.message.chat_id if query.message else update.effective_chat.id
|
||||
|
||||
if data.startswith("list:categories:"):
|
||||
await show_categories(update, int(data.rsplit(":", 1)[1]))
|
||||
return
|
||||
if data.startswith("list:tags:"):
|
||||
await show_tags(update, int(data.rsplit(":", 1)[1]))
|
||||
return
|
||||
if data.startswith("quiz:random:"):
|
||||
_, _, count, mode = data.split(":", 3)
|
||||
await start_quiz(update, random_questions(clamp_count(int(count))), mode, context=context)
|
||||
return
|
||||
if data.startswith("pick:category:"):
|
||||
category_id = int(data.rsplit(":", 1)[1])
|
||||
await query.edit_message_text("How many questions?", reply_markup=count_menu("category", category_id))
|
||||
return
|
||||
if data.startswith("pick:tag:"):
|
||||
tag_id = int(data.rsplit(":", 1)[1])
|
||||
await query.edit_message_text("How many questions?", reply_markup=count_menu("tag", tag_id))
|
||||
return
|
||||
if data.startswith("start:category:"):
|
||||
_, _, category_id, count, mode = data.split(":", 4)
|
||||
await start_quiz(update, category_questions(int(category_id), clamp_count(int(count))), mode, "Category", context)
|
||||
return
|
||||
if data.startswith("start:tag:"):
|
||||
_, _, tag_id, count, mode = data.split(":", 4)
|
||||
await start_quiz(update, tag_questions(int(tag_id), clamp_count(int(count))), mode, "Keyword", context)
|
||||
return
|
||||
if data.startswith("answer:"):
|
||||
state = active_quizzes.get(chat_id)
|
||||
if not state:
|
||||
await query.edit_message_text("This quiz expired. Start a new one with /random or /categories.")
|
||||
return
|
||||
chosen_idx = int(data.split(":", 1)[1])
|
||||
question = state.questions[state.index]
|
||||
correct_idx = question["correct_index"]
|
||||
ok = chosen_idx == correct_idx
|
||||
if ok:
|
||||
state.score += 1
|
||||
chosen = LETTERS[chosen_idx] if chosen_idx < len(LETTERS) else "?"
|
||||
correct = LETTERS[correct_idx] if correct_idx < len(LETTERS) else "?"
|
||||
state.answers.append((state.index + 1, chosen, correct, ok))
|
||||
if state.mode == "study":
|
||||
await query.edit_message_text(
|
||||
answered_question_text(state, question, chosen_idx, correct_idx),
|
||||
parse_mode=ParseMode.HTML,
|
||||
)
|
||||
await send_answer_feedback(context, chat_id, question, chosen_idx, correct_idx, ok)
|
||||
else:
|
||||
await query.edit_message_reply_markup(reply_markup=None)
|
||||
state.index += 1
|
||||
if state.index >= len(state.questions):
|
||||
await finish_quiz(chat_id, context)
|
||||
else:
|
||||
await send_current_question(chat_id, None, context)
|
||||
|
||||
|
||||
async def text_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
text = update.message.text if update.message else ""
|
||||
match = NUMBER_RE.match(text or "")
|
||||
if match:
|
||||
await start_quiz(update, random_questions(clamp_count(int(match.group(1)))), "study")
|
||||
return
|
||||
await send_text(update, "Send a number like 20, or use /categories, /keywords, /search, /random.", main_menu())
|
||||
|
||||
|
||||
async def post_init(app: Application) -> None:
|
||||
await app.bot.set_my_commands([
|
||||
BotCommand("start", "Show help and quick quiz buttons"),
|
||||
BotCommand("random", "Start a random quiz: /random 20 exam"),
|
||||
BotCommand("categories", "Browse categories"),
|
||||
BotCommand("keywords", "Browse/search keywords: /keywords fever"),
|
||||
BotCommand("search", "Search text/topic: /search sepsis 20"),
|
||||
BotCommand("stop", "Stop the current quiz"),
|
||||
])
|
||||
|
||||
|
||||
def main() -> None:
|
||||
app = Application.builder().token(TELEGRAM_BOT_TOKEN).post_init(post_init).build()
|
||||
app.add_handler(CommandHandler(["start", "help"], help_cmd))
|
||||
app.add_handler(CommandHandler("random", random_cmd))
|
||||
app.add_handler(CommandHandler("search", search_cmd))
|
||||
app.add_handler(CommandHandler("categories", categories_cmd))
|
||||
app.add_handler(CommandHandler(["keywords", "tags"], keywords_cmd))
|
||||
app.add_handler(CommandHandler("stop", stop_cmd))
|
||||
app.add_handler(CallbackQueryHandler(callback_handler))
|
||||
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, text_handler))
|
||||
app.run_polling(allowed_updates=Update.ALL_TYPES)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
2
telegram-bot/requirements.txt
Normal file
2
telegram-bot/requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
python-telegram-bot>=21.0,<22
|
||||
psycopg[binary]>=3.1,<4
|
||||
Loading…
Reference in a new issue