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.
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:
- The user provides a unique username.
- The system creates or retrieves the corresponding user.
- 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.
- 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.
- As the user answers each question, the system computes question scores and stores answers.
- Upon finishing, the system:
- computes the final normalized score (0–1000),
- stores aggregated statistics for the simulation and for each question,
- updates the ranking.
- 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.
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>”.
- “Chave deste simulado – Compartilhe este simulado com um amigo:
- When a seed is provided, the backend reuses the configuration and question set from the base simulation associated with that seed.
- Reusable form components used by the simulation page, especially:
-
frontend/pages/simulation.py:- Unified simulation page with three logical steps in the same UI:
- Setup (Configurar simulado):
- uses
simulation_setup_formto send aSimulationCreatepayload to the backend (POST /simulation); - loads the selected questions for the created simulation (
GET /simulation/{id}/questions).
- uses
- 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}/abortand clears local state so the user can start another simulado.
- displays the current question index (e.g.
- 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.
- on “Finalizar simulado”, calls
- Setup (Configurar simulado):
- Unified simulation page with three logical steps in the same UI:
-
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.
- fetches ranking from
- Leaderboard of users:
-
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.
- Statistics page for the question bank and simulations:
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).
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.csvon 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.
- Initializes the database schema (
-
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 withorder_indexto 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,scoreper question,created_at.
Statistics: per-simulation aggregated statistics, mirroringSimulationaggregates 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.
SimulationOutandSimulationDetailOut: 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_progresswith no activity for more than 5 minutes) by callingmark_stale_simulations_as_aborted; - validates
questions_per_subjectif provided; - if
seed_idis present:- finds a base simulation (
_find_simulation_by_seed) bySimulation.seed_id, - reuses exactly the same questions from the base simulation via
SimulationQuestion, - overrides
payload.num_questionsto match the base;
- finds a base simulation (
- otherwise (simulado “normal”):
- builds an initial distribution
per_subject_count(uniform or respectingquestions_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_availabilityto:- 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_weightsonly activate strict mode after at least 10 completed simulations and when there is a diversity of weights for that subject;
- builds an initial distribution
- validates that the number of selected questions is equal to
payload.num_questions; - creates a
Simulationwithseed_id(if any) andstatus="in_progress"; - persists entries in
SimulationQuestionfor the chosen questions.
- aborts stale simulations (
-
register_answer:- validates the simulation and question,
- computes per-question time budget as
120 * 60 / num_questions, - calculates
time_spentandremaining_time, - determines correctness, uses
Question.difficulty_weightas weight, - computes question score as
remaining_time * weight * 0.5if correct, 0 otherwise, - records
Answerwith all these details.
-
abort_simulation:- explicitly aborts a simulation:
- sets
status="aborted"andfinished_at, - does not recalculate aggregates or update statistics,
- sets
- is idempotent: if the simulation is already
finishedoraborted, returns its current view.
- explicitly aborts a simulation:
-
finish_simulation:- sets
status="finished"andfinished_at, - calls
recalculate_simulation_aggregatesto compute final score and aggregates, - upserts
Statisticsfor that simulation, - calls
update_question_statistics_for_simulationto update difficulty weights of questions used in this simulation, - calls
_cleanup_old_simulationsto delete finished/aborted simulations older than 8 days.
- sets
-
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
abortedif they have been idle (no answers and stillin_progress) for more than 5 minutes.
- marks simulations as
-
-
backend/services/scoring.py:calculate_question_score: computes per-question score usingremaining_time_seconds * weight * 0.5when correct, 0 otherwise.recalculate_simulation_aggregates: computes:raw_score(sum ofAnswer.score),accuracy(correct answers / total answers),avg_time(averagetime_spent_seconds),total_time(sum oftime_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.2to 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 anddifficulty_weightper 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.
- highest
- computes ranking per user based on:
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.
The scoring model separates the following metrics:
raw_score: sum of question scores (each question’s score isremaining_time_seconds * weight * 0.5if 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, currentlyw_raw = 0.4,w_acc = 0.4,w_time = 0.2. -
S_rawis 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_accis simplyaccuracy. -
S_time = 1 / (1 + avg_time / T_ref), withT_ref = 30 seconds:- if
avg_time = 0, thenS_time = 1; - if
avg_time = 30, thenS_time = 0.5; - if
avg_time = 60, thenS_time ≈ 0.33.
- if
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.
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.