Building my AI Powered UPSC Mock Exams
This post walks through every block of code used in my UPSC Mock Test project, file by file. For each file I show the code and explain what it does in plain language. All code blocks are fenced and labeled as Python.

Project Structure
upsc_mock_test-main/app.py
File: upsc_mock_test-main/app.py
Top-level statements
# app.py — Streamlit UPSC Mock Test (UI-only, OpenAI via st.secrets)
These top-level statements define constants, configuration, and UI schema that the rest of the file uses. Keeping them near the top makes the file self-explanatory.
Imports
import time
import json
import hashlib
import random
import datetime as dt
from dataclasses import dataclass
from typing import List, Dict, Optional
import streamlit as st
# Optional OpenAI client (used only if provider == "OpenAI")
These imports bring in libraries I need for the UI, state management, data handling, and generation logic. Grouping them at the top keeps dependencies explicit.
Top-level statements
try:
These top-level statements define constants, configuration, and UI schema that the rest of the file uses. Keeping them near the top makes the file self-explanatory.
Imports
from openai import OpenAI
These imports bring in libraries I need for the UI, state management, data handling, and generation logic. Grouping them at the top keeps dependencies explicit.
Top-level statements
except Exception:
OpenAI = None
# ======================= App Config =======================
st.set_page_config(page_title="UPSC Mock Test", layout="wide")
# ======================= Rate Limiting =======================
# === Runtime budget/limits loader (auto-updates from GitHub) ===
These top-level statements define constants, configuration, and UI schema that the rest of the file uses. Keeping them near the top makes the file self-explanatory.
Imports
import os, time, types, urllib.request
import streamlit as st
# Raw URL of your budget.py in the shared repo (override via env if needed)
These imports bring in libraries I need for the UI, state management, data handling, and generation logic. Grouping them at the top keeps dependencies explicit.
Top-level statements
BUDGET_URL = os.getenv(
"BUDGET_URL",
"https://raw.githubusercontent.com/RahulBhattacharya1/shared_config/main/budget.py",
)
# Safe defaults if the fetch fails
_BUDGET_DEFAULTS = {
"COOLDOWN_SECONDS": 30,
"DAILY_LIMIT": 40,
"HOURLY_SHARED_CAP": 250,
"DAILY_BUDGET": 1.00,
"EST_COST_PER_GEN": 1.00,
"VERSION": "fallback-local",
}
These top-level statements define constants, configuration, and UI schema that the rest of the file uses. Keeping them near the top makes the file self-explanatory.
Function: _fetch_remote_budget
def _fetch_remote_budget(url: str) -> dict:
mod = types.ModuleType("budget_remote")
with urllib.request.urlopen(url, timeout=5) as r:
code = r.read().decode("utf-8")
exec(compile(code, "budget_remote", "exec"), mod.__dict__)
cfg = {}
for k in _BUDGET_DEFAULTS.keys():
cfg[k] = getattr(mod, k, _BUDGET_DEFAULTS[k])
return cfg
The _fetch_remote_budget
function encapsulates one clear task in the workflow: it takes inputs, applies the logic, and returns results. Keeping functions focused improves readability and testing.
Function: get_budget
def get_budget(ttl_seconds: int = 300) -> dict:
"""Fetch and cache remote budget in session state with a TTL."""
now = time.time()
cache = st.session_state.get("_budget_cache")
ts = st.session_state.get("_budget_cache_ts", 0)
if cache and (now - ts) < ttl_seconds:
return cache
try:
cfg = _fetch_remote_budget(BUDGET_URL)
except Exception:
cfg = _BUDGET_DEFAULTS.copy()
# Allow env overrides if you want per-deploy tuning
cfg["DAILY_BUDGET"] = float(os.getenv("DAILY_BUDGET", cfg["DAILY_BUDGET"]))
cfg["EST_COST_PER_GEN"] = float(os.getenv("EST_COST_PER_GEN", cfg["EST_COST_PER_GEN"]))
st.session_state["_budget_cache"] = cfg
st.session_state["_budget_cache_ts"] = now
return cfg
# Load once (respects TTL); you can expose a "Refresh config" button to clear cache
_cfg = get_budget(ttl_seconds=300)
COOLDOWN_SECONDS = int(_cfg["COOLDOWN_SECONDS"])
DAILY_LIMIT = int(_cfg["DAILY_LIMIT"])
HOURLY_SHARED_CAP = int(_cfg["HOURLY_SHARED_CAP"])
DAILY_BUDGET = float(_cfg["DAILY_BUDGET"])
EST_COST_PER_GEN = float(_cfg["EST_COST_PER_GEN"])
CONFIG_VERSION = str(_cfg.get("VERSION", "unknown"))
# === End runtime loader ===
The get_budget
function encapsulates one clear task in the workflow: it takes inputs, applies the logic, and returns results. Keeping functions focused improves readability and testing.
Function: _hour_bucket
def _hour_bucket(now=None):
now = now or dt.datetime.utcnow()
return now.strftime("%Y-%m-%d-%H")
@st.cache_resource
The _hour_bucket
function encapsulates one clear task in the workflow: it takes inputs, applies the logic, and returns results. Keeping functions focused improves readability and testing.
Function: _shared_hourly_counters
def _shared_hourly_counters():
return {}
The _shared_hourly_counters
function encapsulates one clear task in the workflow: it takes inputs, applies the logic, and returns results. Keeping functions focused improves readability and testing.
Function: init_rate_limit_state
def init_rate_limit_state():
ss = st.session_state
today = dt.date.today().isoformat()
if "rl_date" not in ss or ss["rl_date"] != today:
ss["rl_date"] = today
ss["rl_calls_today"] = 0
ss["rl_last_ts"] = 0.0
if "rl_last_ts" not in ss:
ss["rl_last_ts"] = 0.0
if "rl_calls_today" not in ss:
ss["rl_calls_today"] = 0
The init_rate_limit_state
function encapsulates one clear task in the workflow: it takes inputs, applies the logic, and returns results. Keeping functions focused improves readability and testing.
Function: can_call_now
def can_call_now():
init_rate_limit_state()
ss = st.session_state
now = time.time()
# Cooldown
remaining = int(max(0, ss["rl_last_ts"] + COOLDOWN_SECONDS - now))
if remaining > 0:
return (False, f"Please wait {remaining}s before the next generation.", remaining)
# Daily budget check (primary guardrail)
est_spend = ss["rl_calls_today"] * EST_COST_PER_GEN
if est_spend >= DAILY_BUDGET:
return (False, f"Daily cost limit reached (${DAILY_BUDGET:.2f}). Try again tomorrow.", 0)
# Optional: also keep your per-session daily cap (can leave as-is or lower)
if ss["rl_calls_today"] >= DAILY_LIMIT:
return (False, f"Daily limit reached ({DAILY_LIMIT} generations). Try again tomorrow.", 0)
# Optional shared hourly cap
if HOURLY_SHARED_CAP > 0:
bucket = _hour_bucket()
counters = _shared_hourly_counters()
used = counters.get(bucket, 0)
if used >= HOURLY_SHARED_CAP:
return (False, "Hourly capacity reached. Please try later.", 0)
return (True, "", 0)
The can_call_now
function encapsulates one clear task in the workflow: it takes inputs, applies the logic, and returns results. Keeping functions focused improves readability and testing.
Function: record_successful_call
def record_successful_call():
ss = st.session_state
ss["rl_last_ts"] = time.time()
ss["rl_calls_today"] += 1
if HOURLY_SHARED_CAP > 0:
bucket = _hour_bucket()
counters = _shared_hourly_counters()
counters[bucket] = counters.get(bucket, 0) + 1
# ======================= Data Models =======================
@dataclass
The record_successful_call
function encapsulates one clear task in the workflow: it takes inputs, applies the logic, and returns results. Keeping functions focused improves readability and testing.
Class: MCQ
class MCQ:
id: str
question: str
options: List[str]
correct_index: int
explanation: str
@dataclass
I use the MCQ
class to organize related behavior and state. Encapsulation here makes the code easier to extend and reuse.
Class: Essay
class Essay:
id: str
prompt: str
rubric_points: List[str]
# ======================= UI Helpers =======================
I use the Essay
class to organize related behavior and state. Encapsulation here makes the code easier to extend and reuse.
Function: brand_h2
def brand_h2(text: str, color: str):
st.markdown(f"<h2 style='margin:.25rem 0 .75rem 0; color:{color}'>{text}</h2>", unsafe_allow_html=True)
The brand_h2
function encapsulates one clear task in the workflow: it takes inputs, applies the logic, and returns results. Keeping functions focused improves readability and testing.
Function: md_card
def md_card(title_text: str, body_html: str = ""):
extra = f'<div style="margin-top:.35rem">{body_html}</div>' if body_html else ""
st.markdown(
f"""
<div style="border:1px solid #e5e7eb; padding:.75rem 1rem; border-radius:10px; margin-bottom:.75rem;">
<div style="font-weight:600">{title_text}</div>
{extra}
</div>
""",
unsafe_allow_html=True
)
# ======================= Topic Catalogs =======================
GS_PRELIMS = [
"Polity & Governance", "Economy", "Geography", "History & Culture",
"Environment & Ecology", "Science & Tech", "Current Affairs"
]
CSAT_SECTIONS = ["Comprehension", "Reasoning", "Data Interpretation", "Basic Numeracy"]
MAINS_GS = [
"GS1: History & Culture, Society, Geography",
"GS2: Polity, Governance, IR",
"GS3: Economy, Agriculture, S&T, Environment, Security",
"GS4: Ethics, Integrity, Aptitude",
"Essay"
]
OPTIONALS = [
"Public Administration", "Sociology", "Anthropology", "Geography (Optional)",
"History (Optional)", "Political Science & IR", "Economics (Optional)",
"Psychology", "Philosophy", "Mathematics", "Management"
]
# ======================= Offline Generators =======================
The md_card
function encapsulates one clear task in the workflow: it takes inputs, applies the logic, and returns results. Keeping functions focused improves readability and testing.
Function: offline_mcq_bank
def offline_mcq_bank(topic: str, difficulty: str, language: str, seed: int, n: int) -> List[MCQ]:
rng = random.Random(seed + len(topic) + len(difficulty) + len(language))
mcqs: List[MCQ] = []
stems = [
"Which of the following statements is/are correct regarding {X}?",
"Consider the following statements about {X}. Which of the statements given above is/are correct?",
"With reference to {X}, consider the following: choose the correct option."
]
facts = [
"{X} is constitutionally backed.",
"{X} affects federal-state relations.",
"{X} influences inclusive growth.",
"{X} has implications for climate resilience.",
"{X} is linked to demographic trends.",
"{X} is notified under a recent policy."
]
exps = [
"Statement 1 is correct because of its legal basis; Statement 2 is incorrect due to scope limits.",
"The provision applies conditionally; hence only one statement holds.",
"Recent committee reports clarify the scope, aligning with option chosen."
]
for i in range(n):
stem = rng.choice(stems).format(X=topic)
s1 = rng.choice(facts).format(X=topic)
s2 = rng.choice(facts).format(X=topic)
options = ["1 only", "2 only", "Both 1 and 2", "Neither 1 nor 2"]
rng.shuffle(options)
truth = rng.choice(options)
correct_index = options.index(truth)
qtxt = f"{stem}\n\n{s1}\n{s2}\n"
exp = rng.choice(exps)
mcqs.append(MCQ(
id=f"{topic}-{i}-{rng.randint(1000,9999)}",
question=qtxt,
options=options,
correct_index=correct_index,
explanation=exp
))
return mcqs
The offline_mcq_bank
function encapsulates one clear task in the workflow: it takes inputs, applies the logic, and returns results. Keeping functions focused improves readability and testing.
Function: offline_essays
def offline_essays(topic: str, difficulty: str, language: str, seed: int, n: int) -> List[Essay]:
rng = random.Random(seed + len(topic) + len(difficulty) + len(language) + 99)
prompts = [
"Critically examine the role of {X} in advancing inclusive development.",
"Discuss how {X} reshapes federal dynamics and governance outcomes.",
"Evaluate the challenges and opportunities of {X} for sustainable growth.",
"Analyze ethical considerations surrounding {X} in public administration.",
"How does {X} interact with technological change and social equity?"
]
rubrics = [
["Concept clarity", "Use of examples/data", "Critical analysis", "Structure & coherence", "Conclusion"],
["Understanding of syllabus demand", "Interlinkages across topics", "Counter-arguments", "Recommendations", "Language"]
]
essays: List[Essay] = []
for i in range(n):
prompt = rng.choice(prompts).format(X=topic)
rubric = rng.choice(rubrics)
essays.append(Essay(
id=f"essay-{topic}-{i}-{rng.randint(1000,9999)}",
prompt=prompt,
rubric_points=rubric
))
return essays
# ======================= OpenAI Generators (JSON-only) =======================
The offline_essays
function encapsulates one clear task in the workflow: it takes inputs, applies the logic, and returns results. Keeping functions focused improves readability and testing.
Function: call_openai_mcq
def call_openai_mcq(model: str, topic: str, difficulty: str, language: str, n: int, temperature: float, max_tokens: int) -> List[MCQ]:
api_key = st.secrets.get("OPENAI_API_KEY", "")
if not api_key:
raise RuntimeError("OPENAI_API_KEY missing in Streamlit Secrets.")
if OpenAI is None:
raise RuntimeError("openai package not available. Add openai to requirements.txt.")
client = OpenAI(api_key=api_key)
sys = (
"You are a UPSC Prelims question setter. Output strict JSON only with key 'mcqs' as a list of objects. "
"Each object must have: id (string), question (string, can include two statements labeled 1 and 2), "
"options (array of 4 strings), correct_index (0-3), explanation (string). No extra keys or prose."
)
usr = json.dumps({
"topic": topic,
"difficulty": difficulty,
"language": language,
"count": n,
"style": "two-statement style preferred; UPSC tone; balanced difficulty; avoid niche facts."
})
resp = client.chat.completions.create(
model=model,
temperature=float(temperature),
max_tokens=int(max_tokens),
messages=[{"role": "system", "content": sys}, {"role": "user", "content": usr}]
)
text = resp.choices[0].message.content.strip()
if text.startswith("```"):
text = text.strip("`")
if "\n" in text:
text = text.split("\n", 1)[1].strip()
data = json.loads(text)
out: List[MCQ] = []
for q in data.get("mcqs", []):
out.append(MCQ(
id=str(q.get("id", "")),
question=str(q.get("question", "")),
options=[str(x) for x in q.get("options", [])][:4],
correct_index=int(q.get("correct_index", 0)),
explanation=str(q.get("explanation", "")),
))
return out
The call_openai_mcq
function encapsulates one clear task in the workflow: it takes inputs, applies the logic, and returns results. Keeping functions focused improves readability and testing.
Function: call_openai_essay
def call_openai_essay(model: str, topic: str, difficulty: str, language: str, n: int, temperature: float, max_tokens: int) -> List[Essay]:
api_key = st.secrets.get("OPENAI_API_KEY", "")
if not api_key:
raise RuntimeError("OPENAI_API_KEY missing in Streamlit Secrets.")
if OpenAI is None:
raise RuntimeError("openai package not available. Add openai to requirements.txt.")
client = OpenAI(api_key=api_key)
sys = (
"You are a UPSC Mains question setter. Output strict JSON only with key 'essays' as a list of objects. "
"Each object must have: id (string), prompt (string), rubric_points (array of short strings). No extra keys or prose."
)
usr = json.dumps({
"topic": topic,
"difficulty": difficulty,
"language": language,
"count": n,
"style": "UPSC Mains tone; analytical; allow multidimensional treatment; avoid niche trivia."
})
resp = client.chat.completions.create(
model=model,
temperature=float(temperature),
max_tokens=int(max_tokens),
messages=[{"role": "system", "content": sys}, {"role": "user", "content": usr}]
)
text = resp.choices[0].message.content.strip()
if text.startswith("```"):
text = text.strip("`")
if "\n" in text:
text = text.split("\n", 1)[1].strip()
data = json.loads(text)
out: List[Essay] = []
for e in data.get("essays", []):
out.append(Essay(
id=str(e.get("id", "")),
prompt=str(e.get("prompt", "")),
rubric_points=[str(x) for x in e.get("rubric_points", [])]
))
return out
# ======================= Sidebar (Common Controls) =======================
st.title("UPSC Mock Test")
with st.sidebar:
st.subheader("Generator")
provider = st.selectbox("Provider", ["OpenAI", "Offline (rule-based)"])
model = st.selectbox("Model (OpenAI)", ["gpt-4o-mini", "gpt-4o", "gpt-4.1-mini"])
brand = "#0F62FE"
temp = st.slider("Creativity (OpenAI)", 0.0, 1.0, 0.4, 0.05)
max_tokens = st.slider("Max tokens (OpenAI)", 512, 4096, 1500, 32)
init_rate_limit_state()
ss = st.session_state
st.markdown("**Usage limits**")
st.markdown(f"<span style='font-size:0.9rem'>Today: {ss['rl_calls_today']} / {DAILY_LIMIT} generations</span>", unsafe_allow_html=True)
if HOURLY_SHARED_CAP > 0:
counters = _shared_hourly_counters()
used = counters.get(_hour_bucket(), 0)
st.markdown(f"<span style='font-size:0.9rem'>Hour capacity: {used} / {HOURLY_SHARED_CAP}</span>", unsafe_allow_html=True)
est_spend = ss['rl_calls_today'] * EST_COST_PER_GEN
st.markdown(
f"<span style='font-size:0.9rem'>Budget: ${est_spend:.2f} / ${DAILY_BUDGET:.2f}</span>",
unsafe_allow_html=True
)
st.markdown(
f"<span style='font-size:0.8rem; opacity:0.8'>Version: {CONFIG_VERSION}</span>",
unsafe_allow_html=True
)
# Optional: show a warning if we’re on fallback defaults (remote fetch failed)
if CONFIG_VERSION == "fallback-local":
st.warning("Using fallback defaults — couldn’t fetch remote budget.py")
remaining = int(max(0, ss["rl_last_ts"] + COOLDOWN_SECONDS - time.time()))
if remaining > 0:
st.progress(min(1.0, (COOLDOWN_SECONDS - remaining) / COOLDOWN_SECONDS))
st.caption(f"Cooldown: {remaining}s")
# ======================= Test Configuration =======================
colA, colB = st.columns([1.3, 1])
with colA:
exam_type = st.selectbox("Exam Type", ["Prelims (MCQ)", "Mains (Descriptive)"])
language = st.selectbox("Language", ["English", "Hindi"])
difficulty = st.selectbox("Difficulty", ["Any", "Easy", "Moderate", "Hard"])
time_limit = st.number_input("Time limit (minutes)", min_value=0, max_value=300, value=30, step=5)
with colB:
if exam_type.startswith("Prelims"):
prelims_topics = st.multiselect("Prelims Topics (GS, CSAT, Current Affairs)", GS_PRELIMS + CSAT_SECTIONS, default=["Polity & Governance", "Economy"])
num_questions = st.slider("Number of questions", 5, 100, 20, 5)
negative_mark = st.select_slider("Negative marking", options=[0.0, -0.25, -0.33, -0.5], value=-0.33)
shuffle_q = st.checkbox("Shuffle questions", value=True)
# Give the checkbox a key so we can read it later
show_explanations_after = st.checkbox("Show explanations after submit", value=True, key="show_explanations_after")
else:
mains_topics = st.multiselect("Mains Topics (GS, Essay)", MAINS_GS, default=["GS2: Polity, Governance, IR"])
optional_subject = st.selectbox("Optional Subject (optional)", ["None"] + OPTIONLS if False else ["None"] + OPTIONALS, index=0)
essay_count = st.slider("Number of questions/prompts", 1, 10, 4, 1)
col1, col2, col3, col4 = st.columns([1, 1, 1, 1])
allowed, reason, _wait = can_call_now()
with col1:
gen = st.button("Generate Test", type="primary", disabled=not allowed)
with col2:
regen = st.button("Regenerate")
with col3:
start_timer = st.button("Start Timer")
with col4:
submit = st.button("Submit")
reset = st.button("Reset All")
# ======================= Session State =======================
if "seed" not in st.session_state:
st.session_state.seed = 101
if "test_started_at" not in st.session_state:
st.session_state.test_started_at = None
if "prelims_qs" not in st.session_state:
st.session_state.prelims_qs: List[MCQ] = []
if "mains_qs" not in st.session_state:
st.session_state.mains_qs: List[Essay] = []
if "answers" not in st.session_state:
st.session_state.answers: Dict[str, int] = {}
if "essay_answers" not in st.session_state:
st.session_state.essay_answers: Dict[str, str] = {}
if reset:
for k in ["prelims_qs", "mains_qs", "answers", "essay_answers", "test_started_at"]:
st.session_state.pop(k, None)
# ======================= Generation Orchestrators =======================
The call_openai_essay
function encapsulates one clear task in the workflow: it takes inputs, applies the logic, and returns results. Keeping functions focused improves readability and testing.
Function: generate_prelims
def generate_prelims(topics: List[str], n: int):
blocks: List[MCQ] = []
if not topics:
return blocks
per_topic = max(1, n // len(topics))
remainder = n - per_topic * len(topics)
for i, t in enumerate(topics):
want = per_topic + (1 if i < remainder else 0)
if provider == "Offline (rule-based)":
blocks.extend(offline_mcq_bank(t, difficulty, language, st.session_state.seed + i * 13, want))
else:
try:
blocks.extend(call_openai_mcq(model, t, difficulty, language, want, temp, max_tokens))
except Exception as e:
st.error(f"OpenAI MCQ error for {t}: {e}. Falling back offline for this topic.")
blocks.extend(offline_mcq_bank(t, difficulty, language, st.session_state.seed + i * 13, want))
if shuffle_q:
random.Random(st.session_state.seed).shuffle(blocks)
return blocks[:n]
The generate_prelims
function encapsulates one clear task in the workflow: it takes inputs, applies the logic, and returns results. Keeping functions focused improves readability and testing.
Function: generate_mains
def generate_mains(topics: List[str], opt_subject: Optional[str], n: int):
prompts: List[Essay] = []
pools = topics.copy()
if opt_subject and opt_subject != "None":
pools.append(f"Optional: {opt_subject}")
for i, t in enumerate(pools):
if provider == "Offline (rule-based)":
prompts.extend(offline_essays(t, difficulty, language, st.session_state.seed + i * 29, 1))
else:
try:
prompts.extend(call_openai_essay(model, t, difficulty, language, 1, temp, max_tokens))
except Exception as e:
st.error(f"OpenAI Essay error for {t}: {e}. Falling back offline for this topic.")
prompts.extend(offline_essays(t, difficulty, language, st.session_state.seed + i * 29, 1))
if len(prompts) >= n:
break
return prompts[:n]
# ======================= Actions =======================
if (gen or regen):
if not allowed:
st.warning(reason)
else:
if exam_type.startswith("Prelims"):
st.session_state.prelims_qs = generate_prelims(prelims_topics, num_questions)
else:
st.session_state.mains_qs = generate_mains(mains_topics, optional_subject, essay_count)
st.session_state.answers = {}
st.session_state.essay_answers = {}
record_successful_call()
if regen:
st.session_state.seed += 7
if start_timer and time_limit > 0:
st.session_state.test_started_at = time.time()
# ======================= Timer =======================
The generate_mains
function encapsulates one clear task in the workflow: it takes inputs, applies the logic, and returns results. Keeping functions focused improves readability and testing.
Function: render_timer
def render_timer():
if st.session_state.test_started_at and time_limit > 0:
elapsed = int(time.time() - st.session_state.test_started_at)
remaining = max(0, time_limit * 60 - elapsed)
mins = remaining // 60
secs = remaining % 60
st.info(f"Time remaining: {mins:02d}:{secs:02d}")
if remaining == 0:
st.warning("Time is up. You can still submit to view results.")
else:
st.caption("Timer not started.")
# ======================= Render: Prelims =======================
The render_timer
function encapsulates one clear task in the workflow: it takes inputs, applies the logic, and returns results. Keeping functions focused improves readability and testing.
Function: render_prelims
def render_prelims():
brand_h2("Prelims — MCQ", brand)
render_timer()
if not st.session_state.prelims_qs:
st.info("Click Generate Test to create questions.")
return
for i, q in enumerate(st.session_state.prelims_qs, start=1):
st.markdown(f"**Q{i}.** {q.question}")
safe_id = (getattr(q, "id", "") or "").strip() or f"q{i}"
qid_hash = hashlib.md5((q.question or "").encode("utf-8")).hexdigest()[:6]
widget_key = f"ans_{i}_{safe_id}_{qid_hash}" # unique across runs/shuffles
current = st.session_state.answers.get(q.id, None)
choice = st.radio(
"Select an option:",
options=list(range(len(q.options))),
format_func=lambda idx: f"{chr(65+idx)}. {q.options[idx]}",
index=current if current is not None else 0,
key=widget_key
)
st.session_state.answers[q.id] = choice
st.markdown("---")
if submit:
total = len(st.session_state.prelims_qs)
correct = 0
wrong = 0
unattempted = 0
for q in st.session_state.prelims_qs:
sel = st.session_state.answers.get(q.id, None)
if sel is None:
unattempted += 1
elif sel == q.correct_index:
correct += 1
else:
wrong += 1
score = correct * 1.0 + wrong * negative_mark
summary_html = (
f"- Questions: <b>{total}</b><br>"
f"- Correct: <b>{correct}</b><br>"
f"- Wrong: <b>{wrong}</b><br>"
f"- Unattempted: <b>{unattempted}</b><br>"
f"- Negative marking: <b>{negative_mark} per wrong</b><br>"
f"- <b>Score: {score:.2f} / {total:.2f}</b>"
)
md_card("Result Summary", body_html=summary_html)
show_flag = st.session_state.get("show_explanations_after", True)
if show_flag:
brand_h2("Review & Explanations", brand)
for i, q in enumerate(st.session_state.prelims_qs, start=1):
sel = st.session_state.answers.get(q.id, None)
correct_tag = chr(65 + q.correct_index)
your_tag = "-" if sel is None else chr(65 + sel)
body_html = (
f"Correct: <b>{correct_tag}</b> | Your answer: <b>{your_tag}</b><br><br>"
f"<b>Explanation:</b> {q.explanation}"
)
md_card(f"Q{i}.", body_html=body_html)
# ======================= Render: Mains =======================
The render_prelims
function encapsulates one clear task in the workflow: it takes inputs, applies the logic, and returns results. Keeping functions focused improves readability and testing.
Function: render_mains
def render_mains():
brand_h2("Mains — Descriptive", brand)
render_timer()
if not st.session_state.mains_qs:
st.info("Click Generate Test to create prompts.")
return
for i, e in enumerate(st.session_state.mains_qs, start=1):
st.markdown(f"**Q{i}.** {e.prompt}")
st.caption("Rubric points: " + " · ".join(e.rubric_points))
safe_eid = (getattr(e, "id", "") or "").strip() or f"e{i}"
ep_hash = hashlib.md5((e.prompt or "").encode("utf-8")).hexdigest()[:6]
key = f"essay_{i}_{safe_eid}_{ep_hash}"
val = st.session_state.essay_answers.get(e.id, "")
ans = st.text_area("Your answer:", value=val, height=200, key=key)
st.session_state.essay_answers[e.id] = ans
st.markdown("---")
if submit:
attempted = sum(1 for v in st.session_state.essay_answers.values() if v.strip())
total = len(st.session_state.mains_qs)
md_card(
"Submission Saved",
body_html=(
f"- Prompts: <b>{total}</b><br>"
f"- Attempted: <b>{attempted}</b><br><br>"
"Note: Mains answers are not auto-graded in this UI. Use rubric points for self-evaluation."
)
)
with st.expander("Copy this test as Markdown"):
md_lines = []
for i, e in enumerate(st.session_state.mains_qs, start=1):
md_lines.append(f"**Q{i}.** {e.prompt}")
md_lines.append("Rubric: " + ", ".join(e.rubric_points))
md_lines.append("")
st.code("\n".join(md_lines), language="markdown")
# ======================= Main Render =======================
if exam_type.startswith("Prelims"):
render_prelims()
else:
render_mains()
The render_mains
function encapsulates one clear task in the workflow: it takes inputs, applies the logic, and returns results. Keeping functions focused improves readability and testing.