ESP32-S3 Bench Guide
ESP32‑S3 N16R8 벤치 가이드: 실시간 핀아웃, 강의, 시뮬레이터 및 벤치 로그
메이커가 쓴 소개는 위의 한 줄이 전부입니다. 더 자세한 내용은 저장소 README에서 읽을 수 있습니다. 저장소 README ↗
README 전체를 불러오는 중입니다.
README 전문 보기 ↗An Android app where tutors run tuitions (CBSE, Classes 6–12) and their students take timed multiple-choice tests on their phones. The app marks them, and the tutors see every result. Each tutor has a tuition of their own, and a student can learn from several tutors. A tutor creates each student's login (or lets a student in who asks with the tuition's join code) and makes a test in one of two ways: upload a school question paper (photos or a PDF) that AI types out for them to check, or describe the test to AI and let it write the questions, as many as they ask for.
| Folder | What it is |
|---|---|
app/ | The Flutter app (Android). Students and the teacher use the same app. |
admin/ | The developer's admin app (Android), "CA Admin": who uses the app, on which phones, and how the server is doing. |
api/ | The API: Hono on Neon Functions, with Postgres and object storage on Neon. The schema and the marking functions are in api/migrations/. |
design/ | Soft Structuralism comps (design/comps/), their screenshots and the token CSS. |
tools/ | Scripts to migrate, seed, test, audit, and drive the test phone. |
The moment a student hands a test in, or the time runs out and the app hands it in for them, they see their marks and every right answer. Nothing waits for the closing time and the tutor has nothing to switch on. Every test still has a closing time: a tutor who posts one without gets the next 9 pm India time that is at least three hours away, and one who picks a time already past is told so. The right answers stay on the server until that student has submitted that test.
Until 1.7.0 marks were held back until the test closed, everyone had finished, or the tutor
showed them early (tests.results_released_at, migration 011). That is gone: the column stays in
the database, unused, and the server no longer has the "release marks" route. Replies still carry
results_open: true because the apps of 1.4.0 to 1.6.0 read it, and an old app therefore shows the
marks at once too, with no update.
There are three tabs: Home, Courses and Groups. Home lists every group that has something running: its live tests (open to students now) and posted tests (published, opening later), with how many students have submitted and how many are writing at this moment. Groups with nothing running are listed underneath in one short list. Groups lists them all. Once a course exists, Home has two sections, Groups and Courses, built the same way; with no courses it is as described. Courses lists the tuition's courses (see Courses below).
A group (a class and a subject, "10th Maths") has one page: a Make a test button, its tests (live, posted, drafts, finished) and its students with an Add student button. Class and subject come from the group, so nothing asks for them again. Make a test offers upload or describe it to AI. The app has no page of questions: a wrong answer is fixed from the test's own page, which marks everyone's attempt again. Add student suggests a username from the name and numbers a repeat, so a second Harini Venkatesh gets harini.v.2 without anyone thinking of one (the server decides, so the app of 1.3 and before gets it too).
A test's page leads with how it went and who has written it, then the questions (folded to two with a link to show the rest) and its settings (time limit, opens, closes, order). Settings (the avatar on Home) holds the tuition's name, the tutors, the subjects, the theme and the password. Several tutors can share one tuition: they all see every group and test, and any tutor can add another or turn one off (not themselves) in Settings.
A tuition is a tutor's business: its own students, groups, subjects, papers, questions and tests.
A person (tutor or student) has one login and belongs to tuitions through memberships, so a tutor
can run a tuition of their own and a student can learn from more than one tutor. Nothing in one
tuition can be read or changed from another: every tutor route reads and writes only the tuition
the request is for, and tools/tenant-test.ts checks that by trying every route with another
tuition's ids.
x-tuition header.
Without it the server uses the person's first tuition, which is how the apps from before tuitions
keep working. A person who names a tuition they are not an active member of gets 403.POST /auth/signup-tutor, capped per phone and per hour),
then create their tuition with the classes and subjects they teach (POST /auth/tuitions). The
first run in the app does both. A tutor can run up to three tuitions.POST /student/join), waits as "pending", and the
tutor lets them in from Home with the class and subjects they choose (/teacher/join-requests).
Wrong codes are counted, so a code cannot be guessed. A tutor can still add a student directly,
as before, and the owner can close joining or make a new code.ai_usage.tuition_id), and the evening summary goes to each
tuition's own tutors.tuition_id column to it, so the API from before this change keeps working
against the new schema (its 140 checks pass), and the apps of 1.3.1 and before see no difference.Going live with tuitions takes three steps, in this order, and each needs the owner's go-ahead:
main.
The API running at that moment is the old one, and it keeps working.main, and check / and a login.tuition_id defaults.The tuition teaches more than one subject. Each student studies one or more subjects, and a group is a class and a subject together: "10th Science" is every Class 10 student who studies Science. Chapters, questions, papers and tests each belong to a subject, and a test goes to its group or to chosen students in it. A test never leaves its group: an older "whole class" test also reaches only the class's students who take its subject. Maths and Science are built in, and more subjects can be added in Settings or straight from a paper's subject choice. Students added by the 1.0.0 app, and everything made before subjects existed, count as Maths.
A course is the other kind of group: it is not worked out from a class and a subject. A tutor makes
one, names it, and puts in the people they choose, from any class or with no class at all. A person
can be in any number of groups and any number of courses; adding someone to a course never takes
them out of a group, and a person made through a course (a student with no class, no subjects) is in
no group. A course page works like a group page: Make a test, its tests (live, posted, drafts,
finished) and its people, with a page for each person. Every tutor of the tuition sees every course.
A course can be renamed from its own page (Rename course, PATCH /teacher/courses/:id, trimmed,
up to 60 characters, 409 when another course of the tuition has the name); its tests, people and
results are not touched, since they hang on the course's id and not on its name. Apps before 1.7.0
have no Rename button.
Everything that belongs to a course (tests, papers, questions) says so in course_id and has no
class or subject (migration 019), and no group query returns it, so nothing in a course ever shows
in a group or the other way round. A student sees a Courses list only while they are in at least one
course; a student who is only in courses has no class and opens straight on it. Deleting a course
deletes its tests, questions, papers and results, and never its people: it refuses with 409
has_attempts while someone has written a test in it, unless the call says ?with_results=1.
Old apps cannot show any of this. An app that does not say it is 1.6.0 or newer in X-App is never
sent a course or a person with no class, and a student with no class who signs in from one is told
to update (426 update_required). The server refuses every main-app build below
app_settings.min_app_build the same way, and a new app answers a 426 with a full-screen "Please
update" page. That row does not exist until someone writes it (tools/min-build.ts), so releasing
the app forces nothing by itself. The admin app, signing out, and a caller that sends no X-App
are never held to it.
Correcting a question's right option, or its marks, marks again every test already written with it, so a student who chose the right answer gets the mark even when the key was wrong at the time. Questions, tests, papers and students can all be deleted. A question leaves the tests nobody has written; one in a test students have written stays, so their results stay whole. A written test, or a student, goes only after a second warning, and takes those results with it. Deleting a paper also deletes the questions saved from it that no test uses.
The app no longer offers these: the tutor said they are not needed (2026-10-09). The questions and their draft chapter tests stay in the database and the tools stay here, but no screen lists them, the group page leaves the draft chapter tests out, and the test editor's question picker skips them.
tools/library/ holds original chapter-wise MCQs, with worked solutions, written for the
NCERT 2026-27 books (nothing is copied from a paper or a book): ten per chapter for Class 7
Maths (Ganita Prakash, 15 chapters), Class 8 Maths (Ganita Prakash, 14), Class 9 Maths
(Ganita Manjari, 14) and Science (Exploration, 13), and Class 10 Maths (14) and Science (13).
That is 830 questions. Every answer is proved before it can be imported:
node tools/check-library.ts
node tools/seed-library.ts --env .env.local
The import adds each book's chapters, its questions (tagged Ready-made in the app) and one
draft chapter test per chapter for the class's group. It is safe to run again: rows are
matched by library_key, and a chapter test that has been published or written is left alone.
The teacher adds the pages first: photos, gallery images or a PDF. AI then works out the paper's name, class, subject, where it comes from (its category, such as "NCERT Exemplar") and, for a one-chapter paper, the chapter. It asks the teacher only for what it could not find. Every page is read into drafts, together with any answer key the paper prints, on whichever page it is.
Answers come from that key first. For the rest, models of two different families each work the
question out on their own, without seeing each other's answer, and each lists every option it
finds correct, not just one (solve in api/src/routes/papers.ts). A model made to pick one
answer picks one for a faulty question as confidently as for a sound one; asked for the whole
list, it shows a question with two right options, or none. The solver is gpt-oss-120b on Groq
(GROQ_SOLVE_MODELS; gpt-oss-20b stands in when the 120b has used its day's allowance) and the
checker is Gemini 3.1 Flash-Lite (GROQ_CHECK_MODELS; Gemini 3.5 Flash-Lite, then Qwen, stand in
when it is busy or over its free limit).
verdict in api/src/lib/votes.ts says what their answers add up to (node tools/votes-test.ts).
An answer is marked only when at least two models were heard, each found exactly one right option
and was at least 80% sure, they all chose the same one, and the models that chose it come from at
least two families (gpt-oss, Gemini and Qwen are three: two gpt-oss models that agree prove little,
because they make the same mistakes). A model that found two right options, none, or hesitated
blocks the mark, because the question is at fault. A question the first two split on, or that only
one of them could be heard on, keeps what was said and waits for a stronger Gemini model
(GROQ_TIEBREAK_MODELS, the second opinion: a step of its own that runs once the tutor already has
the paper) and is marked when two of the three agree. When one check cannot be reached, the other's
answer is kept, not thrown away. When neither can (both down, twice), the question says "Not checked
yet" and starts again from scratch three minutes later: by itself while the paper stays open on the
tutor's screen (up to five times), and whenever the paper is opened. Questions that need a figure
are left to the teacher.
Why these models: 165 questions an AI had written (140 sound, 25 faulty, each judged by hand) went
through the candidates. gpt-oss-120b was right on 32 of 33 sound questions outside Hindi and Tamil
and on 8 of 12 in them. Gemini Flash-Lite was right on 138 of 140, Hindi and Tamil included, but let
12 of the 25 faulty ones through. The two together blocked 24 of the 25 faulty questions and marked
none of the sound ones wrongly (87% of the sound ones were marked; the rest went to the writer or
the tutor), where two gpt-oss models together blocked 16 and let 6 to 9 through. Gemini Flash-Lite
answers one call in three with "high demand", or takes 30 to 60 seconds, from the evening on; the
check that is down then costs the question a later look, never a wrong answer. On 27 October six
papers of 15 questions (trigonometry, motion and force, English grammar, Hindi grammar, Tamil
grammar, Indian geography and the Constitution) went through the real pipeline on the dev copy
(tools/out/ai-maker-run.mjs, not in git): 80 of the 90 were marked before any replacing (all 45 in
maths, science and English; 11 of 15 in Hindi and in Tamil; 13 of 15 in geography) and all 90 after
14 replacements, in 50 to 350 seconds a paper. A hand check of the 90 marks found none wrong and
four that a teacher could argue over (grammar and geography points). The scripted test
tools/answers-test.ts (45 checks, no AI keys needed) makes each failure happen on cue: a model that
is down, slow, or answers with junk, two that split, the writer disagreeing, the second opinion.
Each marked answer says where it came from, so the teacher can check it before saving. Once every question is saved or skipped, the app asks whether to publish the paper as a test for its class and subject straight away; the test remembers the paper it came from.
A card also says what AI noticed: part of the question could not be read on the page (some PDFs print every subscript as a box, ■), or neither model found an option that fits, which usually means a misprint. AI keeps a ■ where nothing in the question forces what it stood for (one box is one character, so "t■■■" was three, such as "n+1"), and fills in the ones that are forced. It does not answer a question it had to fill in or leave a box in: an answer built on a guess would be a guess. The teacher checks the question and taps "Looks right, find the answer" (or rewords it), and then AI looks for the answer. A question that repeats an earlier one of the same paper (a PDF holding its pages twice) is skipped, and saving leaves out any question the bank already has. Skipped questions can be brought back.
Pages are read by Google's Gemini 3.5 Flash-Lite on its free tier (GEMINI_API_KEY), about 4
seconds a page. Groq's vision model reads instead whenever Gemini is busy or refuses a page,
and Groq always works out the answers. Without a Gemini key, Groq reads too. Groq limits each
account, not each key, so keys made in one account share one budget: about 1,000 requests a
day per model, and 7,000 input tokens a minute for the vision model, which is only two or
three pages a minute. The app waits out a limit on its own and shows how long it is waiting.
Using several Groq accounts to get past the limits is against Groq's rules.
Gemini's free plan limits each model separately (measured 2026-10-06 from its error replies): 3.5 Flash-Lite 15 requests a minute, 3.5 Flash 20 requests a day, 3.6 Flash 5 requests a minute; the newest models often answer "high demand" instead. That is why the app reads four pages at a time (a dozen pages is 13 calls, inside the 15 a minute) and checks answers with 3.1 Flash-Lite.
The app runs the work side by side. Pages are read four at a time, and as soon as the first
page is read, three workers start looking for answers, each taking its own batch of questions
(the server hands every call a different batch: answer_claimed_at), while the later pages are
still being read. A printed answer key on a late page replaces any answer AI worked out in the
meantime. A 12-page worksheet took 2 minutes 45 seconds one step at a time and takes about 30
seconds this way (tools/out/try-parallel.mjs replays the app's run against the local API).
In a group, Make a test, Describe it to AI takes a few words ("easy and medium questions on
quadratic equations, with two word problems") and how many questions: any number up to a
thousand, where the top only guards against a slip of the finger. The server asks Gemini 3.1
Flash-Lite, with gpt-oss-120b behind it (GROQ_WRITE_MODELS), for the questions as JSON: four
options each and the writer's answer. The result opens in the same review screen as an uploaded
paper. The writer's answer is only a proposal, and the checking models never see it. A question is
marked when two models of different families agree (see Question papers), and the writer's answer
is one more opinion: a mark that differs from it is kept but asks the tutor to check, and a
question whose writer's answer could not be matched to one of its options is never marked. On a
paper AI wrote, a split between the first two models is final, since a new question costs less than
a stronger model's time: the question is replaced (below).
The writer is told to work each answer out in a work line before it writes the options, to build
the three wrong options from plausible mistakes, to give every number a calculation needs, never to
write two options of the same value, and to ask only what a textbook settles with one accepted
answer. Each writer model is tried on its own, so one that replies with nothing usable is followed
by the next. By hand, 25 of 165 questions from the first prompt were faulty (15%) and 6 of 90 from
this one (7%), on topics of the same kind (the 6 were a tense question with two acceptable answers,
a loosely worded constitution question, a question whose options showed raw maths markup, a badly
worded rolled-paper problem and two Tamil grammar questions).
One reply holds about 25 questions, so the app asks for twenty at a time. The first call makes
the paper. Each later call sends its paper_id, is told which questions are already written and
adds new ones, so a test can be any length: 47 questions took three calls and 19 seconds, with
no repeats. The app shows how far it has got and waits out the free tier's per-minute limit. If
the allowance runs out part way, it keeps what was written and says how many.
A question the checks cannot settle is replaced, not handed to the tutor. After the answers and the
second opinions, the app asks the server to replace each question of the paper that has no answer,
or an answer other than the one the writer meant (POST /teacher/papers/:id/replace). The question
is left out (it shows under Skipped as Replaced, with the reason, and can be brought back), and the
writer is asked for a new one on the same request, which takes its number and goes through the same
checks. What is still unsettled after that is replaced again, up to three rounds in one run; the
paper keeps trying when it is opened again. A question the models could not be reached for is not
replaced: it is asked again (see Question papers). A question the tutor has marked, reworded or brought
back is hers and is never replaced, and neither is anything in an uploaded paper. The server keeps
the request the paper was written from (papers.ai_request, migration 021), says whether more can
be replaced in replaceable on GET /teacher/papers/:id, and stops at 20 replacements or the
paper's length, whichever is larger. An app that finds no replaceable leaves the paper alone. A
question is replaced one for one, so the paper keeps its size (a server test checks it, and a run on
the dev copy that asked for 10 questions and made 2 of them unanswerable ended with 10, 2 replaced),
and when the replacing is over the review says how many were replaced and how many questions the
paper still has, because the replaced ones stay listed under Skipped.
Measured earlier on 27 October on the dev copy with the real models and the first version of the
checks (tools/out/ai-maker-run.mjs, not in git), three papers of 20 questions each, on linear equations, on ratio and profit and loss word problems, and
on number systems: 17, 18 and 19 questions were clean after the checks, and all three papers ended
with 20 of 20 clean, after 4, 4 and 1 replacements, in 39, 47 and 27 seconds. The second paper
used all three rounds.
Models write LaTeX in JSON with one backslash (\frac), which JSON reads as a form feed plus
"rac". Every model reply passes through fixLatexEscapes first (api/src/lib/latex-json.ts,
checked by node tools/latex-json-test.mjs), and an option that is all maths but came without
$...$ gets them.
The screens are English; the questions are in whatever language the tutor works in. A paper
printed in Hindi or Tamil is read, its answer key matched ((क)(ख)(ग)(घ), (अ)(ब)(स)(द), (அ)(ஆ)(இ)(ஈ),
Hindi and Tamil digits), and its answers worked out like an English one, and a test described in
Hindi or Tamil, or with "in Tamil" in the request, is written in that language. The prompts say so,
and three things that would have broken on non-English text are fixed (api/src/lib/text.ts,
checked by npm run test:text): the repeat detector kept only a to z and 0 to 9, which made every
Hindi or Tamil question without digits a repeat of the first one and skipped it, the printed
answer was matched by English labels only, and an option that looked like bare maths was wrapped
in $...$ even when it held Hindi or Tamil words. A name typed in Tamil or Hindi gets its username
from an English spelling of the name, which AI writes.
Measured on 16 verified Class 10 Maths questions (plain text), translated to Hindi and Tamil by
Gemini, printed as four-page papers and sent through the real pipeline (tools/out/lang/, not in
git): details found on every paper (class 10, Maths, a name), all 16 questions and options read on
every paper, and the printed key matched 16 of 16 in all three languages. Without a key AI marked
16 of 16 right in English, Hindi and Tamil on 27 October, after the answer checks were reworked
(on 10 October it was 16, 15 and 15, the other question left for the tutor), with none wrong. Tamil letters that look alike (ண and ன) were misread once in
four Tamil runs, so a tutor still checks the questions, as with English.
Every change made in the app tells the screens showing that data to load again, and a tab loads again when it comes back into view or the app returns to the front. Home, a test's results and the student's home also check every 30 to 60 seconds while on screen, and stop after 15 minutes without a touch, so a phone left open does not keep the database awake.
From 1.2.0 on, the app updates itself. When it starts, and when it comes back to the front
(at most every two hours), it reads update.json from the latest GitHub release. If that
names a higher build number than the one installed, Home shows "Version 1.2.1 is ready".
Update downloads the APK, checks its SHA-256 against the feed and hands it to Android's
package installer. The first time, Android asks once whether Core Academy may install apps.
After that, Android 12 and later put the update in without the Install prompt; older Android
shows it. Android closes the app while it replaces it, and a notification offers to open the
new version. Settings (and a student's profile) show the version and can check at once.
Versions 1.1.0 and earlier have no updater, so 1.2.0 has to be installed by hand once.
Debug builds never offer an update unless they are built with
--dart-define=UPDATE_FEED=....
admin/ is a separate Android app for the developer, "CA Admin" (app id com.coreacademy.admin).
It takes only a developer login, and reads either the live database or dev. It shows:
api/src/lib/pricing.ts, from the tokens each call used). The free plans mean nothing is billed,
so this is what the work is worth, not a bill. Students are only ever counted there, never named.
Requests are counted per client from migration 017 on (api_daily_tuition).It changes only two things: it can sign an account out of one phone, and unlock a locked login. Only signed-in requests and logins are counted, since they use the database anyway; the notification trigger and anonymous traffic never wake it.
It looks the same as the main app because it uses the main app's own theme and components.
admin/lib/theme.dart, ui/tokens.dart, ui/kit.dart and ui/motion.dart are copies: change the
originals in app/lib, then copy them over again with node tools/sync-admin-ui.mjs. The icons come from
tools/gen-icons.mjs, which writes both apps' sets.
The developer login is made per branch, with the password typed at a hidden prompt:
node tools/create-developer.ts --env .env.main --username <name>
The admin app updates itself like the main app, but privately: a release goes into the branch's
storage, and only the developer login can download it. The first install is by hand (the APK is
copied to tools/out).
.\tools\release-admin.ps1 -Notes notes.txt
A release build reads only the live API, the one the students use; there is nothing to switch.
A test build (flutter run in admin/) reads dev. To run it against an API on the PC:
adb reverse tcp:8787 tcp:8787, then flutter run --dart-define=API_BASE=http://127.0.0.1:8787.
Push notifications go through Firebase Cloud Messaging. Each student is told once about each
test, the moment it is open for them: when it is published, or later if they join its group or
are added to it (test_notices records who was told). They get a reminder an hour before it
closes if they have not started it
(only for tests that were open two hours or more; for a shorter one, the first notification
already gave the closing time). At 8 pm India time the teacher gets a summary of the day, if
anything happened.
The app registers the phone after logging in and on every start. The registration belongs to
that login, so logging out, or a PIN reset, stops the notifications. Android shows a
notification only while the app is in the background. One that arrives with the app open makes
the screens load again instead, so the new test is on the student's home screen at once.
Publishing an open test
announces it straight away. Everything else comes from /cron/notify, which a Neon trigger
calls every five minutes. That run reads the time of the next
notification from notify/next.json in the bucket and only queries the database when it is
due. If it queried every time, the 0.25 CU compute would never scale to zero: 186
compute-hours a month, and the free plan has 100.
To set it up, once:
In the Firebase console, create a project and add two Android apps:
com.coreacademy.core_academy and com.coreacademy.core_academy.dev. Save the
google-services.json you get after the second as app/android/app/google-services.json.
Builds made without that file work normally, with notifications off.
In Project settings, under Service accounts, generate a private key. Save it as
api/firebase-service-account.json and upload it to the function:
powershell -ExecutionPolicy Bypass -File tools/upload-firebase-key.ps1 -Branch dev
Create the trigger on that branch. The cron is in UTC; every five minutes lines up with India's :00 and :30.
neon triggers create --branch dev --function-slug api --name notify --cron '*/5 * * * *' --function-path /cron/notify
Until the key is on a branch, the API there reports "push": false at / and sends nothing.
The trigger is not declared in neon.ts: neon dev refuses to run against a branch that is
missing a declared trigger, and neon deploy leaves triggers made with the CLI alone.
You need Node 24, Flutter 3.47, the Neon CLI (npm i -g neon, then neon login) and an
Android phone with USB debugging on.
1. Install and link to the dev branch
npm install
neon link --project-id tiny-cell-87785722 --branch dev -y
neon env pull
neon env pull writes the branch's database and storage settings to .env.local.
2. Database (first time, or after a schema change)
npm run migrate
npm run seed
The seed adds 8 sample students, 42 sample questions and sample tests. Their logins and the
teacher's go to tools/out/sample-logins.md. That folder is git-ignored, so logins never
reach the repo.
3. The API on your PC
Put the AI keys in api/.env.secrets (git-ignored): the Groq keys as
GROQ_API_KEYS=key1,key2 and the Gemini key as GEMINI_API_KEY=key. Then, in PowerShell:
$env:GROQ_API_KEYS = ((Get-Content api\.env.secrets) -match '^GROQ_API_KEYS=')[0].Substring(14)
$env:GEMINI_API_KEY = ((Get-Content api\.env.secrets) -match '^GEMINI_API_KEY=')[0].Substring(15)
neon dev --source ./api/src/index.ts --port 8787
tools/upload-groq-keys.ps1 -Branch dev puts both keys on a deployed branch.
4. The app on the phone
adb reverse tcp:8787 tcp:8787
cd app
flutter run --dart-define=API_BASE=http://127.0.0.1:8787
This is the test build, "Core Academy Dev" (app id com.coreacademy.core_academy.dev), so it
installs beside the live app. Leave out --dart-define and it talks to the deployed dev API
instead, so it works
without your PC.
npm run deploy
This deploys the API function on the linked branch. The Groq keys are set on the function
once per branch, outside neon.ts, so a deploy never carries them:
powershell -ExecutionPolicy Bypass -File tools/upload-groq-keys.ps1 -Branch dev
Until that has run on a branch, the API there reports "ai": false at / and the paper
reader is off.
The live API runs on the main branch. Ship an API change or a new migration there too,
once it works on dev:
node tools/migrate.ts --env .env.main
neon deploy --branch main --no-env-pull
Keep --no-env-pull: without it, neon deploy writes main's settings over .env.local,
and the scripts that read it would then point at the live database.
| Command | What it proves |
|---|---|
npm run typecheck | The API type-checks. |
npm run test:latex | The LaTeX repair for model replies and the bare-maths wrapping (29 cases). |
node tools/tenant-test.ts | 145 checks that tuitions are walled off from each other, and that sign-up, joining, switching, PIN resets, removals, the owner's join code, the rate limits, picture keys and chapters from another tuition, upload sizes and the daily AI cap behave. It makes its own tutors, tuitions and students, and removes them. |
node tools/security-test.ts | About 670 checks that walk every route the API has, found from the app itself: nobody and each role are refused what they should be, a tutor of another tuition can read or change nothing, junk bodies, ids and queries never end in a server error or an unanswered request, the answer key never reaches a student, a new password signs the other phones out, guesses sent all at once are counted one by one, the sign-up and tuition caps hold under a burst, and an oversized body is read away and refused. --only '<regex>' fuzzes just the routes whose METHOD /path matches. It makes its own data and removes it. Dev only. |
node tools/load-test.ts | A classroom (40 students unless --students says otherwise) logs in, opens the home screen, starts a test, saves answers and submits, all at the same moment, then reads the marks: no server errors, one finished attempt each, each marked on its own answers, the marks open for everyone, and a table of how long each step took (the trip from your PC to the database is in those figures). It makes its own data and removes it. Dev only. |
node tools/levels-test.ts | 47 checks of classes 1 to 12 and the levels a tuition names itself. |
node tools/votes-test.ts | 44 checks of when AI answers agree enough to mark a question: how a model's reading counts (one option, several, none, hesitant), models of two families, splits, the writer's answer, and a split on a paper AI wrote being final. |
node tools/answers-test.ts | 45 checks of the answers and second-opinion steps with scripted models (the API runs in the process against the dev database, every AI call is answered by a script, no keys or quota needed): a model down, slow or answering junk, two that split or doubt, a partial answer kept, the writer's own answer, a question left "not checked yet" and asked again. |
node tools/replace-test.ts |
The app follows Soft Structuralism. tools/extract-tokens.mjs copies the token layer
verbatim from the design guide into app/lib/theme.dart and design/comps/tokens.css; the
guide itself stays out of the repo. The one accent colour, violet, belongs to the AI paper
reader and nothing else. Motion is short and eased: an opening animation, lists that fade in a few
at a time, grey placeholder rows with a soft light passing over them while a list loads, figures that
count up, the press scale and a chosen option settling into place (app/lib/ui/motion.dart). Everything
stands still when the phone's "remove animations" setting is on. No spinners.
To view the comps, run python -m http.server 8777 --bind 127.0.0.1 --directory design/comps.
These are git-ignored and must never be committed: .env.local, .env.main,
api/.env.secrets, api/firebase-service-account.json, app/android/app/google-services.json,
tools/out/ (logins), and any keystore. The Groq keys and the Firebase service-account key
live only in those files and on the deployed function, never in the app.
(google-services.json does go into the app: it only names the Firebase project.)
Anyone can sign up as a tutor, so each door has a ceiling:
AI_DAILY_CALLS
on the function changes the second. Past it the app says the tuition has used its day of AI.REQUIRE_UPLOAD_SIZE=1 on the function
refuses uploads that give no size, once those apps are gone. A picture key names the tuition it was made for, and a
question or draft can only point at its own tuition's keys, so a signed link to another file is never handed out.
A chapter or student id another tuition owns is refused everywhere.CORS_ORIGINS (comma separated origins) on a dev function; the live one never sets it.The main branch holds the live data. It never gets sample data: it starts with just the
teacher login. This is how it was set up:
neon env pull --branch main --file .env.main -s postgres
node tools/migrate.ts --env .env.main
node tools/create-teacher.ts --env .env.main
neon deploy --branch main --no-env-pull
neon env pull --branch main --file .env.main
node tools/api-test.ts --env .env.main
The first pull takes only the database settings, because the bucket the full pull expects
only exists after the first deploy. create-teacher.ts writes the teacher login to
tools/out/logins.main.md.
Then upload the Groq keys with tools/upload-groq-keys.ps1 -Branch main, and change the
teacher password from the generated one in the app (Settings → Password).
The app only accepts teacher passwords of 8 or more characters. To set any other password, or to reset a forgotten one, run this in a terminal and type the password at the hidden prompt:
node tools/set-teacher-password.ts --env .env.main
Migrations are applied with node tools/migrate.ts --env <file>. On a branch whose API is older than
018_no_default_tuition.sql, stop before it with --up-to 017: that migration removes the "first tuition"
default the old API relied on. Deploy the new API, then run the command again without --up-to.
Release builds talk to the live API (main); debug builds talk to dev. A release is signed
with the key in app/android/core-academy-release.jks, and app/android/key.properties holds
its passwords. Both are git-ignored. Keep a copy of both somewhere safe. Android only
installs an update over an app signed with the same key. Lose the key, and every student has
to uninstall and reinstall.
Raise version: in app/pubspec.yaml, for example 1.2.1+4. The number after + must
go up every release: installed copies compare it to decide whether an update is newer.
Build it (ARM only, which covers every phone) and try it on the test phone:
.\tools\phone.ps1 release
To try the version on a phone before it goes live, with no risk to live data, build the
dev copy instead and send or install tools/out/core-academy-dev-<version>.apk:
.\tools\build-dev-copy.ps1
It is the release build (so it feels as the real one will) under its own app id
(com.coreacademy.core_academy.dev, "Core Academy Dev"), so it sits beside the live app,
pointed at the dev server alone. The script checks all of that in the finished file and stops
if the live server's address is inside it. It is signed with the debug key, like the debug
builds that share that app id, so it updates a practice copy already on a phone in place.
The project sits on E: while the pub cache is on C:, which Kotlin's incremental compile
cannot handle, so app/android/gradle.properties turns it off (kotlin.incremental=false).
The first build there takes a quarter of an hour; later ones are quicker.
Write what changed in a plain text file, a line per change. GitHub shows it on the release, and the app shows it in its "What is new" card.
Publish it:
.\tools\release-app.ps1 -Notes notes.txt
The script builds the signed APK, writes tools/out/update.json (version, build number,
download link, SHA-256, size and the notes), creates the GitHub release with both files,
then reads the feed back through GitHub's latest-release link. Phones on 1.2.0 or later
offer the update within two hours, or at once from Settings, App, Check for updates.
Add -NoPublish to build and write the files without releasing.
To try the updater without publishing anything, build two debug APKs that read a feed on the PC, the second with a higher build number:
flutter build apk --debug --build-number=3 --dart-define=UPDATE_FEED=http://127.0.0.1:8778/update.json
flutter build apk --debug --build-number=4 --build-name=1.2.1 --dart-define=UPDATE_FEED=http://127.0.0.1:8778/update.json
Put the second APK and an update.json that describes it in a folder, serve the folder with
py -3 -m http.server 8778, run adb reverse tcp:8778 tcp:8778, install the first APK and
tap Update.
The launcher icon comes from design/icon/icon.html; run .\tools\make-icon.ps1 after
changing it.
| Checks that the question maker replaces what its checks could not settle and never touches what the tutor has, what waits for a second opinion, what a call is working on, or an uploaded paper. The new questions come from the real writer, so the API needs its AI keys. Dev only. |
node tools/courses-test.ts | 114 checks of courses: people from any class or with no class at all, a course's papers, questions and tests kept out of every group, app versions from before courses kept away from them and told to update, the minimum-build gate, and deleting a course while its people stay. Against the local dry-run API (--base http://127.0.0.1:8787 --log server.log) it adds a check that the push names the course. It makes its own data and removes it. Dev only. tools/courses-demo.ts sets up a small demo of courses on dev (--clear removes it). |
node tools/bank-test.ts | 28 checks of the Bank (GET /teacher/bank, app 1.8.1): every paper the tutor has posted once, with how often it was used and the latest test to post it again from. The same questions in another order, fewer of them or for another class are other papers; a draft, a test taken down or a test deleted is no longer a use; who the test went to (the group, or students chosen one by one) is in the row. It makes its own questions and tests through the API, needs no database connection, and removes them again. Dev only. |
node tools/v180-extras-test.ts | 135 checks of what app 1.8.0 adds on the server: the update gate (a test already started can still be finished), a test left open for a week (the list on Home, Close the test, Remind the students, and the tutor's notification), Library files in the tutor's own order and the notice to students, renaming any subject, easy, medium and hard levels on questions and drafts, and storage per tuition for the developer's app. The notifications are only seen against the local dry-run API (--base http://127.0.0.1:8787 --log server.log); --ai also has the AI write a few questions. It makes its own data and removes it. Dev only. |
node tools/text-test.mjs | Hindi and Tamil text handling, the test writer's answer matching and suggested usernames. |
node tools/summary-test.ts --log server.log | 7 checks that the evening summary goes to each tuition's own tutors (after 8 pm India time; local API, dry-run push). |
npm run test:api | 168 end-to-end checks of the marking rules, when results open (closing time, last student, tutor, and which app versions wait), usernames for students who share a name, groups, the home and group pages, tutors, logins, question papers (including ones uploaded before their details are known, printed answer keys that replace AI answers, questions the bank already has, two answer calls at once and question groups) and the admin app's API against the deployed dev API (add -- --env .env.main for the live one; the admin checks run only where the logins file has a developer login). It creates its own students, subject, tests and paper, gives its tests only to those students, then removes them all. |
node tools/notify-test.ts --log server.log | 25 checks of the notifications against the API on your PC, run with PUSH_DRY_RUN=1 so each notification is written to the log instead of sent. Dev only. |
node tools/check-answers.ts | Every sample question's marked answer is right, and no other option equals it. |
node tools/ai-test.ts | How accurately the AI reads the 2-page sample paper (render it first with tools/make-sample-paper.ps1). |
flutter analyze and flutter test (in app/ and in admin/) | The lints and the widget tests; the screenshot harnesses run in their check-only mode. |
SHOTS=1 flutter test test/shots/screens_test.dart (in app/ or admin/) | Draws the screens, light and dark, to tools/out/shots on your PC from a stub server: the real widgets and fonts, no phone. LANDSCAPE=1 in app/ draws them on their side, and WIDTH_DP=360 TEXTSCALE=1.5 draws them on a narrow phone with large text, which is how to find what stops fitting. |
flutter test test/contract/tenant_flow_test.dart --dart-define=API_BASE=http://127.0.0.1:8787 (in app/) | The app's session code against the local API: sign up, make a tuition, join a second one, be let in, switch. Then node tools/out/cleanup-contract.mjs. |
python tools/audit.py | The design audit: type scale only, no stray colours, no banned patterns, and where the accent is used. |