Skip to content

About

Public service exam simulator for banking careers in Brazil

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

43 Commits

Folders and files

Repository files navigation

Brazilian public service exam simulator

The goal of this project is to provide a realistic practice environment for Brazilian public service exams, with configurable practice tests (simulados), time pressure per question, difficulty-based weighting, and a normalized final score in the 0–1000 range.

High-level architecture

The application is implemented as a single Python monolith with:

  • Streamlit as the web interface.
  • A FastAPI backend exposing JSON APIs for simulations, ranking, questions, and statistics.
  • An application services layer responsible for question selection, scoring, statistics, and ranking.
  • An ephemeral SQLite database running in the same container as the backend and frontend (suitable for Cloud Run).

The main flow is:

  1. The user provides a unique username.
  2. The system creates or retrieves the corresponding user.
  3. The user configures a simulation:
    • selects subjects (matérias),
    • optionally filters by exam board (banca) and year range,
    • chooses the number of questions (60, 90, or 120),
    • optionally defines a shared seed key so that others can take the exact same simulation.
  4. The backend builds a simulation with:
    • a specific set of questions selected from the question bank,
    • per-question time limit computed as 120 minutes / num_questions.
  5. As the user answers each question, the system computes question scores and stores answers.
  6. Upon finishing, the system:
    • computes the final normalized score (0–1000),
    • stores aggregated statistics for the simulation and for each question,
    • updates the ranking.
  7. Old simulations (finished or aborted) older than 8 days are cleaned up automatically to keep the ephemeral database small.

More detailed architecture notes and the original proposal are in docs/architecture.md.

Main components

Frontend (Streamlit)

The frontend is implemented with Streamlit and organized into pages:

  • frontend/app.py: main entrypoint; defines the sidebar menu in Portuguese:

    • Home: health check and usage instructions.
    • Simulado: unified page to configure, run and view the result of a simulation.
    • Ranking: leaderboard page.
    • Estatísticas: statistics page.
  • frontend/components/forms.py:

    • Reusable form components used by the simulation page, especially:
      • simulation_setup_form: renders the configuration form for a new simulado:
        • username,
        • optional shared seed (UUID v4),
        • subjects,
        • number of questions (60, 90, 120),
        • optional filters (banca, banco/instituição, year range),
        • optional custom distribution of questions per subject.
      • When no seed is provided, the form generates a seed_id (UUID v4) and displays:
        • “Chave deste simulado – Compartilhe este simulado com um amigo: <UUID>”.
      • When a seed is provided, the backend reuses the configuration and question set from the base simulation associated with that seed.
  • frontend/pages/simulation.py:

    • Unified simulation page with three logical steps in the same UI:
      1. Setup (Configurar simulado):
        • uses simulation_setup_form to send a SimulationCreate payload to the backend (POST /simulation);
        • loads the selected questions for the created simulation (GET /simulation/{id}/questions).
      2. Run (Responder simulado):
        • displays the current question index (e.g. Questão 1 de 60);
        • shows each question as [ BANCA, ANO - BANCO/INSTITUIÇÃO ] Enunciado da questão;
        • renders options A–D (and E if present);
        • displays a per-question countdown timer based on 120 minutos / número de questões;
        • allows navigation between questions, sending answers to POST /simulation/answer;
        • offers an “Abandonar simulado atual” action, which calls POST /simulation/{id}/abort and clears local state so the user can start another simulado.
      3. Result (Resultado do simulado):
        • on “Finalizar simulado”, calls POST /simulation/{id}/finish;
        • shows the final normalized score (0–1000),
        • shows accuracy, average response time, and decision efficiency,
        • explains the scoring model,
        • optionally shows a DataFrame with locally cached answers.
  • frontend/pages/ranking.py:

    • Leaderboard of users:
      • fetches ranking from /ranking,
      • shows best score, number of simulations, and average accuracy per user,
      • explains the ordering criteria: best score, number of simulations, then average accuracy.
  • frontend/pages/statistics.py (if present):

    • Statistics page for the question bank and simulations:
      • can show distribution of questions by subject, board, bank/institution, year and difficulty (weight),
      • allows filtering by subject, board and year,
      • can show per-user simulation statistics when the backend exposes them.

The frontend uses frontend/utils/api_client.py as a typed HTTP client to interact with the backend (start/finish/abort simulations, submit answers, list questions, fetch ranking and statistics).

Backend (FastAPI + services)

The backend is implemented with FastAPI and a services layer. Main modules:

  • backend/main.py:

    • Initializes the database schema (Base.metadata.create_all).
    • Seeds questions from backend/data/questions.csv on startup (if the questions table is empty).
    • Registers routers:
      • /questions (CRUD/read-only for questions),
      • /simulation (create simulations, submit answers, abort, finish, get questions),
      • /ranking (user ranking),
      • /statistics (question and user statistics).
    • Health endpoint /health.
  • backend/models:

    • User: users identified by username (unique).
    • Question: exam questions with subject, board, year, statement and options A–D (and optional E), difficulty_weight (1–4).
    • Simulation: simulation sessions with:
      • user_id,
      • num_questions,
      • seed_id (optional, used to share exact question sets),
      • status (in_progress, finished, aborted),
      • total_score, accuracy, avg_response_time, decision_efficiency,
      • created_at, finished_at.
    • SimulationQuestion: link table between simulations and questions with order_index to preserve the order in which questions are presented.
    • Answer: answers per question per simulation, storing:
      • answer,
      • is_correct,
      • question_weight (difficulty at the time),
      • time_spent_seconds,
      • remaining_time_seconds,
      • score per question,
      • created_at.
    • Statistics: per-simulation aggregated statistics, mirroring Simulation aggregates and ready for future extensions.
  • backend/schemas: Pydantic schemas for request/response:

    • SimulationCreate: input for starting a simulation, including:
      • username,
      • subjects,
      • num_questions (60–120),
      • optional seed_id,
      • optional board, year_from, year_to,
      • optional questions_per_subject.
    • SimulationOut and SimulationDetailOut: simulation output schemas.
    • SimulationQuestionOut: schema for questions associated to a simulation, including subject, board, year, statement and options.
    • QuestionOut, QuestionUpdate: question read/update schemas.
    • RankingEntry, RankingResponse: ranking response.
    • QuestionStatisticsOut, UserStatisticsOut: statistics responses.
  • backend/services/simulation.py:

    • create_simulation:

      • aborts stale simulations (in_progress with no activity for more than 5 minutes) by calling mark_stale_simulations_as_aborted;
      • validates questions_per_subject if provided;
      • if seed_id is present:
        • finds a base simulation (_find_simulation_by_seed) by Simulation.seed_id,
        • reuses exactly the same questions from the base simulation via SimulationQuestion,
        • overrides payload.num_questions to match the base;
      • otherwise (simulado “normal”):
        • builds an initial distribution per_subject_count (uniform or respecting questions_per_subject);
        • counts how many questions are available for each selected subject with the chosen filters (banca, banco/instituição, faixa de anos);
        • uses _redistribute_subject_counts_with_availability to:
          • cap each subject at its available quantity,
          • check if the total available (somando todas as matérias) atinge num_questions,
          • if yes, redistribute any deficit to other subjects with questions leftover, keeping the final total equal to num_questions;
          • if there are not sufficient questions in total, raise ValueError;
        • for each subject, computes difficulty quotas (40/30/20/10) and calls _select_random_questions, which:
          • In "strict" mode: try to respect the weight proportion and then fill in the rest with questions of any weight;
          • In non-strict mode: ignores weights and draws randomly;
        • _should_use_strict_weights only activate strict mode after at least 10 completed simulations and when there is a diversity of weights for that subject;
      • validates that the number of selected questions is equal to payload.num_questions;
      • creates a Simulation with seed_id (if any) and status="in_progress";
      • persists entries in SimulationQuestion for the chosen questions.
    • register_answer:

      • validates the simulation and question,
      • computes per-question time budget as 120 * 60 / num_questions,
      • calculates time_spent and remaining_time,
      • determines correctness, uses Question.difficulty_weight as weight,
      • computes question score as remaining_time * weight * 0.5 if correct, 0 otherwise,
      • records Answer with all these details.
    • abort_simulation:

      • explicitly aborts a simulation:
        • sets status="aborted" and finished_at,
        • does not recalculate aggregates or update statistics,
      • is idempotent: if the simulation is already finished or aborted, returns its current view.
    • finish_simulation:

      • sets status="finished" and finished_at,
      • calls recalculate_simulation_aggregates to compute final score and aggregates,
      • upserts Statistics for that simulation,
      • calls update_question_statistics_for_simulation to update difficulty weights of questions used in this simulation,
      • calls _cleanup_old_simulations to delete finished/aborted simulations older than 8 days.
    • get_simulation_questions:

      • returns questions for a simulation, including board and year, in the order they were selected.
    • mark_stale_simulations_as_aborted:

      • marks simulations as aborted if they have been idle (no answers and still in_progress) for more than 5 minutes.
  • backend/services/scoring.py:

    • calculate_question_score: computes per-question score using remaining_time_seconds * weight * 0.5 when correct, 0 otherwise.
    • recalculate_simulation_aggregates: computes:
      • raw_score (sum of Answer.score),
      • accuracy (correct answers / total answers),
      • avg_time (average time_spent_seconds),
      • total_time (sum of time_spent_seconds),
      • normalizes these into:
        • S_raw = min(raw_score / 14400.0, 1.0),
        • S_acc = accuracy,
        • S_time = 1 / (1 + avg_time / 30),
      • uses weights w_raw = 0.4, w_acc = 0.4, w_time = 0.2 to compute:
        • composite = w_raw * S_raw + w_acc * S_acc + w_time * S_time,
        • final_score = clamp(composite, 0, 1) * 1000,
      • stores:
        • simulation.total_score = final_score,
        • simulation.accuracy, simulation.avg_response_time,
        • simulation.decision_efficiency = composite.
  • backend/services/statistics.py:

    • _difficulty_weight_from_accuracy: maps accuracy to difficulty weights 1–4.
    • update_question_statistics_for_simulation: increments difficulty weights for questions used in a specific simulation, based on all historical answers.
    • get_question_statistics: aggregates attempts, correct answers, accuracy and difficulty_weight per question.
    • get_user_statistics: aggregates per-user statistics (total simulations, best score, average accuracy and efficiency).
  • backend/services/scoring.get_user_ranking:

    • computes ranking per user based on:
      • highest total_score (best score),
      • number of finished simulations,
      • average accuracy.

Data model

The current schema (reflected in SQLAlchemy models) covers:

  • users: usernames and creation time.
  • questions: exam questions with subject, board, year, statement, options A–D/E and difficulty weight (1–4).
  • simulations: simulation sessions with user, number of questions, seed, status, score and timestamps.
  • simulation_questions: which question IDs belong to which simulation and their order.
  • answers: user answers per simulation and question, including correctness, timing and per-question score.
  • statistics: simulation-level aggregates (accuracy, average response time, efficiency).

The question bank is seeded from backend/data/questions.csv on startup, assuming that CSV has the columns:

subject, board, year, statement, option_a, option_b, option_c, option_d, option_e, correct_answer.

Final score model (0–1000)

The scoring model separates the following metrics:

  • raw_score: sum of question scores (each question’s score is remaining_time_seconds * weight * 0.5 if correct, 0 otherwise).
  • accuracy: proportion of correct answers, correct / total, in [0, 1].
  • avg_time: average time spent per question (in seconds).
  • efficiency: composite indicator in [0, 1] combining normalized raw score, accuracy and time.

The final score final_score is computed as:

final_score = 1000 * (w_raw * S_raw + w_acc * S_acc + w_time * S_time)

with:

  • w_raw + w_acc + w_time = 1, currently w_raw = 0.4, w_acc = 0.4, w_time = 0.2.

  • S_raw is the normalized raw score:

    S_raw = min(raw_score / 14400.0, 1.0), using an approximate theoretical ceiling of 14400 based on maximum remaining time and maximum difficulty.

  • S_acc is simply accuracy.

  • S_time = 1 / (1 + avg_time / T_ref), with T_ref = 30 seconds:

    • if avg_time = 0, then S_time = 1;
    • if avg_time = 30, then S_time = 0.5;
    • if avg_time = 60, then S_time ≈ 0.33.

This ensures that the final grade stays in [0, 1000] and increases when the user has higher raw score, higher accuracy and lower average response time, and penalizes practice tests with high raw scores but low accuracy or excessively high response time.

Cloud Run and SQLite

The system uses SQLite as an ephemeral database inside the Cloud Run container. In this setup:

  • the database file is stored under /tmp/banking_approved.db (or similar) inside the container;
  • the file is local to the instance and can be lost when the instance is restarted or replaced;
  • this is acceptable for ephemeral usage, demos and short-lived rankings.

When durable ranking/history is required across restarts or multiple Cloud Run instances, the next natural step is to move persistence to a durable database (such as Cloud SQL or another managed database), while keeping the same application-layer architecture.

About

Public service exam simulator for banking careers in Brazil

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages