Compare commits

...

16 commits

Author SHA1 Message Date
Daniel
e7f91e5e5b fix: scope quiz logging and bot status 2026-06-06 00:01:44 +02:00
Daniel
95de56d81b Improve quiz note and navigation layout
All checks were successful
Mobile Android Release / android-release (push) Successful in 1m33s
2026-05-14 17:30:08 +02:00
Daniel
55713902ed Make MyNote draggable
All checks were successful
Mobile Android Release / android-release (push) Successful in 1m32s
2026-05-12 18:39:50 +02:00
Daniel
9f97218f39 Add persistent MyNote for quizzes
All checks were successful
Mobile Android Release / android-release (push) Successful in 1m32s
2026-05-12 17:51:23 +02:00
Daniel
4b75edef4c Remove highlight toast notifications 2026-05-12 17:02:41 +02:00
Daniel
e2c070b70b Refine quiz image zoom increments 2026-05-12 16:44:11 +02:00
Daniel
dd959371d1 Add dashboard in-progress quizzes 2026-05-12 16:41:44 +02:00
Daniel
d4ef94a117 Remove dashboard document management link 2026-05-12 16:38:33 +02:00
Daniel
17b0f06037 Add quiz image zoom controls 2026-05-12 16:36:53 +02:00
Daniel
4f347a18a3 Ignore local Firecrawl cache 2026-05-12 16:20:10 +02:00
Daniel
ffeb35922a Improve quiz UX and add Telegram bot 2026-05-12 15:57:27 +02:00
Daniel
dcab250223 Use explicit Android build actions 2026-05-12 01:25:40 +02:00
Daniel
a80effe9c6 Allow manual Android release tag 2026-05-12 01:07:24 +02:00
Daniel
9b691eb2fd Use local Forgejo runner for Android build 2026-05-12 01:05:37 +02:00
Daniel
fdebda993c Improve quiz TTS and Android release build
Some checks failed
Mobile Android Release / android-release (push) Failing after 1s
2026-05-12 01:01:01 +02:00
Daniel
d59c8bed6f Remove coach modes and improve quiz audio 2026-05-10 01:24:06 +02:00
62 changed files with 6592 additions and 1725 deletions

View 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
View file

@ -25,3 +25,4 @@ backend/.env.save
# Database backups
backups/
.firecrawl/

View file

@ -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.

View file

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

View file

@ -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

View 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')

View file

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

View file

@ -11,30 +11,39 @@ from app.logging_config import setup_logging
# Configure structured JSON logging before anything else
setup_logging(settings.LOG_LEVEL)
from app.database import engine, Base, SessionLocal
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses, mobile, 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")

View file

@ -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",
]

View file

@ -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):

View 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")

View file

@ -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)

View file

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

View file

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

View file

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

View file

@ -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}

View file

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

View file

@ -1,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}

View file

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

View file

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

View file

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

View file

@ -233,6 +233,56 @@ def _call_model(prompt: str, model_id: str | None, api_key: str | None) -> str:
return response.choices[0].message.content
def _parse_json_response(text: str) -> dict:
text = text.strip()
if text.startswith("```"):
text = text.split("\n", 1)[1] if "\n" in text else text[3:]
if text.endswith("```"):
text = text[:-3]
text = text.strip()
return json.loads(text)
def transcribe_audio(
audio: bytes,
filename: str = "audio.webm",
content_type: str = "audio/webm",
model_id: str | None = None,
api_key: str | None = None,
) -> str | None:
"""Transcribe uploaded audio through LiteLLM's OpenAI-compatible audio endpoint."""
import httpx
if not audio:
return None
base = (settings.LITELLM_API_BASE or "").rstrip("/").removesuffix("/v1")
if not base:
logger.error("LiteLLM API base not configured for STT")
return None
key = api_key or settings.LITELLM_API_KEY
headers = {"Authorization": f"Bearer {key}"} if key else {}
try:
resp = httpx.post(
f"{base}/v1/audio/transcriptions",
headers=headers,
data={"model": model_id or "local-parakeet-v3", "response_format": "json"},
files={"file": (filename or "audio.webm", audio, content_type or "application/octet-stream")},
timeout=120,
)
resp.raise_for_status()
try:
data = resp.json()
if isinstance(data, dict):
return str(data.get("text") or data.get("transcript") or data.get("transcription") or "").strip()
except ValueError:
return resp.text.strip()
except Exception as e:
logger.error(f"LiteLLM STT failed: {e}")
return None
def extract_questions_no_answers(
content: str,
page_info: str = "unknown",
@ -330,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/", "")

View file

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

View file

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

View file

@ -1,5 +1,3 @@
version: "3.8"
services:
postgres:
image: pgvector/pgvector:pg16
@ -37,6 +35,9 @@ services:
volumes:
- uploads_data:/app/uploads
- chroma_data:/app/chroma_data
networks:
- default
- 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

View file

@ -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

View file

@ -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`.

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -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>
)
}

View 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>
)
}

View file

@ -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) ───────────────────────────── */

View file

@ -5,7 +5,7 @@ import api from '../api/client'
import Dialog from '../components/Dialog'
import { useDialog } from '../hooks/useDialog'
const TASKS = ['extraction', 'tts', 'teach', 'keyword', 'flashcard']
const TASKS = ['extraction', 'tts', 'stt', 'teach', 'keyword', 'flashcard']
export default function AdminPage() {
const { user } = useAuth()
@ -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)' }}>

View file

@ -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>
)
}

View file

@ -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 &amp; 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>

View file

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

View file

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

View file

@ -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 && (

View file

@ -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 />}

View file

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

View file

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

View file

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

View file

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

View file

@ -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'
}
}

View file

@ -20,9 +20,9 @@
}
},
"android": {
"allowMixedContent": true,
"allowMixedContent": false,
"backgroundColor": "#0f172a",
"webContentsDebuggingEnabled": true,
"webContentsDebuggingEnabled": false,
"androidScheme": "https"
},
"ios": {

View file

@ -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>

View file

@ -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');

View file

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

View file

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

View file

@ -18,9 +18,9 @@
}
},
"android": {
"allowMixedContent": true,
"allowMixedContent": false,
"backgroundColor": "#0f172a",
"webContentsDebuggingEnabled": true,
"webContentsDebuggingEnabled": false,
"androidScheme": "https"
},
"ios": {

View file

@ -20,9 +20,9 @@
}
},
"android": {
"allowMixedContent": true,
"allowMixedContent": false,
"backgroundColor": "#0f172a",
"webContentsDebuggingEnabled": true,
"webContentsDebuggingEnabled": false,
"androidScheme": "https"
},
"ios": {

View file

@ -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>

View file

@ -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');

View file

@ -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",

View file

@ -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>

View file

@ -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');

View file

@ -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
View 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
View 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()

View file

@ -0,0 +1,2 @@
python-telegram-bot>=21.0,<22
psycopg[binary]>=3.1,<4