An AI-powered exam preparation platform built with Flutter. Helps students track syllabus progress, attempt mock tests, get AI-driven predictions, and visualize their performance — all in one place.
- Project Overview
- Architecture
- Mock Test Platform
- AI Pipeline (DICL)
- Exam Prediction Feature
- Focus Session
- Community Feed
- Subjects and Handout Upload
- Profile
- Test Attempts, Question Results, and AI Evaluation
- PDF Upload Pipeline
- PYQ Batch Upload Pipeline (Internal Tooling)
- Nova — Adaptive Prep Planner
- Folder Structure
- Tech Stack
- Features
- Dashboard -- How It Works
- Running the App
- Code Generation
- Running the Backend
- Adding a New Feature
- Tech Debt
- Roadmap
An AI-powered study platform designed for college exam preparation. It offers personalized mock tests calibrated to a student's college difficulty level using Diverse In-Context Learning (DICL), along with PYQ analysis, performance analytics, goal tracking, and distraction-free focus sessions. Students can also access a community feed of AI-generated tests shared across subjects within their college ecosystem. Authentication through college email ensures secure, college-specific access to resources and data isolation.
Follows Clean Architecture with a feature-first folder structure. Every feature is fully isolated across three layers.
Presentation -> Domain -> Data
- Presentation: Riverpod providers, pages, widgets. Knows nothing about data sources.
- Domain: Pure Dart. Entities, repository interfaces, use cases. No Flutter imports.
- Data: DTOs, data sources, repository implementations. Talks to Supabase, APIs, or local storage.
Dependencies always point inward — data depends on domain, presentation depends on domain, nothing depends on presentation or data directly.
UI Widget
↓ watches
Riverpod Provider (AsyncNotifierProvider / NotifierProvider)
↓ calls
UseCase
↓ calls
Repository (abstract interface)
↓ implemented by
RepositoryImpl
↓ calls
DataSource (Supabase / FastAPI / local storage)
- Data layer catches exceptions, returns
Either<Failure, Data> - Domain layer defines
Failuretypes - Presentation layer handles
Eitherresults withstate.when(loading, error, data)
The mock test feature lets students take AI-generated tests calibrated to their subject, college, and exam type. It follows full Clean Architecture across all layers.
| Type | Mode | Default Questions | Max Questions | Description |
|---|---|---|---|---|
| Quiz | Written Practice | 5 | 10 | Short open-ended questions, model answers included |
| Midsem | Written Practice | 6 | 10 | Medium difficulty, draws from midsem + quiz PYQs |
| Compre Part A | MCQ Blitz | 8 | 15 | Timed MCQ quiz, draws from full syllabus PYQs |
| Compre Part B | Written Practice | 4 | 10 | Long-answer practice, draws from full syllabus PYQs |
| File | Role |
|---|---|
domain/entities/mock_test_entity.dart |
MockTestEntity, McqQuestion, OpenQuestion — freezed domain models |
domain/repositories/mock_test_repository.dart |
Abstract repository interface |
domain/usecases/mock_test_usecases.dart |
FetchMcqQuestionsUseCase, FetchOpenQuestionsUseCase, FetchQuestionsByIdsUseCase |
data/dtos/mock_test_dto.dart |
DTOs with fromJson / toJson |
data/datasources/mock_test_datasource.dart |
Calls /generate-batch, /generate-open-batch, fetches by IDs from Supabase, sources subjects dynamically per user |
data/repository_impl/mock_test_repository_impl.dart |
Wraps datasource in Either<Failure, T> |
presentation/providers/mock_tests_provider.dart |
MockTestNotifier — state, API calls, exam mode routing, loadExistingTest |
presentation/pages/mock_tests_pages.dart |
Full UI: setup screen, loading, error, MCQ flow, written practice flow, result screen |
shared/models/exam_type.dart |
ExamType enum — single source of truth used across mock tests and feed |
ExamType.compreA → ExamMode.mcqBlitz → POST /generate-batch
ExamType.quiz → ExamMode.writtenPractice → POST /generate-open-batch
ExamType.midsem → ExamMode.writtenPractice → POST /generate-open-batch
ExamType.compreB → ExamMode.writtenPractice → POST /generate-open-batchloadExistingTest(questionIds, examType) fetches questions by their Supabase IDs and reconstructs the test state. Used by the community feed Attempt button. Currently only supports written practice exam types — see Tech Debt for Compre Part A limitation.
The mock test setup screen pulls the student's subject list from their actual user_subjects enrollment rather than a hardcoded list.
Instead of generating generic questions, Skolar uses the college's own Previous Year Questions (PYQs) as a reference. The generated questions match that specific college's difficulty level, question style, and topic distribution. No manual labeling or tagging is required — the system infers difficulty from context.
The pipeline is based on the paper "Exploring the Role of Diversity in Example Selection for In-Context Learning" published at SIGIR 2025. The key finding is that selecting diverse examples for the LLM prompt produces better outputs than selecting the most similar examples.
Naive example selection picks the most similar PYQs to the query. This causes topical bias — the LLM sees only one subtopic and generates repetitive questions. DICL uses MMR (Maximal Marginal Relevance) to pick examples that are both relevant and diverse, spreading coverage across different topics.
Token efficiency: instead of sending all PYQs to the LLM, MMR selects 5 diverse examples (~2,000 tokens). This is a 95% reduction in token usage with better output quality.
Before MMR runs, the question bank is filtered to only include PYQs relevant to the chosen exam type:
| Exam Type | PYQs used as examples |
|---|---|
| Quiz | quiz only |
| Midsem | midsem + quiz1 |
| Quiz 2 | quiz2 + midsem + quiz1 |
| Compre | compre + quiz2 + midsem + quiz1 |
If no questions match the filter, the pipeline falls back to the full bank so generation never hard-fails. A null exam_type value from Supabase is handled defensively so it no longer raises an AttributeError during filtering.
MCQ Blitz (/generate-batch) — used for Compre Part A. Generates N MCQs in parallel, each with 4 options and a correct index. Timed quiz UI with score tracking.
Written Practice (/generate-open-batch) — used for Quiz, Midsem, and Compre Part B. Generates N open-ended questions, each with a pre-generated structured model answer. Two practice views: flashcard (one at a time, reveal answer) and paper (all questions scrollable).
PDF Files (PYQs)
↓
pdfplumber — extract raw text
↓
Groq LLM (LLaMA 3.3 70B) — extract clean questions with marks, type, subject, year
↓
sentence-transformers (all-MiniLM-L6-v2) — embed each question → vector(384)
↓
Supabase (questions table, scoped by college + subject + exam_type)
↓
Exam type filter — keep only allowed exam_types for this mode
↓
sentence-transformers (all-MiniLM-L6-v2) — query embedding
↓
MMR Algorithm — select k diverse examples from question bank
↓
Groq LLM (LLaMA 3.3 70B) — generate MCQ or open question
↓ (written practice only)
Groq LLM (LLaMA 3.3 70B) — generate structured model answer
↓
Auto-save to published_tests (written practice only — see Tech Debt)
↓
Questions returned to Flutter app
Student uploads handout PDF (Flutter → Supabase Storage)
↓
handout_url + handout_filename written to user_subjects row
↓
Fire-and-forget POST /extract-plan (FastAPI)
↓
pdfplumber — extract raw text from handout
↓
Groq LLM — extract flat topic list from handout text
↓
Groq LLM — generate weekly study plan grouped by topic
↓
Deactivate existing active plan for this user_subject
↓
Insert new row into study_plans (topics jsonb, weekly_plan jsonb, raw_handout_data jsonb, is_active true)
The upload completes and the UI updates immediately. Plan generation runs in the background and persists permanently in study_plans. Re-uploading a new handout deactivates the previous plan and generates a fresh one.
At every step, MMR picks the candidate that maximises:
score = alpha * relevance_to_query - (1 - alpha) * max_similarity_to_already_selected
Alpha = 0.7 means 70% relevance, 30% diversity. Greedy algorithm that builds selection one item at a time.
The schema below reflects the live database. Tables with no corresponding Flutter/Python description yet are marked (schema only). Nova-specific tables are documented in the Nova section.
id uuid, primary key, default gen_random_uuid()
name text, not null
short_name text, not null, unique
email_patterns jsonb, not null, default '[]'::jsonb
website text, nullable
created_at timestamptz, not null, default now()
id uuid, primary key, default gen_random_uuid()
institution_id uuid, not null, references institutions.id ON DELETE CASCADE
name text, not null
short_name text, not null, unique
subdomain text, nullable
location text, nullable
created_at timestamptz, not null, default now()
id uuid, primary key, default gen_random_uuid()
institution_id uuid, not null, references institutions.id ON DELETE CASCADE
name text, not null
short_name text, nullable
academic_year smallint, not null, check (1–4)
semester smallint, nullable
credits smallint, nullable
campus_id uuid, nullable, references campuses.id
created_at timestamptz, not null, default now()
id uuid, primary key, references auth.users(id)
email text, not null, unique
full_name text, nullable
roll_number text, nullable
college text, nullable
institution_id uuid, nullable, references institutions.id
campus_id uuid, nullable, references campuses.id
academic_year smallint, nullable, check (1–5) — widened from 1–4 to cover dual-degree students
avatar_url text, nullable
avatar_data text, nullable — inline avatar payload from the onboarding avatar picker
branch text, nullable
dual_branch text, nullable — second branch for dual-degree students
current_semester smallint, nullable, check (1 or 2)
study_capacity text, nullable — free-form capacity note captured at onboarding, feeds `nova_config.capacity_hour_mapping`
plan text, not null, default 'free'
role text, not null, default 'student', check (student|admin|super_admin)
semester_credits smallint, nullable
created_at timestamptz, not null, default now()
updated_at timestamptz, not null, default now()
RLS policies: insert, update, select scoped to auth.uid().
avatar_data, dual_branch, current_semester, and study_capacity were added by the redesigned onboarding flow (see Onboarding Seed Context under Nova) — they're written by the same RPC call that seeds standing_flags, nova_history, and career_units, not through a separate profile-edit path.
User- or institution-defined subjects that don't exist in the shared subjects catalog yet.
id uuid, primary key, default gen_random_uuid()
institution_id uuid, not null, references institutions.id ON DELETE CASCADE
course_code text, not null
name text, not null
credits smallint, nullable
created_at timestamptz, not null, default now()
id uuid, primary key, default gen_random_uuid()
user_id uuid, not null, references users.id ON DELETE CASCADE
subject_id uuid, nullable, references subjects.id ON DELETE CASCADE
custom_subject_id uuid, nullable, references custom_subjects.id ON DELETE SET NULL
semester text, not null
handout_url text, nullable
handout_filename text, nullable
handout_uploaded_at timestamptz, nullable
Constraint: exactly one of subject_id / custom_subject_id must be set — enforced via CHECK constraint user_subjects_subject_check.
topic_schedulecolumn was present in an earlier version and has been dropped. Study plan data lives instudy_plans.
Stores exam dates per enrolled subject. Nova uses this for time_left urgency signal.
id uuid, primary key, default gen_random_uuid()
user_subject_id uuid, not null, references user_subjects.id ON DELETE CASCADE
exam_type text, not null, check (quiz1|midsem|quiz2|compre)
exam_date date, not null
created_at timestamptz, not null, default now()
unique (user_subject_id, exam_type)
RLS: scoped to student via user_subject_id join.
Canonical topic list extracted from PYQs and handouts. All free-text topic columns across the schema have a corresponding topic_id FK to this table.
id uuid, primary key, default gen_random_uuid()
subject_id uuid, nullable, references subjects.id
custom_subject_id uuid, nullable, references custom_subjects.id
name text, not null
created_at timestamptz, not null, default now()
Constraint: exactly one of subject_id / custom_subject_id must be set. Case-insensitive unique indexes prevent "Normalization" and "normalization" from being stored as separate topics.
RLS: read-only for all authenticated users. Write access pending pipeline key confirmation (service-role vs anon).
id uuid, primary key, default gen_random_uuid()
question_text text, not null
marks integer, not null, default 0
question_type text, not null — mcq|short_answer|long_answer|numerical
subject text, not null — legacy text, kept for pipeline compatibility
college text, not null — legacy text, kept for pipeline compatibility
paper_year integer, nullable
academic_year smallint, nullable
exam_type text, nullable — quiz1|midsem|quiz2|compre|generated
embedding vector(384), nullable — all-MiniLM-L6-v2
published boolean, not null, default false
published_by uuid, nullable, references users.id ON DELETE SET NULL
published_at timestamptz, nullable
created_at timestamptz, not null, default now()
options jsonb, nullable — MCQ options array
correct_index smallint, nullable — correct option index 0–3
subject_id uuid, nullable, references subjects.id — canonical FK
campus_id uuid, nullable, references campuses.id
source_pdf_id uuid, nullable, references uploaded_pdfs.id
doc_type text, nullable
topic text, nullable — legacy free text, kept for pipeline compatibility
topic_id uuid, nullable, references topics.id — canonical FK
has_diagram boolean, not null, default false
sub_parts jsonb, nullable
model_answer text, nullable
answer_source text, nullable
confidence_score numeric, nullable
marks_inferred boolean, not null, default false
subject and topic text columns coexist with their FK replacements (subject_id, topic_id) during migration. New code should write both; future cleanup will drop the text columns once the pipeline is fully migrated.
id uuid, primary key, default gen_random_uuid()
published_by uuid, nullable, references users.id ON DELETE SET NULL
college text, not null — legacy
subject text, not null — legacy
subject_id uuid, nullable — canonical FK
campus_id uuid, nullable
exam_type text, not null, check (quiz1|midsem|quiz2|compre|generated)
question_ids uuid[], not null — plain array, not FK-enforced
upvotes integer, not null, default 0
downvotes integer, not null, default 0
attempts integer, not null, default 0
created_at timestamptz, not null, default now()
question_ids is a plain array — deleting a questions row will silently leave a dangling ID here.
user_id uuid, not null, references users.id ON DELETE CASCADE
post_id uuid, not null, references published_tests.id ON DELETE CASCADE
vote smallint, not null, check (1 or -1)
created_at timestamptz, not null, default now()
primary key (user_id, post_id)
id uuid, primary key, default gen_random_uuid()
user_subject_id uuid, not null, references user_subjects.id ON DELETE CASCADE
user_id uuid, not null, references users.id ON DELETE CASCADE
subject_name text, not null — denormalized for query convenience
handout_url text, not null — which handout version this plan was generated from
topics jsonb, not null — flat list of topic strings
weekly_plan jsonb, not null — array of {week, topics, study_hours, focus}
raw_handout_data jsonb, nullable — full extracted handout data, preserves all content
is_active boolean, not null, default true
generated_at timestamptz, not null, default now()
updated_at timestamptz, not null, default now()
Only one plan per user_subject_id is active at a time. Uploading a new handout deactivates the previous plan before inserting the new one.
id uuid, primary key, default gen_random_uuid()
uploaded_by uuid, nullable, references users.id ON DELETE SET NULL
uploaded_as text, not null, default 'student', check (student|admin)
storage_path text, not null
doc_type text, not null, check (pyq|tutorial|solution|lab|misc)
subject_id uuid, nullable, references subjects.id
campus_id uuid, nullable, references campuses.id
exam_type text, nullable
paper_year integer, nullable
topic text, nullable
topic_id uuid, nullable, references topics.id
status text, not null, default 'pending', check (pending|running|succeeded|partial|failed)
questions_extracted integer, not null, default 0
questions_failed integer, not null, default 0
created_at timestamptz, not null, default now()
id uuid, primary key, default gen_random_uuid()
test_id uuid, not null, references published_tests.id ON DELETE CASCADE
user_id uuid, not null, references users.id ON DELETE CASCADE
subject_id uuid, nullable, references subjects.id
exam_type text, nullable, check (quiz1|midsem|quiz2|compre)
attempt_number smallint, not null, default 1
total_marks integer, not null, default 0
obtained_marks integer, not null, default 0
completed_at timestamptz, not null, default now()
unique (test_id, user_id, attempt_number)
id uuid, primary key, default gen_random_uuid()
attempt_id uuid, not null, references test_attempts.id ON DELETE CASCADE
question_id uuid, not null, references questions.id
topic text, nullable — legacy free text
topic_id uuid, nullable, references topics.id — canonical FK
is_correct boolean, nullable
marks_available integer, not null, default 0
marks_obtained numeric, not null, default 0
self_rating smallint, nullable, check (1–5)
error_category text, nullable, check (concept_gap|practice_gap|careless)
ai_evaluation_id uuid, nullable, references ai_evaluations.id ON DELETE SET NULL
created_at timestamptz, not null, default now()
id uuid, primary key, default gen_random_uuid()
question_result_id uuid, not null, references question_results.id ON DELETE CASCADE
user_id uuid, not null, references users.id ON DELETE CASCADE
question_id uuid, not null, references questions.id
answer_photo_url text, nullable
extracted_text text, nullable
model_answer_used text, nullable
score_awarded numeric, nullable
max_score numeric, nullable
feedback_json jsonb, nullable
evaluated_at timestamptz, not null, default now()
EMA-style weakness signal per topic per student.
id uuid, primary key, default gen_random_uuid()
user_id uuid, not null, references users.id
subject_id uuid, nullable, references subjects.id — null for custom subjects
custom_subject_id uuid, nullable, references custom_subjects.id — null for catalog subjects
topic text, not null — legacy free text
topic_id uuid, nullable, references topics.id — canonical FK
weight numeric, not null, default 0.5, check (0–1)
created_at timestamptz, not null, default now()
updated_at timestamptz, not null, default now()
unique (user_id, subject_id, topic)
Constraint: exactly one of subject_id / custom_subject_id must be set — enforced via CHECK constraint user_topic_weights_subject_check.
| Parent deleted | Cascades to | Effect |
|---|---|---|
users |
ai_evaluations, post_votes, study_plans, test_attempts, user_subjects, nova_* tables |
hard delete |
institutions |
campuses, custom_subjects, subjects |
hard delete |
subjects |
user_subjects |
hard delete — but blocked by NO ACTION from questions, test_attempts, uploaded_pdfs |
user_subjects |
study_plans, user_subject_exams, staleness_tracker |
hard delete |
published_tests |
post_votes, test_attempts |
hard delete |
test_attempts |
question_results |
hard delete |
question_results |
ai_evaluations |
hard delete |
users (as published_by / uploaded_by) |
published_tests, questions, uploaded_pdfs |
SET NULL — content kept, authorship orphaned |
custom_subjects |
user_subjects.custom_subject_id |
SET NULL — enrollment kept, link cleared |
ai_evaluations |
question_results.ai_evaluation_id |
SET NULL |
questions |
ai_evaluations, question_results |
NO ACTION — delete blocked while referenced |
Model answers are structured markdown, calibrated to marks:
- 1–3 marks: 2–3 sentences, bold key terms, no headings
- 4–6 marks: direct answer +
### Steps(computational) or### Key points(conceptual) - 7+ marks: full worked answer with
### Working,### Result, or### Approach / Explanation / Conclusion
The backend is deployed on Railway. Start locally with:
uvicorn main:app --reload --port 8000| Method | Endpoint | Description |
|---|---|---|
GET |
/health |
Sanity check |
GET |
/stats |
Bank stats scoped by college |
GET |
/questions |
Browse/filter the question bank |
POST |
/generate |
One open-ended question |
POST |
/generate-batch |
N MCQs in parallel (Compre Part A) |
POST |
/generate-open-batch |
N open questions + model answers, auto-saves to published_tests |
POST |
/upload-pyq |
PDF → extract → insert into Supabase |
POST |
/extract-plan |
Handout PDF → topic list + weekly study plan → insert into study_plans |
| Method | Endpoint | Description |
|---|---|---|
POST |
/nova/capacity |
Submit today's capacity tap (light/normal/packed) |
POST |
/nova/trigger/check |
Run trigger layer — decides if a reasoning pass is needed |
POST |
/nova/plan/generate |
Run reasoning pass, return ranked focus list + time budgets + why |
GET |
/nova/plan/current |
Fetch today's active plan |
GET |
/nova/plan/log |
Full audit trail of all plan changes |
POST |
/nova/flags/situation |
Propose + confirm a situation flag |
POST |
/nova/flags/standing |
Propose + confirm a standing flag |
DELETE |
/nova/flags/{flag_id} |
Remove an active flag |
POST |
/nova/career/track |
Add a career/industry unit to track |
POST |
/nova/career/relevance/refresh |
Trigger industry relevance web lookup |
GET |
/nova/career/tracks |
List active career units with relevance + staleness |
POST |
/nova/conversation |
Send a message to Nova — stores turn, extracts facts, proposes writes |
GET |
/nova/conversation/history |
Fetch active conversation history |
POST |
/nova/override/one-off |
Log a one-off override — never persisted to schema |
GET |
/nova/proposals/pending |
List unconfirmed proposals awaiting confirmation |
POST |
/nova/proposals/{proposal_id}/confirm |
Confirm a proposal — triggers schema write + reasoning pass |
DELETE |
/nova/proposals/{proposal_id} |
Reject and discard a pending proposal |
- All
/nova/*endpoints are scoped to the authenticated student — no cross-student data access - Nova never calls
pipeline.py— question generation and Nova are fully separate /nova/trigger/checkis called on app open, after test submission, and after flag confirmation/nova/conversationnever writes silently — all fact writes go through/nova/proposals/{id}/confirm- Minor re-ranks (no LLM call) and full reasoning passes both go through
/nova/plan/generate— trigger severity decided beforehand by/nova/trigger/check
Most of the table above still describes the planned Phase 5 backend. One exception:
POST /nova/trigger/checkis real and live today — see Nova Trigger Layer below for where it actually lives and how it differs from this plan. The Nova CLI prototype (see Nova CLI (Q&A Prototype)) still doesn't go through FastAPI at all — it talks to Supabase and Groq directly from a local script.
{
"user_subject_id": "<uuid>",
"user_id": "<uuid>",
"subject_name": "Operating Systems",
"handout_url": "https://..."
}Note: this endpoint is not yet wired in Flutter — see Tech Debt.
evaluate.py measures generation quality across two dimensions: accuracy and diversity.
cd lib/core/ai/rag_llms
myenv311\Scripts\activate
python evaluate.pyTests run:
| Test | What it checks | Pass threshold |
|---|---|---|
| Health | Server is reachable | 200 OK |
| Stats | Question bank has data | total_questions > 0 |
| MCQ Accuracy | correct_index is 0–3, all 4 options distinct, non-empty question | 10/10 |
| MCQ Diversity | Pairwise cosine similarity across 10 generated questions | avg < 0.5, max < 0.8 |
| Open Quality | Non-empty questions, substantive answers (>50 chars), not MCQ format | 5/5 |
| Open Diversity | Pairwise cosine similarity across 5 open-ended questions | avg < 0.5, max < 0.8 |
Current baseline (BPHC / Artificial Intelligence, 33 questions):
Note: eval scores at this bank size are not meaningful — 33 questions is too small for MMR to diversify effectively. Re-run when the bank reaches 100+ questions per subject.
| Metric | Score | Status |
|---|---|---|
| MCQ avg similarity | 0.435 | ✅ Pass |
| MCQ max similarity | 0.996 | ❌ Fail — duplicate DFS question in bank |
| Open avg similarity | 0.539 | ❌ Fail — bank skewed toward RL questions |
| Open max similarity | 0.887 | ❌ Fail — bank skewed toward RL questions |
Multiple concurrent generation requests are safe. The ThreadPoolExecutor for parallel generation is capped at 3 workers to stay within Groq free-tier rate limits. Supabase handles concurrent reads natively.
lib/core/ai/rag_llms/
main.py — FastAPI app and all endpoints
pipeline.py — DICL pipeline: parsing, embedding, MMR, generation, bank I/O, study plan extraction
.env — GROQ_API_KEY, SUPABASE_URL, SUPABASE_KEY (not committed)
evaluate.py — Pipeline evaluation: accuracy, diversity scoring
The exam prediction feature lets students browse and filter the college question bank directly from the app.
| File | Role |
|---|---|
exam_prediction_pages.dart |
Main page with tabs: Question Bank browser |
exam_prediction_datasource.dart |
Calls GET /questions with filters |
exam_prediction_repository_impl.dart |
Wraps datasource in Either<Failure, T> |
exam_prediction_provider.dart |
Riverpod notifier for question bank state |
exam_prediction_usecases.dart |
Use cases: GetQuestionsUseCase, GetStatsUseCase |
exam_prediction_entity.dart |
QuestionEntity, StatsEntity domain models |
Questions can be filtered by subject, year, exam_type, question_type, and source.
The focus session feature gives students a distraction-free countdown timer for managing structured study blocks. It is self-contained with no backend dependency — all state lives in FocusTimerController.
FocusSetupPage provides a full-screen custom duration picker, navigated to from the Custom chip.
- A hero time display (large gradient text) updates in real time as the slider moves
- A
Sliderranges from 5 minutes to 3 hours with 1-minute steps and a customGlowThumbShapethumb - Three preset chips (Pomodoro, 45 min, 1 hr) sync bidirectionally with the slider
- A Session breakdown card shows the chosen duration in hours/minutes and the equivalent pomodoro count
- Tapping Start session calls
onConfirm(seconds)and pops back to the timer page
The focus session is presentation-layer only. There is no domain layer or backend call. State is managed by FocusTimerController, a ChangeNotifier consumed directly by FocusTimerPage via addListener.
This is intentional — the timer resets if the user leaves the app (AppLifecycleState observer), enforcing distraction-free focus. If session history or streak tracking is added in Phase 5, a StorageService.saveSession() call should be added inside _onTick when _secondsLeft reaches zero.
| File | Role |
|---|---|
controllers/focus_timer_controller.dart |
State machine, countdown ticker, wave animation |
presentation/focus_timer_page.dart |
Main timer screen: bonsai hero, readout, slide track, give-up sheet |
presentation/focus_setup_page.dart |
Custom duration picker: hero time, preset chips, slider, session card |
widgets/focus_background.dart |
Custom painter: base, ambient glow, sliding surface panel |
widgets/glow_thumb_shape.dart |
Custom SliderComponentShape with glow halo for the setup page slider |
widgets/present_chip.dart |
Animated chip widget used for preset selection on both pages |
data/models/focus_present.dart |
FocusPreset value type with three default presets |
The community feed surfaces AI-generated tests created by students on college subjects. It is backed by a live Supabase query against the published_tests table.
Student generates a written practice test (Quiz / Midsem / Compre B)
↓
Backend auto-saves questions to Supabase and inserts row into published_tests
↓
published_tests row contains: subject, college, exam_type, question_ids[], published_by
↓
Community feed shows the test with subject, question count, upvotes, attempts
↓
Attempt button calls loadExistingTest(questionIds, examType) on MockTestNotifier
Note: Compre Part A (MCQ Blitz) tests are not currently published or displayed in the feed — see Tech Debt.
The feed follows the same Clean Architecture pattern as every other feature. The datasource was swapped from local mock data to the live API in one file:
FeedLocalDataSourceImpl → FeedRemoteDataSourceImpl
(mock JSON) (Supabase: published_tests table)
Everything above — FeedRepositoryImpl, GetFeedUseCase, FeedNotifier, FeedPage, FeedPostCard — stayed identical.
Upvote/downvote state is persisted to Supabase via the post_votes table, with optimistic UI updates on the client so the vote reflects instantly before the write confirms.
| File | Role |
|---|---|
data/datasources/feed_remote_datasource.dart |
Queries published_tests from Supabase |
data/dtos/feed_post_dto.dart |
fromSupabase factory, questionIds, examType fields |
data/repository_impl/feed_repository_impl.dart |
Wraps remote datasource in Either<Failure, T> |
domain/entities/feed_post_entity.dart |
examType, questionIds fields |
presentation/providers/feed_provider.dart |
College read from userProvider |
presentation/pages/feed_page.dart |
College from userProvider |
presentation/widgets/feed_post_card.dart |
Attempt button wired to loadExistingTest, vote buttons wired to Supabase |
The subjects feature lets students view their enrolled subjects for the current semester and upload a course handout PDF per subject. Uploading a handout triggers automatic AI study plan generation in the background.
Student taps "Upload handout" chip on a subject card
↓
FilePicker.pickFiles() — native PDF picker (file_picker v11)
↓
PDF uploaded to Supabase Storage: handouts/{userId}/{userSubjectId}/{filename}
↓
handout_url + handout_filename written to user_subjects row
↓
UI chip updates to show filename immediately
↓
Fire-and-forget POST /extract-plan → study plan generated and saved to study_plans
The upload and UI update are synchronous from the user's perspective. Plan generation is asynchronous — it completes in the background and persists permanently. Re-uploading replaces the handout and regenerates the plan.
Bucket: handouts (public)
RLS policies:
INSERT— authenticated users only,bucket_id = 'handouts'SELECT— authenticated users only,bucket_id = 'handouts'
| File | Role |
|---|---|
data/datasources/subjects_datasource.dart |
uploadHandout — uploads to Storage, updates user_subjects, triggers plan extraction |
data/repository_impl/subjects_repository_impl.dart |
Wraps uploadHandout in Either<Failure, SubjectEntity> |
presentation/pages/subjects_pages.dart |
_SubjectsNotifier.uploadHandout, _HandoutChip, _pickAndUploadHandout |
| State | Appearance |
|---|---|
| No handout | "Upload handout" with upload icon, dimmed border |
| Uploading | Spinner + "Generating plan…" text |
| Handout uploaded | Filename + ↺ icon, primary color border |
The profile page is a real, Supabase-backed screen — it replaced two earlier throwaway/mock implementations (profile_page2.dart, profile_pages1.dart, both deleted). It's read-only: there's no separate profile-edit form. Editing works by re-entering the onboarding flow, which writes through the same save_onboarding_seed_context RPC (see Onboarding Seed Context) with a partial payload.
| Card | Data source |
|---|---|
| User header — avatar, name, plan badge, roll number, campus | userProvider (users table); avatar rendered from avatar_data SVG via flutter_svg, falls back to a text initial |
| Academic details — branch, dual branch, academic year, current semester | userProvider |
| Study strategy — study pace + weekly hour range, endgame goal, prep style | profileDetailsProvider — standing_flags (endgame) + nova_history (prep style), both filtered to source = 'onboarding' |
| Career interests | profileDetailsProvider — career_units filtered to source = 'onboarding' |
| Account & actions — email, "Update Profile & Preferences" (routes to onboarding), sign out | userProvider |
A FutureProvider in lib/features/profile/presentation/providers/profile_provider.dart that queries standing_flags, career_units, and nova_history directly, each scoped to the current auth.uid() and source = 'onboarding'. Each of the three queries is wrapped in its own try/catch — a failure in one (e.g. no career interests yet) doesn't blank out the other two.
| File | Role |
|---|---|
presentation/pages/profile_page.dart |
Full UI — five staggered-entrance cards, pull-to-refresh, sign-out confirmation dialog |
presentation/providers/profile_provider.dart |
ProfileDetails, profileDetailsProvider |
"Update Profile & Preferences" routes to AppRoutes.onboardingProfile and re-runs the onboarding questionnaire. onboarding_profile_page.dart now tracks its own _isLoading state around the complete() call (button shows a spinner and ignores taps mid-submit) and no longer auto-advances on the endgame/prep-style single-select steps — both were needed once onboarding became a re-entrant edit flow rather than a first-run-only screen.
Schema is live in Supabase; no Flutter or FastAPI code description exists yet for this flow.
A test_attempts row represents one student's attempt at a published_tests entry. Each question answered within that attempt becomes a question_results row, capturing correctness, marks, error category, and an optional self-rating. For written/photographed answers, a question_results row can link to an ai_evaluations row.
published_tests
↓ (student attempts)
test_attempts (total_marks, obtained_marks, exam_type, completed_at)
↓ (one row per question)
question_results (is_correct, marks_obtained, error_category, topic_id, self_rating)
↓ (optional, for photographed/handwritten answers)
ai_evaluations (answer_photo_url → extracted_text → score_awarded, feedback_json)
Schema is live in Supabase (
uploaded_pdfstable); no corresponding Flutter/FastAPI description exists yet.
uploaded_pdfs tracks any PDF through ingestion independent of which feature triggered the upload. questions.source_pdf_id links extracted questions back to the source upload.
The POST /upload-pyq endpoint (the one path the Flutter app and any external API caller can reach) still only accepts .pdf — it hard-rejects anything else by filename extension. Multi-format extraction (below) is currently only reachable through the internal batch_upload.py script, not through the app or the public endpoint.
backend/pyq/batch_upload.py walks a local directory of PYQs and pushes every file through the same extraction/insert path the Flutter app's upload button uses (run_upload_pyq in lib/core/ai/rag_llms/pipeline.py), one file at a time, without touching the UI or the FastAPI endpoint. Dev/maintainer tool for seeding the question bank in bulk — not exposed to students.
python backend/pyq/batch_upload.py D:\PYQs\CS_F372 \
--subject "Compiler Construction" --college "BITS Pilani Hyderabad" \
--exam-type compre --doc-type pyqpipeline.py gained extract_raw_text_any(file_bytes, filename), which dispatches by extension:
| Extension | Extractor |
|---|---|
.pdf |
extract_raw_text — pdfplumber, now with hybrid per-page OCR (see below) |
.docx |
extract_docx_text — python-docx for paragraphs/tables, plus OCR of any images embedded in word/media/ |
.doc |
extract_doc_text — converts to .docx via a local LibreOffice (soffice) install, then runs extract_docx_text. Raises a clear error if soffice isn't on PATH rather than silently returning empty text |
.png / .jpg / .jpeg / .webp / .tif / .tiff |
extract_image_text — OCRs the image directly, for a standalone photo/screenshot of a paper |
Previously, OCR only ran when the whole extracted document was under 200 characters — so a page with real text plus one question pasted in as a screenshot never triggered OCR, and that question silently vanished. Now every page is checked individually: a page with an embedded image and under 400 characters of extracted text gets OCR'd on its own and the result appended, tagged [OCR supplement — page N, ...]. The whole-document 200-character fallback still runs afterward for fully image-only PDFs. OCR moved from pytesseract + pdf2image to easyocr + pypdfium2 for PDF rendering (pytesseract is still used for the smaller job of OCR'ing images embedded in .docx files).
exam_type is no longer required up front. Resolution order, cheapest/most-confident first:
- Filename —
_guess_exam_type()inbatch_upload.pylooks forcompre/midsem/test1/quiz1/test2/quiz2/test3in the filename. - Document content — if the filename guess comes up empty,
run_upload_pyqcallsguess_exam_type_from_text()(same keyword set, scanned over the first 2000 characters of extracted text) before extraction proceeds. --exam-typeflag — only tried if both of the above fail.
If all three come up empty, run_upload_pyq raises ExamTypeUndeterminedError and the file is skipped (recorded in the manifest as skipped_no_exam_type) rather than silently mis-tagged. The result dict from run_upload_pyq now also reports exam_type and exam_type_source ("filename" / "content" / "flag") for whichever value won.
Every file is hashed (SHA-256 of content, not filename — renaming a file doesn't trigger a re-upload) and recorded in <directory>/.upload_manifest.json after every single upload, not just at the end of the run. Re-running the script after a crash or a Ctrl+C skips everything already marked succeeded.
Image files (.jpg/.jpeg/.png/.tif/.tiff) whose filename ends in a page-number suffix (...p1, ...page 2, ..._3) are grouped by their common base name, sorted numerically, and merged in-memory into a single multi-page PDF via Pillow before upload — so a paper scanned as five separate photos becomes one document instead of five. .pdf/.docx/.doc files and images with no detectable page suffix are never grouped.
A separate, standalone scraper (backend/download_papers.py) for bulk-downloading question papers from a DSpace-based college repository by subject/author code, for feeding into batch_upload.py. Hardcodes an internal-network base URL — local/dev tool only, not part of the deployed backend.
python backend/download_papers.py "CS F372"| Package | Used by | Purpose |
|---|---|---|
pypdfium2 |
lib/core/ai/rag_llms |
Renders PDF pages to images for OCR |
easyocr |
lib/core/ai/rag_llms |
OCR engine for PDF pages and standalone images |
python-docx |
lib/core/ai/rag_llms |
.docx paragraph/table text extraction |
pytesseract |
lib/core/ai/rag_llms |
OCR for images embedded inside .docx files |
pillow |
lib/core/ai/rag_llms, backend |
Image handling; merging scanned pages into a PDF |
requests, beautifulsoup4 |
backend |
download_papers.py scraping |
Nova is an AI mentor that tells the student where to put their hours today. It reasons over a fresh facts snapshot every day, weighing exam urgency, weakness type, capacity, career relevance, and history — with no hardcoded priority rules.
Nothing about what to prioritize is hardcoded. Only the plumbing — what triggers re-evaluation, what gets confirmed, what gets logged — is rule-based. The actual judgment is reasoned fresh every time by an LLM acting like an experienced senior.
All Nova tables have RLS enabled, scoped to user_id = auth.uid().
Daily capacity tap per student. One row per student per day, upserted when student changes their tap.
id uuid, primary key, default gen_random_uuid()
user_id uuid, not null, references users.id
capacity text, not null, check (light|normal|packed)
logged_for_date date, not null
created_at timestamptz, not null, default now()
updated_at timestamptz, not null, default now()
unique (user_id, logged_for_date)
When upserting: ON CONFLICT (user_id, logged_for_date) DO UPDATE SET capacity = EXCLUDED.capacity, updated_at = now().
Tracks when each unit (academic subject or career unit) was last meaningfully worked on. Used by the trigger layer to detect neglected topics.
id uuid, primary key, default gen_random_uuid()
user_id uuid, not null, references users.id
user_subject_id uuid, nullable, references user_subjects.id
career_unit_id uuid, nullable, references career_units.id
topic_id uuid, nullable, references topics.id
last_meaningfully_touched timestamptz, not null, default now()
created_at timestamptz, not null, default now()
updated_at timestamptz, not null, default now()
Constraint: exactly one of user_subject_id / career_unit_id must be set (XOR).
Durable instructions from the student that persist until explicitly removed or superseded. Example: "always buffer DBMS practicals." Can attach to either an academic subject or a career unit — not academic-only.
id uuid, primary key, default gen_random_uuid()
user_id uuid, not null, references users.id
user_subject_id uuid, nullable, references user_subjects.id
career_unit_id uuid, nullable, references career_units.id
instruction_text text, not null
confirmed_at timestamptz, nullable — null = pending confirmation, inert until confirmed
superseded_at timestamptz, nullable — set when a newer statement replaces this one
supersedes_id uuid, nullable, references standing_flags.id — links to the flag this replaced
source text, not null, default 'conversation'
created_at timestamptz, not null, default now()
Active flags: WHERE superseded_at IS NULL AND confirmed_at IS NOT NULL.
Temporary context the student tells Nova. Example: "I'm sick this week," "I have a family event Saturday." Can attach to either an academic subject or a career unit.
id uuid, primary key, default gen_random_uuid()
user_id uuid, not null, references users.id
user_subject_id uuid, nullable, references user_subjects.id
career_unit_id uuid, nullable, references career_units.id
flag_text text, not null
confirmed_at timestamptz, nullable
superseded_at timestamptz, nullable
supersedes_id uuid, nullable, references situation_flags.id
starts_at timestamptz, not null, default now()
expires_at timestamptz, nullable — null = no fixed end date
created_at timestamptz, not null, default now()
Constraint: expires_at IS NULL OR expires_at > starts_at.
Unlike
standing_flagsandnova_history, this table has nosourcecolumn — unconfirmed whether that's intentional (situation flags are conversation-only by design) or a gap. Worth confirming before assuming either way.
What has worked for this student before, conversation-fed only. Not auto-inferred from scores (v2 deferral). Can attach to either an academic subject or a career unit.
id uuid, primary key, default gen_random_uuid()
user_id uuid, not null, references users.id
user_subject_id uuid, nullable, references user_subjects.id
career_unit_id uuid, nullable, references career_units.id
content text, not null — free text, preserves full nuance
confirmed_at timestamptz, nullable
superseded_at timestamptz, nullable
supersedes_id uuid, nullable, references nova_history.id
source text, not null, default 'conversation'
created_at timestamptz, not null, default now()
Known dependency: contradiction detection (setting supersedes_id correctly) must be handled by the conversation layer at write time. The schema cannot enforce this.
Career/industry skills or directions the student is tracking alongside academic prep.
id uuid, primary key, default gen_random_uuid()
user_id uuid, not null, references users.id
name text, not null
description text, nullable
industry_relevance_text text, nullable — human-readable relevance signal
industry_relevance_score numeric, nullable, check (0–1) — machine-readable, used by trigger layer
industry_relevance_updated_at timestamptz, nullable — when relevance was last refreshed
confirmed_at timestamptz, nullable
paused_at timestamptz, nullable — null = active, set = paused
source text, not null, default 'conversation'
created_at timestamptz, not null, default now()
updated_at timestamptz, not null, default now()
Active units: WHERE paused_at IS NULL AND confirmed_at IS NOT NULL. Career units use pause/resume rather than supersession — pausing a skill is not the same shape as contradicting a stated preference.
Audit trail for every plan change — full facts snapshot, resulting plan, and a reasoning summary, per §7/§9 of the Nova spec. entry_type distinguishes the three shapes a log entry can take: a full reasoning pass, a no-LLM-call minor arithmetic re-rank, or a logged one-off override that never touched the schema.
id uuid, primary key, default gen_random_uuid()
user_id uuid, not null, references users.id
entry_type text, not null, check (full_pass|minor_trigger|one_off_override)
user_subject_id uuid, nullable, references user_subjects.id
topic_id uuid, nullable, references topics.id
career_unit_id uuid, nullable, references career_units.id
facts_snapshot jsonb, nullable — full snapshot the model reasoned over; null for lighter entry types
plan_output jsonb, nullable — the resulting ranked plan
reasoning_summary text, not null
superseded_at timestamptz, nullable — set when a later pass supersedes this entry (concurrency rule, §6)
supersedes_id uuid, nullable, references nova_why_log.id
created_at timestamptz, not null, default now()
Generic key/value config store — not fixed columns. user_id is nullable, which allows global/default config rows (e.g. default staleness thresholds) alongside per-student overrides. RLS enabled; (user_id, key) is unique so a per-student override and the global default for the same key can coexist without conflicting.
id uuid, primary key, default gen_random_uuid()
user_id uuid, nullable, references users.id — null = global/default config, not per-student
key text, not null
value jsonb, not null
updated_at timestamptz, not null, default now()
unique (user_id, key)
Seeded global (user_id = NULL) rows: staleness_days_academic (14), staleness_days_career (21), time_left_buckets ([7,3,1]), academic_pressure_buckets ([10,3]). A per-student capacity_hour_mapping row is written during onboarding — see Onboarding Seed Context below.
Nova's facts snapshot is derived live at reasoning time by nova_pipeline.py, not stored as a materialized table. It joins:
user_subject_exams→time_leftper subjectnova_capacity_log→ today's capacitystaleness_tracker→ last touched per unitstanding_flags+situation_flags→ active flagsnova_history→ confirmed history entriescareer_units→ active career units with relevance scoresquestion_results+user_topic_weights→ performance and error_category per topicstudy_plans→ topic structure per subject
The snapshot is logged as jsonb inside nova_why_log for auditability. A pre-materialized table is not used because it would create a sync problem and violate the spec's atomic-fetch requirement.
The trigger check (POST /nova/trigger/check) is live today, ahead of the rest of the planned Phase 5 backend — but it landed in a different shape than the endpoint table above implies:
- The actual trigger logic — staleness thresholds, time-left buckets, academic-pressure buckets — is a Postgres function,
public.get_nova_triggers(p_user_id uuid), not Python insidenova_pipeline.py. It reads directly fromnova_config,staleness_tracker,user_subject_exams, and related tables and returns the rows that should fire a reasoning pass. - It's exposed by a small standalone FastAPI service at
backend/app/main.py(backend/app/dependencies.pyhandles auth) — a separate directory fromlib/core/ai/rag_llms/, not a mount insidemain.py's existing app. The route verifies the caller's bearer token against Supabase Auth (get_current_user_id) and then calls the RPC with the resultinguser_id, so a student can only ever trigger-check themselves. - It is not yet wired into the Flutter app — no
lib/features/nova/UI calls it yet. It exists and works, but nothing in the product surfaces it.
staleness_tracker's exclusivity constraint was also widened from a two-way XOR (user_subject_id / career_unit_id) to a three-way one that includes topic_id, since the trigger function needs to reason at topic granularity, not just subject/career granularity.
The redesigned onboarding flow (avatar picker + profile capture) writes everything in a single RPC call, public.save_onboarding_seed_context(...), rather than separate writes per table:
Student completes onboarding questionnaire + profile fields (Flutter)
↓
save_onboarding_seed_context(p_endgame, p_derailer, p_buffer_pref, p_prep_style,
p_career_interests, p_daily_capacity,
p_avatar_data, p_full_name, p_roll_number, p_college,
p_branch, p_dual_branch, p_academic_year,
p_current_semester, p_study_capacity)
↓
UPDATE users — avatar_data, full_name, roll_number, college, branch,
dual_branch, academic_year, current_semester, study_capacity
INSERT standing_flags — endgame / derailer / buffer preference (source = 'onboarding')
INSERT nova_history — prep style (source = 'onboarding')
INSERT career_units — one row per career interest, ON CONFLICT (user_id, name)
WHERE paused_at IS NULL DO NOTHING
UPSERT nova_config — capacity_hour_mapping, scoped to this user_id
All profile-field parameters are optional (DEFAULT NULL) and applied via COALESCE, so re-running onboarding, or a future profile-edit screen calling the same RPC with a partial payload, only overwrites the fields it's given. This is also why the users columns above (avatar_data, dual_branch, current_semester, study_capacity) live next to Nova's conversation-fed tables in this migration rather than in a plain profile-edit endpoint.
A working, dev-only prototype exists today, ahead of the full pipeline described above. It's a local CLI (lib/core/ai/nova/nova_agent.py) that fetches a student's live facts snapshot from Supabase and lets you ask Nova questions about it in a terminal chat loop, answered by Groq (llama-3.3-70b-versatile).
python nova_agent.py <user_id>
What it is: a read-only conversational surface over real data — a way to sanity-check what a "facts snapshot" looks like and how naturally an LLM can talk about it, before the rest of the pipeline is built.
What it is not: it does not implement the trigger layer, the reasoning/ranking pass, structured plan output, or why-log writes described elsewhere in this section. There's no daily plan, no confirmation-gated writes, no audit trail — it's purely "fetch facts, answer a question, forget everything when the process exits."
Since the prototype first shipped, the CLI has picked up:
- Groq key fallback — an optional
GROQ_API_KEY_2is read alongside the primary key; if the primary call raises (expired key, rate limit, overload),chat_service.ask_novaretries once against the backup client before giving up. - Model switching —
ask_novanow takes amodelparameter instead of hardcodingGROQ_MODEL, so the CLI can be pointed at a different Groq model without a code change. - Scoped profile fetch — the facts snapshot now also pulls
full_name,academic_year,branch, andcurrent_semesterfromusers, but only via a.eq("id", user_id).limit(1)filter. The CLI runs on a service-role key that bypasses RLS, so this filter — not RLS — is what stops it from reading another student's profile row.
Known limitations (tracked in #15):
user_idis taken as a raw CLI argument with no auth check against the caller's identity — dev-only, local use, service-role key- No error handling around the Supabase calls — an API failure there still crashes the whole session (Groq calls now have single-retry fallback, above, but that only covers the LLM call)
- The facts snapshot is fetched once at startup and held for the entire session, so it can go stale mid-conversation if underlying data changes
See Tech Debt for the full writeup and fix conditions.
| Table | Purpose |
|---|---|
nova_conversations |
Conversation turn history with Nova |
nova_conversations_archive |
Older conversations moved out for performance |
nova_plan_outputs |
Ranked plan the student sees — subjects, time budgets, one-line reasons per item |
nova_trigger_log |
What fired each reasoning pass and why |
nova_unconfirmed_proposals |
Conversation-proposed changes inert until student confirms |
nova_industry_relevance_log |
Web lookup history for career unit relevance signals |
nova_one_off_overrides |
Today-only overrides, never persisted to schema |
nova_why_logandnova_configwere previously listed here but are already live in Supabase — see Nova Schema Tables above.
backend/
├── app/
│ ├── main.py # POST /nova/trigger/check → calls get_nova_triggers() RPC
│ └── dependencies.py # Supabase client + bearer-token auth (get_current_user_id)
├── pyq/
│ └── batch_upload.py # Dev tool — bulk-uploads a local PYQ directory through the pipeline (see PYQ Batch Upload Pipeline)
├── tests/
│ └── test_rls.py # Cross-student RLS isolation tests (see Running RLS Isolation Tests)
├── download_papers.py # Dev tool — scrapes a DSpace college repository for PYQs to feed into batch_upload.py
└── requirements.txt
backend/app/ is the only piece of this directory actually deployed as a service — a standalone FastAPI app exposing the Nova trigger layer (see Nova Trigger Layer), separate from lib/core/ai/rag_llms/ below and not yet called from Flutter. backend/pyq/, backend/download_papers.py, and backend/tests/ are local dev/maintainer tooling that share the directory but aren't part of that deployment.
lib/
├── core/
│ ├── ai/
│ │ ├── data/ # PYQ PDF files
│ │ ├── rag_llms/ # Python backend — question generation + study plans
│ │ │ ├── main.py # FastAPI app (all endpoints)
│ │ │ ├── pipeline.py # DICL pipeline + study plan extraction
│ │ │ ├── evaluate.py # Pipeline evaluation
│ │ │ └── .env # GROQ_API_KEY, GROQ_API_KEY_2 (optional fallback), SUPABASE_URL, SUPABASE_KEY (not committed)
│ │ └── nova/ # Nova CLI Q&A prototype (dev-only, read-only — see Nova section)
│ │ ├── nova_agent.py # CLI entrypoint — python nova_agent.py <user_id>
│ │ └── nova/
│ │ ├── prompts/ # nova_system_prompt.py
│ │ ├── schemas/ # chat.py, facts_snapshot.py
│ │ └── services/ # clients.py, facts_service.py, chat_service.py
│ ├── config/
│ ├── di/
│ ├── errors/
│ ├── network/
│ ├── routing/ # GoRouter — auth guard, named routes
│ ├── services/
│ │ └── activity_log_service.dart # stub — pending implementation
│ ├── storage/
│ ├── theme/
│ ├── utils/
│ │ └── email_parser.dart # Parses BITS email → roll_number, academic_year, subdomain
│ └── widgets/
│
├── shared/
│ ├── components/
│ ├── extensions/
│ ├── models/
│ │ └── exam_type.dart # ExamType enum — single source of truth
│ └── providers/
│
├── features/
│ ├── auth/
│ ├── onboarding/
│ ├── subjects/
│ ├── analytics/
│ ├── dashboard/
│ ├── colleges/
│ ├── syllabus/
│ ├── pyq_upload/
│ ├── exam_prediction/
│ ├── feed/
│ ├── focus_session/
│ ├── mock_tests/
│ ├── nova/ # planned — Nova conversation + plan display
│ └── profile/ # real, Supabase-backed — see Profile
│
└── main.dart
| Purpose | Package |
|---|---|
| State management | flutter_riverpod |
| Navigation | go_router |
| Immutable models | freezed + freezed_annotation |
| JSON serialization | json_serializable + json_annotation |
| Functional error handling | dartz (Either type) |
| HTTP client | dio |
| Dependency injection | get_it |
| Local storage | hive + shared_preferences |
| Charts | fl_chart |
| Quiz confetti | confetti ^0.8.0 |
| Markdown rendering | flutter_markdown_plus |
| File picker | file_picker ^11.0.2 |
| Onboarding success animation | lottie ^3.5.1 (onboarding_success.lottie) |
| Mock test / quiz success animation | Custom PNG frame-sequence player (assets/animations/tick_frames/) — replaced an earlier Lottie renderer for this specific animation |
| Code generation | build_runner |
| Purpose | Tool |
|---|---|
| PDF text extraction | pdfplumber |
| PDF page OCR (hybrid per-page + whole-doc fallback) | easyocr + pypdfium2 |
.docx / .doc extraction |
python-docx (+ local LibreOffice soffice for legacy .doc) |
| Image / embedded-image OCR | pytesseract, pillow |
| Question extraction | Groq API — LLaMA 3.3 70B |
| Topic + study plan extraction | Groq API — LLaMA 3.3 70B |
| Semantic embeddings | sentence-transformers — all-MiniLM-L6-v2 |
| Diversity selection | MMR algorithm (numpy) |
| MCQ + open question generation | Groq API — LLaMA 3.3 70B |
| Model answer generation | Groq API — LLaMA 3.3 70B |
| Parallel generation | concurrent.futures.ThreadPoolExecutor (3 workers) |
| Backend API | FastAPI + uvicorn |
| Deployment | Railway |
| Data store | Supabase (PostgreSQL + pgvector) |
| File storage | Supabase Storage (handouts bucket) |
| RLS isolation testing | pytest (backend/tests/test_rls.py) |
- Authentication — magic link via BITS college email (Supabase Auth)
- Onboarding — redesigned flow with avatar picker, full profile capture (branch, dual branch, academic year, current semester, study capacity), single seed-context RPC write (see Onboarding Seed Context)
- Real user data via
userProvider - Subjects feature — full Clean Architecture, handout upload, study plan generation
- Routing — GoRouter with auth guard
- Scrollable analytics dashboard
- Dark theme with custom color palette
- Full AI pipeline (DICL + MMR + Supabase)
- FastAPI backend — 8 endpoints, fully Supabase-backed, deployed on Railway
- Nova trigger layer —
get_nova_triggers()SQL function + standalonebackend/appFastAPI service exposingPOST /nova/trigger/check; live and working, not yet called from Flutter (see Nova Trigger Layer) - Mock test platform — full Clean Architecture, 4 exam types, MCQ Blitz + Written Practice
- Exam prediction / question bank browser
- Focus session timer
- Community feed — live from Supabase, vote persistence
- Nova CLI Q&A prototype — dev-only, read-only chat over a live facts snapshot, with Groq key fallback and model switching (see Nova CLI (Q&A Prototype))
- Profile page — real, Supabase-backed, replaces two deleted mock pages; editing goes through re-running onboarding rather than a separate edit form (see Profile)
- PYQ batch upload pipeline — dev tool for bulk-seeding the question bank from a local directory, with
.docx/.doc/image extraction, hybrid per-page OCR, content-based exam-type detection, and resumable manifests (see PYQ Batch Upload Pipeline) - RLS isolation test suite — automated cross-student data-isolation checks over five user-scoped tables (
backend/tests/test_rls.py)
test_attempts,question_results,ai_evaluations— test attempt flowuploaded_pdfs— PDF upload tracking- All Nova tables listed in Nova Tables Still To Build
- Streak and coins on profile
- PYQ upload UI
- Friends on profile
- Onboarding subject selection step (UI exists, not wired to DB)
- Study plan display UI (plan is generated and persisted, no page to show it yet)
- Study plan display page per subject
- Syllabus progress tracking
- PYQ upload through the app UI
- Nova conversation UI + daily plan display
- Personal learning goal mode
- Focus session history and streak tracking
- Compre Part A published to feed
All dashboard data lives in assets/data/analytics.json. To change what appears on screen, edit this file and hot-restart the app.
assets/data/analytics.json
↓
AnalyticsLocalDataSourceImpl
↓
AnalyticsDataDto.fromJson()
↓
dto.toDomain()
↓
AnalyticsRepositoryImpl
↓
GetAnalyticsUseCase
↓
DashboardNotifier
↓
dashboardProvider
↓
DashboardPage → charts and tiles
flutter pub get
dart run build_runner build --delete-conflicting-outputs
flutter run| File suffix | Generated by | Purpose |
|---|---|---|
.freezed.dart |
freezed |
Immutable value classes |
.g.dart |
json_serializable |
fromJson / toJson methods |
Run build_runner after adding or changing any @freezed class or @JsonSerializable DTO.
dart run build_runner build --delete-conflicting-outputs
dart run build_runner watch --delete-conflicting-outputscd lib/core/ai/rag_llms
python -m venv myenv311
myenv311\Scripts\activate
pip install fastapi uvicorn pdfplumber sentence-transformers numpy groq python-dotenv supabase requests
uvicorn main:app --reload --port 8000Interactive API docs at http://localhost:8000/docs.
cd lib/core/ai/nova
python nova_agent.py <user_id>Reads SUPABASE_URL, SUPABASE_KEY, and GROQ_API_KEY from lib/core/ai/rag_llms/.env. Service-role key, local dev only — see Nova CLI (Q&A Prototype) and Tech Debt before using this with real student data. An optional GROQ_API_KEY_2 in the same .env is picked up automatically as a one-retry fallback if the primary key fails.
This is a separate service from the two above — see Nova Trigger Layer.
cd backend
python -m venv venv
venv\Scripts\activate
pip install -r requirements.txt
uvicorn app.main:app --reload --port 8001Reads SUPABASE_URL and SUPABASE_KEY from the environment (app/dependencies.py). Requests to POST /nova/trigger/check need a valid Supabase Auth bearer token in the Authorization header — there's no service-role bypass here.
backend/tests/test_rls.py verifies students can't read each other's rows across user_topic_weights, nova_capacity_log, standing_flags, situation_flags, and nova_config, and that a fully unauthenticated client gets 0 rows back too. Needs two real Supabase Auth accounts to sign in as — it isn't run against a mocked backend.
cd backend
pip install pytest
pytest tests/test_rls.pyNot currently run in CI — backend/** isn't in ci.yml's python path filter, and there's no pytest step in the workflow regardless. Must be run manually.
TEST_STUDENT_A_EMAIL/TEST_STUDENT_B_EMAIL need to already exist as real Supabase Auth accounts — nothing in this repo provisions them. Create both manually via Supabase Auth (or the dashboard) before running.
Reads SUPABASE_URL, SUPABASE_KEY, and TEST_STUDENT_A_EMAIL / TEST_STUDENT_A_PASSWORD / TEST_STUDENT_B_EMAIL / TEST_STUDENT_B_PASSWORD from the environment (.env, loaded via python-dotenv). If SUPABASE_URL / SUPABASE_KEY are unset the whole module is skipped; if the two test accounts can't sign in, the cross-student tests skip individually rather than failing. pytest itself isn't in backend/requirements.txt yet — install it separately.
- Create folder structure under
lib/features/your_feature/ - Define domain entities in
domain/entities/using@freezed - Define repository interface in
domain/repositories/ - Write use cases in
domain/usecases/ - Create the DTO in
data/dtos/using@JsonSerializable - Implement the datasource in
data/datasources/ - Implement the repository in
data/repository_impl/ - Create a Riverpod
AsyncNotifierProviderinpresentation/providers/ - Build the page and widgets in
presentation/pages/andpresentation/widgets/ - Add a route in
core/routing/ - Run
dart run build_runner build --delete-conflicting-outputs
/generate-batch does not save to published_tests. loadExistingTest has no MCQ Blitz path. Feed Attempt button always routes to written practice regardless of examType.
When to fix: After Phase 5 ships.
study_plans rows are generated and persisted. No Flutter page exists to display them yet.
When to fix: Phase 5 — foundation for the daily question scheduler.
Currently calls a Supabase Edge Function placeholder. Needs to point to the deployed Railway URL.
When to fix: Before study plan display UI is built.
academic_year exists in questions and UserModel but is not used as a filter in Flutter or the pipeline.
When to fix: Before Phase 5 — daily question plan needs year scoping.
MMR currently runs over the entire question bank. Correct DICL first retrieves top-15 by cosine similarity, then runs MMR over those 15.
When to fix: Phase 6. Needs 100+ questions per subject to be measurable.
Subject selection step UI exists but does not fetch real subjects from the subjects table.
When to fix: Before real user onboarding goes live.
- Duplicate controller file in
widgets/vscontrollers/— delete widgets copy resume()is a no-opFocusTimerStatus.pauseddefined but never set- No session persistence — add
StorageService.saveSession()when Phase 5 streak tracking lands
Plain uuid[] array — deleting a questions row silently leaves a dangling ID.
When to fix: Before any question-deletion or moderation tooling is built.
RLS is enabled on topics with read-only access for authenticated users. Write policy (for pipeline inserts) is pending confirmation of whether the FastAPI backend connects via service-role or anon key.
When to fix: Before wiring the pipeline to write topic_id on question extraction.
questions, question_results, uploaded_pdfs, user_topic_weights, and staleness_tracker all have both a legacy topic text column and a new topic_id FK to the topics table. Old code writes to topic text. New code should write both. Text columns will be dropped once the pipeline is fully migrated.
POST /nova/trigger/check shipped early as a standalone FastAPI service (backend/app/main.py, deployed separately from lib/core/ai/rag_llms/), ahead of the nova_router.py design described in Nova Trigger Layer. It isn't decided whether the rest of the planned /nova/* endpoints get added to this service, get mounted into nova_router.py and the trigger-check route gets migrated to match, or the two stay permanently separate. It also isn't yet called from Flutter.
When to fix: Before Phase 5 Nova backend work starts, so the rest of nova_router.py isn't built against an architecture that gets reconciled later.
CONTRIBUTING.md's architecture guidelines mandate fpdart for Either-based error handling and explicitly say not to introduce dartz. The app actually depends on dartz (see Tech Stack above) and that's what the domain/data layers use throughout. Either the contributing doc needs correcting to say dartz, or a migration to fpdart needs to happen — right now new contributors following CONTRIBUTING.md literally would import a package the codebase doesn't use.
When to fix: Low priority functionally (both are equivalent Either implementations), but worth resolving before it causes an inconsistent PR.
When to fix: When the pipeline is updated to write topic_id on extraction.
lib/features/profile/domain/ and lib/features/profile/data/ (entities, repository interface, usecases, DTO, datasource, repository impl — ~10 files) exist but are entirely unused. profile_page.dart only calls userProvider and profileDetailsProvider, both of which query Supabase directly from the presentation layer, bypassing the scaffold. profile_datasource.dart is empty (// Feature skeleton - Data layer). Violates this project's stated Clean Architecture rule (see Architecture section).
When to fix: Tracked in #TODO — issue number pending. Either delete the unused layers or wire profileDetailsProvider through them properly.
nova_agent.py currently trusts any user_id passed via argv with no check against the caller's identity — it runs against a service-role key locally, so anyone running it could pull any student's exam/weakness/career data just by changing the ID. API failures (Groq overloaded, Supabase down) crash the whole CLI and dump a raw stack trace, killing the session and losing all conversation history built up so far. The facts snapshot is also fetched once at CLI startup and held for the whole session — if underlying data changes mid-session (new test score, capacity update), Nova keeps answering from the stale startup snapshot. Fine for local dev/testing; tracked in #15.
When to fix: Auth before this touches any real user's data; error handling before it's used for real QA testing; snapshot staleness before this pattern carries over into the real triggered Nova pipeline (Phase 5).
extract_raw_text_any (.docx/.doc/image support) and content-based exam_type detection only run through the internal backend/pyq/batch_upload.py script, which calls run_upload_pyq directly. The public POST /upload-pyq endpoint still hard-rejects any filename not ending in .pdf, so students uploading through the app get none of this.
When to fix: Before PYQ upload through the app UI ships (see Planned).
Points at a hardcoded local-network BASE_URL (a DSpace repository reachable only from campus/VPN). Fine for a one-off scraping run on a maintainer's machine; would need to become configurable (env var or CLI flag) before anyone else could use it as-is.
When to fix: Low priority — dev-only tool, not part of the deployed backend.
Phase 1 — Foundation (complete)
Project scaffold and architecture
Theme system
Analytics dashboard with charts
Dev navigation menu
Phase 2 — AI Pipeline (complete)
PDF parsing and question extraction
Semantic embeddings with sentence-transformers
MMR-based diverse example selection (DICL)
LLM MCQ + open question generation
Exam type filtering
Thread-safe parallel generation
FastAPI backend with 8 endpoints
Supabase integration
Railway deployment
Phase 3 — Core Features (complete)
Mock test platform — full Clean Architecture
Exam prediction / question bank browser
Community feed — live from Supabase
Focus session timer
Auto-save written practice tests to published_tests
Phase 4 — Auth and Backend (complete)
✅ Magic link auth via BITS college email
✅ Onboarding → Supabase write
✅ Real userProvider
✅ Subjects feature — full Clean Architecture
✅ Handout upload + study plan generation
✅ GoRouter migration
✅ RLS policies
✅ Vote state persistence
✅ Subject retrieval corrected end-to-end
✅ test_attempts composite uniqueness fixed
✅ Nova schema foundations — user_subject_exams, nova_capacity_log,
staleness_tracker, topics, standing_flags, situation_flags,
nova_history, career_units, nova_why_log, nova_config,
topic_id and career_unit_id on all affected tables
✅ Nova CLI Q&A prototype — dev-only, read-only (see Tech Debt)
✅ Onboarding redesign — avatar picker, full profile fields, single
save_onboarding_seed_context RPC (see Onboarding Seed Context)
✅ CI hardening — dependabot, gitleaks, ruff security lint, pinned
deps, path-filtered jobs, pr-title optional scope
✅ Real profile page — replaces two deleted mock pages, reads live
userProvider + onboarding-sourced Nova tables (see Profile)
✅ RLS isolation test suite — cross-student checks over 5 user-scoped
tables (backend/tests/test_rls.py)
✅ PYQ batch upload pipeline — docx/doc/image extraction, hybrid
per-page OCR, content-based exam_type detection, resumable
manifests (dev tool only — see PYQ Batch Upload Pipeline)
⬜ _triggerPlanExtraction wired to real FastAPI URL
⬜ Study plan display UI per subject
⬜ Compre Part A → published_tests pipeline + feed (deferred to post-Phase 5)
Phase 5 — Personalisation
Study plan display page per subject
Wire test_attempts / question_results / ai_evaluations into the app
academic_year filter across pipeline + exam prediction
Nova schema (remaining)
nova_conversations
nova_conversations_archive
nova_plan_outputs
nova_trigger_log
nova_unconfirmed_proposals
nova_industry_relevance_log
nova_one_off_overrides
(nova_why_log, nova_config, and the trigger-layer patches to
standing_flags/situation_flags/nova_history/staleness_tracker
already live — see Nova Schema Tables and Nova Trigger Layer)
Nova backend
✅ get_nova_triggers() SQL function + backend/app/main.py
exposing POST /nova/trigger/check (early, ahead of schedule —
see Nova Trigger Layer) — not yet wired into a mounted router
or called from Flutter
nova_pipeline.py — facts fetch, reasoning pass, minor rerank,
flag writes, conversation extraction, why-log writes
nova_router.py — remaining Nova endpoints (trigger/check excluded,
already shipped separately in backend/app)
Decide whether backend/app gets folded into nova_router.py or
stays a standalone service — currently unreconciled
pg_cron nightly retention job driven by nova_config
Harden Nova CLI prototype into the real pipeline — auth, error
handling, and live (non-stale) facts fetch (closes #15)
Nova Flutter
Decouple handout plan trigger from Nova plan trigger
capacity_today tap → POST /nova/student/capacity
Daily plan screen → reads from nova_plan_log
Nova conversation UI (chatbot-style)
Trigger check on app open, after test submission, after flag confirmation
Personal learning goal mode
Lives and streak system
Coin economy
Daily college-wide brain puzzle
College leaderboard
Dashboard integration with mock test scores
Focus session history and streak tracking
Phase 6 — ML Extension
Full DICL: top-15 cosine retrieval → MMR over those 15 → pick 5
Fine-tune FLAN-T5 on generated question-answer pairs
Experiment comparing MMR vs random vs top-k selection
Distractor quality analysis for MCQ options
Built by Krishna — BITS Pilani Hyderabad, B.Tech CSE 2024–2028