diff --git a/telegram-bot/bot.py b/telegram-bot/bot.py index 824a096..477990b 100644 --- a/telegram-bot/bot.py +++ b/telegram-bot/bot.py @@ -28,6 +28,10 @@ TELEGRAM_MESSAGE_LIMIT = 4096 QUESTION_TEXT_LIMIT = 3200 OPTION_TEXT_LIMIT = 3200 TELEGRAM_MESSAGE_MARGIN = 200 +# Telegram is where you answer a question on the bus, not where you read the +# teaching. The explanation is abridged to roughly a screenful and the site +# link carries the rest; per-option explanations are not sent at all. +EXPLANATION_LIMIT = int(os.getenv("EXPLANATION_LIMIT", "700")) LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" NUMBER_RE = re.compile(r"^\s*(\d{1,3})\s*$") @@ -44,6 +48,10 @@ class QuizState: active_quizzes: dict[int, QuizState] = {} +# What each chat is revising for, by exam id. In memory and per chat, like the +# quiz itself: the bot has no accounts and writes nothing down. +chat_exam: dict[int, int] = {} + def db_query(sql: str, params: tuple[Any, ...] = ()) -> list[dict[str, Any]]: with psycopg.connect(DATABASE_URL, row_factory=dict_row) as conn: @@ -84,14 +92,48 @@ def answer_index(question: dict[str, Any], options: list[str]) -> int | None: return None -def question_base_sql(where: str = "") -> str: +#: Questions the bot may serve at all. `is_shared` is the only gate between an +#: anonymous stranger on Telegram and the bank, so it is applied in one place. +SERVEABLE = """ + q.question_type = 'mcq' + and q.options is not null + and q.is_shared = 1 +""" + +#: Every category at or below one node. The taxonomy is a tree now — discipline +#: above subdiscipline above condition — so "Cardiology" has to mean everything +#: under Cardiology, not only what was filed directly on it. +SUBTREE = """ + with recursive subtree(id) as ( + select id from question_categories where id = %s + union all + select c.id from question_categories c join subtree s on c.parent_id = s.id + ) +""" + +#: A question belongs to a category by its primary column or by a link row. +IN_SUBTREE = """ + (q.question_category_id in (select id from subtree) + or exists (select 1 from question_category_links l + where l.question_id = q.id and l.category_id in (select id from subtree))) +""" + + +def exam_clause(exam_id: int | None) -> tuple[str, tuple[Any, ...]]: + """Narrow to one study objective, or to nothing if none is chosen.""" + if not exam_id: + return "", () + return ("and exists (select 1 from question_exam_links qel " + "where qel.question_id = q.id and qel.exam_id = %s)", (exam_id,)) + + +def question_base_sql(where: str = "", prefix: str = "") -> str: return f""" + {prefix} 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 {SERVEABLE} {where} order by random() limit %s @@ -113,80 +155,104 @@ def valid_questions(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: return clean -def random_questions(limit: int) -> list[dict[str, Any]]: - rows = db_query(question_base_sql(), (limit * 2,)) +def random_questions(limit: int, exam_id: int | None = None) -> list[dict[str, Any]]: + clause, params = exam_clause(exam_id) + rows = db_query(question_base_sql(clause), (*params, limit * 2)) return valid_questions(rows)[:limit] -def search_questions(term: str, limit: int) -> list[dict[str, Any]]: +def search_questions(term: str, limit: int, exam_id: int | None = None) -> list[dict[str, Any]]: pattern = f"%{term}%" + clause, params = exam_clause(exam_id) rows = db_query( - question_base_sql(""" + question_base_sql(f""" + {clause} 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 + select 1 from question_categories c + where c.id = q.question_category_id and c.name ilike %s ) ) """), - (pattern, pattern, pattern, pattern, limit * 2), + (*params, 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]]: +def category_questions(category_id: int, limit: int, exam_id: int | None = None) -> list[dict[str, Any]]: + clause, params = exam_clause(exam_id) 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), + question_base_sql(f"and {IN_SUBTREE} {clause}", prefix=SUBTREE), + (category_id, *params, 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) +def list_exams() -> list[dict[str, Any]]: + """The objectives, with how many serveable questions each covers.""" 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), + select e.id, e.name, e.family, count(distinct q.id)::int as count + from exams e + join question_exam_links qel on qel.exam_id = e.id + join questions q on q.id = qel.question_id and {SERVEABLE} + where e.is_active = 1 + group by e.id, e.name, e.family, e.sort_order + having count(distinct q.id) > 0 + order by e.sort_order, e.name + """ ) +def category_children(parent_id: int | None, exam_id: int | None = None) -> list[dict[str, Any]]: + """The children of one node, each counted over its own whole subtree. + + Counting only what is filed directly on a node would show "Cardiology (0)" + for a discipline holding six hundred questions three levels down. + """ + clause, params = exam_clause(exam_id) + parent_test = "c.parent_id is null" if parent_id is None else "c.parent_id = %s" + parent_params: tuple[Any, ...] = () if parent_id is None else (parent_id,) + return db_query( + f""" + with recursive tree(root, id) as ( + select c.id, c.id from question_categories c where {parent_test} + union all + select t.root, c.id + from question_categories c join tree t on c.parent_id = t.id + ), + hits(root, question_id) as ( + select t.root, q.id + from tree t join questions q on q.question_category_id = t.id + where {SERVEABLE} {clause} + union + select t.root, q.id + from tree t + join question_category_links l on l.category_id = t.id + join questions q on q.id = l.question_id + where {SERVEABLE} {clause} + ) + select r.id, r.name, + count(distinct h.question_id)::int as count, + exists (select 1 from question_categories k where k.parent_id = r.id) as has_children + from question_categories r + left join hits h on h.root = r.id + where r.id in (select distinct root from tree) + group by r.id, r.name + having count(distinct h.question_id) > 0 + order by r.name + """, + (*parent_params, *params, *params), + ) + + +def category_name(category_id: int) -> str: + rows = db_query("select name from question_categories where id = %s", (category_id,)) + return rows[0]["name"] if rows else "Category" + + def clamp_count(value: int | None) -> int: if value is None: return DEFAULT_QUIZ_SIZE @@ -224,47 +290,29 @@ async def respond(update: Update, context: ContextTypes.DEFAULT_TYPE | None, tex 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: +def main_menu(chat_id: int | None = None) -> InlineKeyboardMarkup: + exam_id = chat_exam.get(chat_id) if chat_id else None + objective = "Change objective" if exam_id else "Choose an objective" 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")], + [InlineKeyboardButton("Browse topics", callback_data="browse:0")], + [InlineKeyboardButton(objective, callback_data="exams")], ]) -def count_menu(kind: str, item_id: int) -> InlineKeyboardMarkup: +def count_menu(kind: str, item_id: int, back_to: int | None = None) -> 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"), ]) + if back_to is not None: + buttons.append([InlineKeyboardButton("⬅ Back", callback_data=f"browse:{back_to}")]) 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: @@ -272,6 +320,32 @@ def truncate_text(value: str, limit: int) -> str: return value[:limit].rsplit(" ", 1)[0].rstrip() + "..." +def strip_markup(value: str) -> str: + """Explanations are markdown on the site; Telegram gets the words.""" + value = re.sub(r"!?\[\[?\d*\|?([^\]]*)\]\]?(\([^)]*\))?", r"\1", value) # links, cross-refs + value = re.sub(r"[*_`>#]+", "", value) + value = re.sub(r"\n{3,}", "\n\n", value) + return value.strip() + + +def abridge(explanation: str | None) -> str: + """About a screenful, cut at a sentence rather than mid-word.""" + text = strip_markup(explanation or "") + if not text: + return "No explanation available." + if len(text) <= EXPLANATION_LIMIT: + return text + window = text[:EXPLANATION_LIMIT] + stop = max(window.rfind(". "), window.rfind("! "), window.rfind("? ")) + if stop > EXPLANATION_LIMIT // 2: + return window[:stop + 1] + return window.rsplit(" ", 1)[0].rstrip() + "…" + + +def was_abridged(explanation: str | None) -> bool: + return len(strip_markup(explanation or "")) > EXPLANATION_LIMIT + + def question_message_text( state: QuizState, question: dict[str, Any], @@ -344,20 +418,14 @@ async def send_answer_feedback( 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\nExplanation: " - next_prefix = "Explanation continued: " - 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) + body = abridge(question.get("explanation")) + lines = [header, "", f"Why: {html.escape(body)}"] + if was_abridged(question.get("explanation")): + # Per-option reasoning and the figures stay on the site. Chat is where + # you answer; it is not where you read the teaching. + lines.append(f'\nFull explanation on PedsHub') + await context.bot.send_message(chat_id, "\n".join(lines), + parse_mode=ParseMode.HTML, disable_web_page_preview=True) def answered_question_text(state: QuizState, question: dict[str, Any], chosen_idx: int, correct_idx: int) -> str: @@ -365,27 +433,40 @@ def answered_question_text(state: QuizState, question: dict[str, Any], chosen_id async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + chat_id = update.effective_chat.id if update.effective_chat else 0 + exam_id = chat_exam.get(chat_id) + current = next((e["name"] for e in list_exams() if e["id"] == exam_id), None) if exam_id else None await send_text(update, "\n".join([ - "PedQuiz bot", + "PedsHub 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.", + "/random 20 exam — random questions in exam mode.", + "/topics — browse the subject tree and quiz any part of it.", + "/objective — pick the exam you are revising for.", + "/search sepsis 20 — quiz by text search.", + "/stop — end the current quiz.", "", - "Study mode shows each answer immediately. Exam mode shows answers at the end.", - ]), main_menu()) + f"Objective: {html.escape(current)}" if current else "Objective: everything.", + "", + "Study mode shows each answer immediately; exam mode shows them at the end.", + "Explanations here are abridged — the full teaching, the figures and the", + f"per-option reasoning are on the site: {PUBLIC_APP_URL}", + "", + "Nothing you do here is recorded. The bot keeps no account and writes", + "nothing to your history — a quiz lives only until you finish or /stop.", + ]), main_menu(chat_id)) 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)) + chat_id = update.effective_chat.id if update.effective_chat else 0 + await start_quiz(update, random_questions(parse_count(context.args), chat_exam.get(chat_id)), + 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 + chat_id = update.effective_chat.id if update.effective_chat else 0 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() @@ -394,79 +475,93 @@ async def search_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None 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}") + await start_quiz(update, search_questions(term, count, chat_exam.get(chat_id)), mode, label=f"Search: {term}") -async def categories_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - await show_categories(update, page=0) +async def topics_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + await show_browse(update, 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 objective_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + await show_exams(update) 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()) + await send_text(update, "Quiz stopped. Nothing was recorded.", + main_menu(update.effective_chat.id if update.effective_chat else 0)) -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.") +PER_PAGE = 10 + + +async def show_browse(update: Update, parent_id: int, page: int = 0) -> None: + """One level of the taxonomy. 0 means the top.""" + chat_id = update.effective_chat.id if update.effective_chat else 0 + exam_id = chat_exam.get(chat_id) + node = None if parent_id == 0 else parent_id + rows = category_children(node, exam_id) + + if not rows: + await respond(update, None, "Nothing here for the objective you have chosen.", main_menu(chat_id)) return - buttons = [ - [InlineKeyboardButton(f"{row['name']} ({row['count']})", callback_data=f"pick:category:{row['id']}")] - for row in chunk - ] + + chunk = rows[page * PER_PAGE:(page + 1) * PER_PAGE] + buttons = [] + # A node that goes deeper opens; a leaf goes straight to the size menu. + for row in chunk: + action = "browse" if row["has_children"] else "pick:category" + target = f"{action}:{row['id']}" if row["has_children"] else f"pick:category:{row['id']}" + arrow = " ›" if row["has_children"] else "" + buttons.append([InlineKeyboardButton(f"{row['name']} ({row['count']}){arrow}", + callback_data=target)]) + 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}")) + nav.append(InlineKeyboardButton("Prev", callback_data=f"browse:{parent_id}:{page - 1}")) + if (page + 1) * PER_PAGE < len(rows): + nav.append(InlineKeyboardButton("Next", callback_data=f"browse:{parent_id}:{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)) + + if node is None: + title = "Choose a topic." else: - await send_text(update, text, InlineKeyboardMarkup(buttons)) + # Everything under this node, not only what is filed directly on it. + title = f"{html.escape(category_name(node))} — choose a sub-topic, or quiz the whole of it." + buttons.append([InlineKeyboardButton("▶ All of this topic", callback_data=f"pick:category:{node}")]) + buttons.append([InlineKeyboardButton("⬅ Top", callback_data="browse:0")]) + + markup = InlineKeyboardMarkup(buttons) + if update.callback_query: + await update.callback_query.edit_message_text(title, parse_mode=ParseMode.HTML, reply_markup=markup) + else: + await send_text(update, title, markup) -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." +async def show_exams(update: Update) -> None: + """Which exam the questions should come from. Optional — none means all.""" + chat_id = update.effective_chat.id if update.effective_chat else 0 + rows = list_exams() + if not rows: + await respond(update, None, "No study objectives are set up yet.", main_menu(chat_id)) + return + current = chat_exam.get(chat_id) + buttons = [] + for row in rows: + mark = "✓ " if row["id"] == current else "" + family = f"{row['family']} — " if row.get("family") else "" + buttons.append([InlineKeyboardButton(f"{mark}{family}{row['name']} ({row['count']})", + callback_data=f"exam:{row['id']}")]) + buttons.append([InlineKeyboardButton(("✓ " if not current else "") + "Everything", + callback_data="exam:0")]) + text = "Which exam are you revising for? It scopes every quiz the bot gives you." + markup = InlineKeyboardMarkup(buttons) if update.callback_query: - await update.callback_query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(buttons)) + await update.callback_query.edit_message_text(text, reply_markup=markup) else: - await send_text(update, text, InlineKeyboardMarkup(buttons)) + await send_text(update, text, markup) async def start_quiz( @@ -479,7 +574,10 @@ async def start_quiz( 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()) + await respond(update, context, + "No questions there for the objective you have chosen. Try /topics, " + "a broader /search, or /objective to widen it.", + main_menu(update.effective_chat.id if update.effective_chat else 0)) return random.shuffle(questions) active_quizzes[update.effective_chat.id] = QuizState(questions=questions, mode=mode) @@ -524,7 +622,7 @@ async def finish_quiz(chat_id: int, context: ContextTypes.DEFAULT_TYPE) -> None: 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) + await context.bot.send_message(chat_id, "\n".join(lines), reply_markup=main_menu(chat_id), disable_web_page_preview=True) async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -535,36 +633,49 @@ async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) - 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])) + if data.startswith("browse:"): + parts = data.split(":") + parent_id = int(parts[1]) + page = int(parts[2]) if len(parts) > 2 else 0 + await show_browse(update, parent_id, page) return - if data.startswith("list:tags:"): - await show_tags(update, int(data.rsplit(":", 1)[1])) + if data == "exams": + await show_exams(update) + return + if data.startswith("exam:"): + exam_id = int(data.rsplit(":", 1)[1]) + if exam_id: + chat_exam[chat_id] = exam_id + name = next((e["name"] for e in list_exams() if e["id"] == exam_id), "that objective") + await query.edit_message_text(f"Revising for {html.escape(name)}.", + parse_mode=ParseMode.HTML, reply_markup=main_menu(chat_id)) + else: + chat_exam.pop(chat_id, None) + await query.edit_message_text("Questions will come from everything.", + reply_markup=main_menu(chat_id)) return if data.startswith("quiz:random:"): _, _, count, mode = data.split(":", 3) - await start_quiz(update, random_questions(clamp_count(int(count))), mode, context=context) + await start_quiz(update, random_questions(clamp_count(int(count)), chat_exam.get(chat_id)), + 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)) + await query.edit_message_text( + f"{html.escape(category_name(category_id))} — how many questions?", + parse_mode=ParseMode.HTML, + reply_markup=count_menu("category", category_id, back_to=category_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) + await start_quiz(update, + category_questions(int(category_id), clamp_count(int(count)), chat_exam.get(chat_id)), + mode, category_name(int(category_id)), 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.") + await query.edit_message_text("This quiz expired. Start a new one with /random or /topics.") return chosen_idx = int(data.split(":", 1)[1]) question = state.questions[state.index] @@ -596,15 +707,16 @@ async def text_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No 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()) + await send_text(update, "Send a number like 20, or use /topics, /objective, /search, /random.", + main_menu(update.effective_chat.id if update.effective_chat else 0)) 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("topics", "Browse the subject tree"), + BotCommand("objective", "Pick the exam you are revising for"), BotCommand("search", "Search text/topic: /search sepsis 20"), BotCommand("stop", "Stop the current quiz"), ]) @@ -615,8 +727,8 @@ def main() -> None: 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(["topics", "categories"], topics_cmd)) + app.add_handler(CommandHandler(["objective", "exam"], objective_cmd)) app.add_handler(CommandHandler("stop", stop_cmd)) app.add_handler(CallbackQueryHandler(callback_handler)) app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, text_handler))