From ffeb35922aca1fb160a1d47e597afed8d680f4d1 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 12 May 2026 15:57:27 +0200 Subject: [PATCH] Improve quiz UX and add Telegram bot --- docker-compose.yml | 12 + frontend/src/index.css | 78 +++- frontend/src/pages/LandingPage.jsx | 33 +- frontend/src/pages/QuizPage.jsx | 215 ++++++++--- frontend/src/pages/QuizzesPage.jsx | 18 + telegram-bot/Dockerfile | 13 + telegram-bot/bot.py | 574 +++++++++++++++++++++++++++++ telegram-bot/requirements.txt | 2 + 8 files changed, 872 insertions(+), 73 deletions(-) create mode 100644 telegram-bot/Dockerfile create mode 100644 telegram-bot/bot.py create mode 100644 telegram-bot/requirements.txt diff --git a/docker-compose.yml b/docker-compose.yml index 99f44a7..4ff377c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -69,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: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 diff --git a/frontend/src/index.css b/frontend/src/index.css index af02efc..f3ac9ef 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -210,11 +210,68 @@ 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; } -.manual-highlight-toolbar { display: inline-flex; gap: 6px; flex-wrap: wrap; align-items: center; } -.manual-highlight-segment { user-select: text; -webkit-user-select: text; } +.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; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(15, 23, 42, 0.82); + cursor: zoom-out; +} +.image-lightbox img { + max-width: min(100%, 1100px); + max-height: 92vh; + border-radius: 10px; + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35); + cursor: default; +} +.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; +} +.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; } @@ -401,6 +458,23 @@ body { .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; + } } /* ── Lesson content (markdown/HTML) ───────────────────────────── */ diff --git a/frontend/src/pages/LandingPage.jsx b/frontend/src/pages/LandingPage.jsx index 432651d..d6fa55f 100644 --- a/frontend/src/pages/LandingPage.jsx +++ b/frontend/src/pages/LandingPage.jsx @@ -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() { - {/* ── AI Scribe section ──────────────────────────────────────────────── */} + {/* ── Clinical tools section ─────────────────────────────────────────── */}
@@ -438,22 +443,23 @@ export default function LandingPage() { Also from PedsHub

- Pediatric AI Scribe + Pediatric Clinical Tools

- A clinical assistant built for pediatric providers. Voice-to-note documentation, - age-specific well visit workflows, developmental milestones, vaccine schedules, - catch-up planners, automatic ICD-10 billing codes, and a full pediatric - calculators & bedside emergency reference — all in one tool. + A pediatric clinical assistant for daily practice: well visit workflows, + developmental milestones, vaccine schedules, catch-up planning, + bedside calculators, dosing support, emergency pathways, and concise + clinical references in one place, powered by Peds-AI clinical assistant tools.

{[ { 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) => (
{item.text} @@ -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 ↗
@@ -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) => (
Contact - AI Scribe + Clinical Tools
diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index ce28767..1a7531a 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -51,17 +51,20 @@ function getSpeechChunkRange(text, maxWords, activeChunk) { return { start: words[startWord].start, end: words[endWord].end } } -function getManualHighlightSelection() { - const selection = window.getSelection?.() +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) + offset, + offset: Number(span.dataset.start || 0) + charOffset, } } @@ -74,7 +77,7 @@ function getManualHighlightSelection() { return { id: start.id, ...ordered } } -function ManualHighlightText({ text, textId, highlights = [], speechRange = null }) { +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) }) @@ -94,7 +97,18 @@ function ManualHighlightText({ text, textId, highlights = [], speechRange = null speechHighlighted ? 'speech-highlight-active' : '', ].filter(Boolean).join(' ') return ( - + { + event.preventDefault() + onRemoveHighlight?.(textId, start) + } : undefined} + title={manuallyHighlighted ? 'Right-click to remove this highlight' : undefined} + > {text.slice(start, end)} ) @@ -344,8 +358,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() @@ -370,6 +397,7 @@ export default function QuizPage() { const [totalTime, setTotalTime] = useState(null) const [toast, setToast] = useState('') const [navOpen, setNavOpen] = useState(false) + const [expandedImagePath, setExpandedImagePath] = useState('') const [startedAt, setStartedAt] = useState(null) const [favorites, setFavorites] = useState([]) const [activeReadSegment, setActiveReadSegment] = useState(null) @@ -379,6 +407,8 @@ export default function QuizPage() { 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) @@ -406,6 +436,54 @@ export default function QuizPage() { 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 + showToast(removeExisting ? 'Highlight removed' : 'Highlighted') + }, [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 @@ -439,6 +517,8 @@ export default function QuizPage() { useEffect(() => { setActiveReadSegment(null) setTtsActive(false) + savedHighlightSelectionRef.current = null + clearTimeout(autoHighlightTimerRef.current) if (!readThrough) setActiveReadSegment(null) clearTimeout(autoAdvanceRef.current) }, [currentIdx, readThrough]) @@ -572,6 +652,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) } } @@ -590,55 +681,45 @@ 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, quizMode, selectedVoice, 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 updateManualHighlights = (action) => { - if (!current) return - const selected = getManualHighlightSelection() - if (!selected) { - showToast('Select question or option text first') - return - } - const [questionKey, fieldKey] = selected.id.split('::') - if (questionKey !== String(current.id)) { - showToast('Select text from the current question') - return - } - setManualHighlights(prev => { - const questionRanges = prev[current.id] || {} - const existing = questionRanges[fieldKey] || [] - const nextRanges = action === 'remove' - ? removeTextRange(existing, selected) - : mergeTextRanges([...existing, { start: selected.start, end: selected.end }]) - const nextQuestion = { ...questionRanges, [fieldKey]: nextRanges } - if (!nextRanges.length) delete nextQuestion[fieldKey] - return { ...prev, [current.id]: nextQuestion } - }) - window.getSelection?.().removeAllRanges() - showToast(action === 'remove' ? 'Highlight removed' : 'Highlighted') - } - const clearCurrentHighlights = () => { if (!current || !manualHighlights[current.id]) return setManualHighlights(prev => { @@ -649,6 +730,25 @@ const timerStarted = timeLeft !== null showToast('Question highlights cleared') } + 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 + }) + showToast('Highlight removed') + } + const highlightsFor = (fieldKey) => manualHighlights[current?.id]?.[fieldKey] || [] const hasActiveTextSelection = () => Boolean(window.getSelection?.().toString().trim()) @@ -889,6 +989,7 @@ const timerStarted = timeLeft !== null textId={`${current.id}::question`} highlights={highlightsFor('question')} speechRange={questionSpeechRange} + onRemoveHighlight={removeJoinedHighlight} /> - -
@@ -957,10 +1052,15 @@ const timerStarted = timeLeft !== null {current.question_type === 'mcq' ? 'Multiple Choice' : current.question_type === 'true_false' ? 'True / False' : 'Fill in the Blank'} {current.image_path && ( -
+ + )} + {expandedImagePath && ( +
setExpandedImagePath('')}> + + Expanded question illustration e.stopPropagation()} />
)} {(current.question_type === 'mcq' || current.question_type === 'true_false') && current.options ? ( @@ -994,6 +1094,7 @@ const timerStarted = timeLeft !== null textId={`${current.id}::${optionFieldKey}`} highlights={highlightsFor(optionFieldKey)} speechRange={optionSpeechRange} + onRemoveHighlight={removeJoinedHighlight} /> {showCorrect && ✓ Correct} diff --git a/frontend/src/pages/QuizzesPage.jsx b/frontend/src/pages/QuizzesPage.jsx index 47d0cb6..e4164c8 100644 --- a/frontend/src/pages/QuizzesPage.jsx +++ b/frontend/src/pages/QuizzesPage.jsx @@ -403,6 +403,24 @@ export default function QuizzesPage() { setStudyQuestion(null)} /> )} +
+
+
Use our Telegram bot
+
+ Start quick random quizzes, browse categories, and study questions from Telegram. +
+
+ + Open @pedshubbot + +
+ {/* Search bar */}
diff --git a/telegram-bot/Dockerfile b/telegram-bot/Dockerfile new file mode 100644 index 0000000..a222de2 --- /dev/null +++ b/telegram-bot/Dockerfile @@ -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"] diff --git a/telegram-bot/bot.py b/telegram-bot/bot.py new file mode 100644 index 0000000..478d89c --- /dev/null +++ b/telegram-bot/bot.py @@ -0,0 +1,574 @@ +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("/") +MAX_EXPLANATION_CHARS = int(os.getenv("MAX_EXPLANATION_CHARS", "1800")) + +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"Question {state.index + 1}/{len(state.questions)}", + 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"Explanation: {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(question: dict[str, Any], 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 "?" + explanation = truncate_text(question.get("explanation") or "No explanation available.", MAX_EXPLANATION_CHARS) + return "\n".join([ + "Correct." if ok else f"Incorrect. You chose {chosen}; correct answer: {correct}.", + "", + f"Explanation: {html.escape(explanation)}", + ]) + + +def answered_question_text(state: QuizState, question: dict[str, Any], chosen_idx: int, correct_idx: int) -> str: + lines = [ + f"Question {state.index + 1}/{len(state.questions)}", + 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([ + "PedQuiz bot", + "Send 20 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 [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 [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"Question {state.index + 1}/{len(state.questions)}", + 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 context.bot.send_message(chat_id, answer_feedback(question, chosen_idx, correct_idx, ok), parse_mode=ParseMode.HTML) + 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() diff --git a/telegram-bot/requirements.txt b/telegram-bot/requirements.txt new file mode 100644 index 0000000..890e2cd --- /dev/null +++ b/telegram-bot/requirements.txt @@ -0,0 +1,2 @@ +python-telegram-bot>=21.0,<22 +psycopg[binary]>=3.1,<4