Files
MathApp/CLAUDE.md

444 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CLAUDE.md
Guidance for agents working in this repo. **Current state only**: what is true now and the rules
that follow from it. History lives in git (`git log -p -- CLAUDE.md`; the long dated status log that
used to be here ends at `eb95209`) and in the owner's reports (`~/Desktop/گزارشات Tadrisino/`).
When something here stops being true, change the line; do not append a dated paragraph.
## The project in one paragraph
Tadrisino is a Duolingo-style math app for Iranian students (grades 3–12): a map of islands per
textbook chapter, short quizzes built from the book's own problems, and an AI tutor («پی») that
answers from the same book. Two repos, one API contract: this Django + DRF backend
(`Tadrisino/MathApp`, live at `https://mathapi.tadrisino2.ir`) and the React frontend
(`Tadrisino/MathApp_frontend`, local clone `~/Desktop/MathApp_frontend`, live at
`https://math.tadrisino2.ir`, with an Android WebView shell in `android/`). The owner
(`AliReza_Zareian`) maintains both. Code, API messages, comments and docs are Persian.
## Where to look
| Need | File |
|---|---|
| Deploy, env vars, backups, nginx, Bazaar sale, admin refunds | `docs/operations.md` |
| Design decisions and why (auth, payment, quiz, hearts, tutor prompt) | `docs/architecture.md` |
| Formal handover document for humans (also as PDF) | `docs/technical-handbook.md` → `python docs/build_handbook.py` |
| What is still open before real users, and the guards that hold what is closed | `docs/security-before-production.md` |
| Editing content, bulk questions, seed rules | `docs/content-authoring.md` |
| Reading a textbook with a vision model (costs, model choice) | `docs/extraction-playbook.md`, `data/textbooks/README.md` |
| Tutor cost per question, subscription margins, guardrails | `docs/ai-cost-and-guardrails.md` |
| Measured model prices (append-only ledger: add a dated row, never overwrite) | `docs/model-prices.md` |
| Load testing (laptop and the shared server) | `tools/loadtest/README.md` |
| Frontend structure and its deploy | `~/Desktop/MathApp_frontend/README.md` |
| Android build, signing, what the next APK must carry | `~/Desktop/MathApp_frontend/android/README.md` |
Before writing a new doc, check this table: the same fact in three places drifts apart.
---
## ⛔ Working on the servers — read before every SSH session
The owner often says "put it on the server" / "deploy it" and expects you to do it yourself. You
may, but these machines are **shared company servers**, not ours. Our app is one tenant among many.
A careless command can take down the whole company's git, mail and other teams' production apps,
and there is no technical guard rail: our account has full root. **The only guard rail is you.**
### What is ours — and nothing else is
**`78.38.35.249` (`haghzad`, app server)**, account `administrator`:
| Ours | Note |
|---|---|
| `/home/administrator/mathapp-v2` | backend checkout; `.env`, `.django_secret_key`, `logs/`, `.deploy-backups/` are untracked and live here |
| `/home/administrator/mathapp_frontend_new` | frontend checkout; nginx serves its `dist/` |
| `gunicorn-mathapp.service` = alias **`zareian-mathapp`** | always type the alias |
| `zareian-redis.service` (port 6390) | our own Redis |
| Postgres cluster `14/zareian` (port 5440, DB `zareian_mathapp`) | our own cluster |
| `zareian-applog-archive.timer` | nightly log archive |
| nginx: `sites-enabled/mathapp`, `sites-available/mathapp-front`, `snippets/zareian-*.conf` | source of truth is `ops/nginx/` |
**Not ours, never touch:** `~/mathapp` (old, not git, not what runs), the generic `gunicorn.service`
(another team's app), `gitea`, `postfix`/`dovecot`, `named`, `mariadb`, `postgresql@14-main` (port 5432),
`redis-server` (6379), `rabbitmq`, `metabase`, `apirag`, `artinafront`, `metaverse`, `celerybeat`,
other people's home directories (`aria`, `artina`, `behdad`, `zahra`, `git`, `gitea`) and every
other nginx site. `metabase` and Artina's `celerybeat` crash-loop and eat ~3 of 8 cores; that is a
request to the server admin, not something to fix.
**`78.38.35.247` (backup server)**, same account and password: ours is only `~/zareian-pgbackup/` (249's
live copy), `~/zareian-pgbackrest-216/` (216's backups, encrypted on 216; 247 never holds the key), our lines in
`~/.ssh/authorized_keys` and `~/.pgpass`, `~/.ssh/zareian_*`, and the `zareian-pg-*` / `zareian-applog-pull` units. It also runs other teams' services and its
own `postgresql@14-main`, which we never use.
**`78.38.35.216` (hostname `mathapp`, new dedicated VM, not yet serving users)**, account `zareian` (sudo; the
senior and the company owner share `mathapp-admin`, also sudo; no other logins): the whole machine is ours. The app runs as the non-sudo user `mathapp` under `/home/mathapp/`; same unit names as on 249.
Password is the `Password` line after `NewServer` in the password file. State and the cut-over order:
`docs/operations.md` «سرورِ اختصاصیِ جدید». Until cut-over, 249 stays the live server and every rule below applies
there unchanged; on 216, rules 1, 5, 6, 8 and 10 still apply.
### Rules (not negotiable)
1. **Read-only first.** Before changing anything, look: `systemctl status`, `ls`, `git -C … log -1`,
`df -h /`. Know what is there before you act on it.
2. **No destructive command outside our directories.** No `rm -rf`, `chown -R`, `chmod -R`,
`find … -delete`, `DROP`, `docker system prune`, package upgrades, reboots, firewall changes.
Inside our directories, write paths out in full (`/home/administrator/mathapp-v2/...`), never
rely on the current directory.
3. **Never `git clean`, never delete and re-clone the project.** `.env`, `.django_secret_key`, logs
and backups are untracked; `git reset --hard origin/main` is the way back to a known tree.
4. **Restart only `zareian-mathapp`.** `systemctl restart gunicorn` is another team's app. nginx is
shared: `sudo nginx -t` then `reload`, never `restart`.
5. **Back up before editing anything under `/etc` or `.env`**, into
`~/mathapp-v2/.deploy-backups/<what>-before-<change>-<YYYYMMDD-HHMMSS>`, and say where it is.
Database: `deploy.sh` already takes a `pg_dump` before `migrate`; for any manual data change take
one yourself first.
6. **Disk is the standing risk.** Check `df -h /` before anything that writes a lot. A full disk
takes Gitea and mail down with us.
7. **Do not use other teams' resources**, even "just an empty database" on their Redis or Postgres,
their queues or their nginx sites. Ask the owner; it becomes a request to the server admin.
8. **Secrets stay put.** Read `.env` only through Django (`venv/bin/python manage.py shell -c` printing
booleans), never `cat .env`, never paste values into chat. `DEBUG` must never be `1` on the server
(it enables `DEV_MASTER_OTP=0000`, a login for any phone).
9. **Load tests and bulk jobs** follow `tools/loadtest/README.md`: never touch production throttles,
stay under the connection budget, clean up.
10. **When something is unexpected** (a file you did not expect, a service in a state you cannot
explain, a command that wants to prompt), stop and tell the owner. Do not improvise on a shared box.
### Announce before you act
Before every command that runs on a server, or that deletes anything (even locally), write one
short Persian line saying what it touches, where (local scratchpad or which directory on which
server), and that nothing outside our own files is affected. Never combine server commands and
local cleanup in one call: once, an agent closing its local SSH socket and destroying its local
password copy in the same command looked like deleting files on the server, and the owner (who
answers for that machine) was rightly alarmed. «سرور رو خاموش کن» means end the SSH session and
destroy the password copy, **never** power anything off.
### The password
Access is a password for the shared `administrator` account (same for 249 and 247). The owner
keeps it in `~/Desktop/رمز سرور.txt` and wants agents to read it from there rather than ask in
chat; the auto-mode classifier may still block that read until the owner confirms in the
session. Take only the password line, everything after the first `: ` (stripping only the label
once sent the whole line and SSH refused it):
```bash
sed -n '/^Password/{s/^[^:]*: *//;p;q;}' ~/Desktop/'رمز سرور.txt' | tr -d '\r\n' > "$SCRATCH/.pw" && chmod 600 "$SCRATCH/.pw" # 249/247 (first Password line)
awk '/^NewServer/{f=1;next} f && /^Password/{sub(/^[^:]*: */,"");printf "%s",$0;exit}' ~/Desktop/'رمز سرور.txt' > "$SCRATCH/.pwn" && chmod 600 "$SCRATCH/.pwn" # 216
sshpass -f "$SCRATCH/.pw" ssh administrator@78.38.35.249 '<command>'
sshpass -f "$SCRATCH/.pw" ssh administrator@78.38.35.249 'sudo -S -p "" <command>' < "$SCRATCH/.pw"
rm -P "$SCRATCH/.pw" # as soon as the work is done (macOS has no shred); recreate it next time
```
Never put it on a command line (`ps` shows argv to every user on the server), in the repo, a
memory, a commit or a note; never echo it. Many SSH connections in a row can trip a lockout: open
one `ControlMaster` connection (socket under `/tmp`, macOS limits the path to 104 bytes). Do not
create SSH keys, edit `sshd_config` or change the password unless the owner asks for exactly that.
### "Deploy it": the procedure
Backend (commit and push to Gitea first; the server deploys what is on `origin/main`):
```bash
sshpass -f "$SCRATCH/.pw" ssh administrator@78.38.35.249 'cd /home/administrator/mathapp-v2 && ./deploy.sh'
sshpass -f "$SCRATCH/.pw" ssh administrator@78.38.35.249 'sudo -S -p "" systemctl restart zareian-mathapp' < "$SCRATCH/.pw"
```
`deploy.sh` saves any hand edits as a patch, resets to `origin/main`, installs, backs up the DB,
migrates, runs `seed_content`, collectstatic, and checks health through the gunicorn socket. The
restart is separate because `sudo` inside the script cannot prompt. Then **verify; never assume**:
- `systemctl show zareian-mathapp -p ActiveEnterTimestamp` is after your deploy (a file on disk is not
a deployed file: once the server served two-day-old code while every file was current);
- `git -C /home/administrator/mathapp-v2 log -1 --format=%h` equals your local `main`;
- `curl https://mathapi.tadrisino2.ir/api/health` → `{"status":"ok"}` (details need `X-Internal-Key`);
- the thing you changed, exercised once from outside.
If the server cannot reach Gitea (`git fetch` 504 from the server, 200 from the laptop), ship a bundle:
`DEPLOY_BUNDLE` in `docs/operations.md` «دیپلوی».
Frontend: push to `upstream` (`Tadrisino/MathApp_frontend`) `main` (the owner authorized pushing and
deploying it without asking), then `cd /home/administrator/mathapp_frontend_new && ./deploy.sh` (builds
into `dist.next`, swaps, keeps `dist.prev`). `config.js` and `index.html` are cached by ArvanCloud;
purge them there when they change.
Report back: the commit hashes now live, the backup paths created, what you verified and what you did
not.
---
## How to work in this codebase
**Comments are claims, not evidence.** Most comments were written by earlier agents to justify their
own change. Real bugs have been found directly under comments calling the code "safe" or "race-free".
When asked to find problems, review adversarially and judge by behaviour: write a throwaway test or call
the endpoint. Ask how a cheating student, an attacker or a confused parent would break it. A finding
counts once reproduced; say which ones you only read. When writing comments, report, don't vouch: say
what the code does, why, and what was *not* checked.
**No silent fallbacks, no speculative backward compatibility.** "Helpful" fallbacks (`x or default`,
`except: pass`, accepting an old field name "just in case") caused the hardest bugs here because the
failure never showed anywhere (a stub tutor answer served with 200 in production; a yearly buyer
silently given 30 days).
1. Fail loudly over guessing. A missing setting, row or field in production is an error the user and
`/api/health` can see. A default is fine only when it is a real product choice, and its comment says
why that value is right.
2. Demo/stub paths only behind `DEBUG` or `RAG_FORCE_MOCK`; never reachable on the server.
3. No backward compatibility without a named client. The only API client is the frontend
(`~/Desktop/MathApp_frontend/src/api/`); check what it and the server DB actually send. If nobody
sends it, delete it. If you must keep one, write who uses it and when it can go.
4. Never `except: pass`. At least `logger.exception`, and swallow only where the comment names the
bigger thing it protects (e.g. affiliate commission must not undo a user's payment).
5. Removing a fallback means the old input fails visibly (400/503), not silently routing elsewhere.
Fallbacks that remain on purpose (re-check before relying on them): settings boot with default
hosts/CORS/cache/secret key (`/api/health` warns); `LocMemCache` without `CACHE_URL`; `_annotation`
defaults for answer-cache entries older than a field; Bazaar's OAuth path beside the Pishkhan token
(the server uses OAuth); `birthYear` alone in `PATCH /api/user/profile` (frontend type still allows it);
`totalQuestions` accepted but not trusted (frontend sends it); orders without a `plan_id` or without the
`payment.0011` snapshot fields grant and refund from the plan's current values; a paid order whose plan
was deleted activates for 30 days and logs ERROR.
**The owner** is early in their career: explain the *why* with the *what*, flag product decisions as
product decisions, and never assume a step was done because it was described. **Reply in Persian**, every
sentence including progress lines between tool calls (code, paths and identifiers stay Latin). Keep chat
replies short; depth goes into the deliverable. Docs describe the current state only; reasons and dates belong in
commit messages. Commit in batches, not per small change.
## Commands
```bash
source venv/bin/activate
python manage.py runserver # needs DJANGO_DEBUG=1 locally (devOtp, DEV_MASTER_OTP)
python manage.py migrate
python manage.py test --noinput # whole suite (973 tests), serial, ~95 s
python manage.py test --noinput accounts # one app
python manage.py test --noinput accounts.tests.SomeTestCase.test_something
python manage.py spectacular --file schema.yaml --format openapi # the API contract
```
**Never run tests with `--parallel` on this Mac, always pass `--noinput`.** The test DB is a SQLite
*file* (`.test-db.sqlite3`, needed by the real-concurrency tests); parallel workers fail to open it and
the parent waits forever, and a leftover test DB makes Django ask "Type 'yes'" on stdin forever. If a
run prints nothing for ~3 minutes it is stuck: `pkill -f "manage.py test"`. No pytest, no linter.
Docker (`docker compose up --build`) adds a local Loki + Alloy + Grafana log stack; dev only
(`docs/docker.md`).
## Architecture
| app | responsibility | key models |
|---|---|---|
| `accounts` | identity, OTP login, sessions, profile | `User`, `Profile`, `Session`, `OTPCode`, `LoginEvent` |
| `content` | subjects, curriculum, lessons, questions, quiz, votes/reports | `Grade`, `Subject`, `Unit`, `Node`, `Lesson`, `Question`, `Choice`, `UserAnswer`, `UserNodeCompletion`, `ContentVote`, `SeedRecord` |
| `progress` | stats, streaks, hearts, leaderboard, companions, weekly report | `UserStats`, `StreakRecord`, `UserHearts`, `LeaderboardEntry`, `Character` |
| `ai_tutor` · `rag` | tutor chat; retrieval from Qdrant + generation via GapGPT; textbook ingest | `Conversation`, `Message`, `LessonChunk`, `IngestedPage` (book list: `rag/services/textbooks.py`) |
| `subscription` | plans and subscriptions | `Plan`, `Subscription` |
| `payment` | orders, transactions, Zarinpal, Cafe Bazaar, payment event ledger | `Order`, `Transaction`, `PaymentEvent` |
| `discount` · `affiliate` | discount codes/offers; referral commissions | `DiscountCode`, `Redemption`, `Offer`, `AffiliateAccount`, `Conversion` |
| `notifications` · `support` · `campaigns` | notifications; tickets/bug reports; campaign storage (no sending) | |
| `statuslog` | cross-app status-change log | `StatusChangeLog` |
| `config` | settings, urls, middleware, health, renderers | |
Invariants that are not obvious from one file:
- **Auth is ours, not Django's.** `accounts.User` is deliberately not `AUTH_USER_MODEL`. Access token =
signed `user_id` (1 h, `django.core.signing`, `accounts/authentication.py`); refresh tokens live
(hashed) in `Session`; logout sets `User.tokens_valid_after`. A missing/invalid header means an anonymous
request; each view's permission class decides. Login is phone + SMS code only (6 digits, 5 guesses);
there is no guest login. The signing key is `.django_secret_key` (generated on the server, mode 600);
the `django-insecure-…` fallback in `config/settings.py` must never sign tokens on a server.
- **No cross-app foreign keys.** Apps refer to each other with `user_id` / `object_id` + `label` so any app
can be split out. Follow the pattern.
- **Throttling**: every sensitive view has a `throttle_scope` and a matching rate in
`REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']`. New endpoint = both. Anonymous requests are limited by IP;
`config.middleware.TrustedProxyMiddleware` trusts `X-Forwarded-For` only from ArvanCloud ranges
(`TRUSTED_PROXY_NETWORKS`), and nginx refuses anything not from Arvan.
- **Grade id = grade number** (8 = eighth). Units belong to a `Subject` (ریاضی، حسابان، هندسه، آمار و احتمال،
گسسته); `/api/home?subject=` picks the book, an unrelated subject is 400.
- **Domains are configuration**: only `PUBLIC_BASE_URL` and `FRONTEND_URL`; everything else derives from
them (`config/tests_domains.py`). Domain-move procedure: `docs/operations.md`.
- **Output is plain Persian** (company decision): `config.renderers.PlainPersianJSONRenderer` strips
diacritics and the written ezafe «ی» from every JSON response, and `rag.services.mathtext.to_plain` does
the same for the tutor (stream included). The DB, seed and admin keep the original text, because rewriting
titles would break question→lesson binding by title path. No LaTeX/markdown reaches a student: seed and
import convert (`content/plain_math.py`), the tutor converts, and the frontend's `plainMath.ts` is the
last net; the two converters must behave alike.
- **Structured logging**: `APIRequestLoggingMiddleware` logs every `/api/` request as one JSON line (no
body, headers or secrets). Unexpected errors always return JSON (`config/exceptions.py`).
- **`.iterator()` never wraps a loop that writes or does network I/O**: the open read cursor held locks
for the whole loop (a "flaky" test that was a real bug). Materialize with `list(...)`.
- **Network calls stay outside `transaction.atomic()`**, and the tutor and the OTP SMS call hand their DB
connection back before the network wait (`release_db_connection`).
## Current state, by area
### Infrastructure (server 249)
- Postgres cluster `14/zareian`, port 5440, localhost only (`DB_*` in `.env`; without `DB_NAME` the app
uses SQLite, which is what local dev and tests use). `max_connections=60`.
- Connections come from a per-worker pool (`DB_POOL_MAX_SIZE` 8); a full pool answers 503 + `Retry-After`
(`config/db.py`), and `/api/health` → `dbPool` checks `workers × pool + 6 ≤ max_connections`.
`DB_CONN_MAX_AGE` in `.env` stops startup on purpose.
- gunicorn: `gunicorn.conf.py`, 4 `gthread` workers × 32 threads (the unit passes no `--workers`). The tutor
gets at most `AI_MAX_CONCURRENT_PER_WORKER` (12) threads per worker; beyond that `/api/ai/ask` answers 503
`Retry-After: 10` before reserving quota.
- Cache: our own Redis (`zareian-redis`, 6390, password, 256 MB `allkeys-lru`, no persistence) via
`CACHE_URL`. `config.cache.ResilientRedisCache` fails open on a Redis outage (logged, health 503, cache-based
limits stop applying); not tested against a real outage on the server.
- nginx (versions in `ops/nginx/`): only ArvanCloud and the machine itself get through (direct IP → 403);
CSP and security headers on the frontend; API body limit 20 MB; HSTS. Verified in Chrome; **not** verified
in the Android WebView or through `pay.html` to the gateway.
- Capacity (load test on the server, `tools/loadtest/play.py`): 300 players ≈ 88 req/s, median 0.1 s;
600 ≈ 137 req/s, median 1.4 s, no errors; 1,000 hits the ceiling (~135 req/s, p95 15 s). The bottleneck
is the shared CPU, not the DB pool. More capacity needs the neighbours' crash-loops fixed plus more cores
(then 8 workers with `DB_POOL_MAX_SIZE` 6).
- `/api/health`: public answer is only ok/error; full details (DB, migrations, cache, pool, integrations,
`aiProvider`, `offsiteBackup`, warnings, `clientIp`) need `X-Internal-Key`. Swagger/ReDoc/schema need an
admin session.
### Backups and logs (server 247)
Every Postgres change streams live to 247 (`pg_receivewal` over a restricted SSH tunnel, slot
`zareian_247`, async; `max_slot_wal_keep_size=1GB` protects 249's disk) plus a daily base backup (7 kept).
`deploy.sh` also takes a `pg_dump -Fc` before `migrate` (last 2 kept). Payment events are exported to CSV on
247 every 6 hours; the app log is archived nightly on 249 and pulled hourly to 247, where it is kept forever.
Restore procedure, monitoring and what was tested: `docs/operations.md` «بکاپ و بازیابی». Restoring onto 249
has been done before.
### Content
- `content/seed/` is the whole curriculum and question bank as committed JSON: 10 grades (3–12), 5 subjects,
89 units, 1,145 islands, 5,406 lessons (every one has at least one question), 39,156 questions. `deploy.sh` runs `seed_content` on every release.
- `seed_content` is idempotent, never deletes, and does not undo admin work: the `SeedRecord` ledger remembers
what it delivered, so anything the admin deleted or renamed is not recreated; lesson text, `example`,
`steps` and `figures` are only filled when empty. Questions bind to lessons **by title path**
(grade › unit › node › lesson), never by id. Practice islands («تمرین: …») are the exception: their
options are synced from the file in place (`seed_sync._refresh_practice`).
- Content changes reach the server only as committed seed files (`seed_content --export`); pushing code
alone moves no content. Lesson ids differ between laptop and server.
- Islands longer than 7 lessons are split into parts of ≤6 (`content/node_split.py`). 160 practice islands
close each chapter (grades 3–12) with computed-answer questions.
- `Lesson.figures` and `Question.figure` are fixed diagrams (same spec as the tutor's, validated by
`rag.services.diagram.validate`); nothing calls a model at display time. Geometry/statistics/discrete
figures were hand-written and checked by eye; API-generated figures (`gen_lesson_figures`) were about half
wrong on geometry and are only used with visual review (`--dry-run` still calls the API).
- Students vote 👍/👎 and report problems on every question and lesson (`ContentVote`, admin columns and
report queues).
- Not yet filled: `Unit.blurb` for some units, `Plan.features`/`badge`; no video content exists although
`Lesson.kind`/`video_url` can express it.
### Playing: islands, quizzes, hearts
- `GET /api/nodes/{id}/quiz`: at most one question per lesson, shuffled on `nodeId:userId` only, so a student
sees the same quiz every time (owner's decision; `NodeQuizStabilityTests`).
**Figured questions win:** inside a lesson the quiz takes a question with a `figure` over one without
(`NodeQuizPrefersFigureTests`). So adding figured questions to a lesson replaces its figureless ones in
the quiz without deleting anything; the old rows stay (seed never deletes, answers may reference them).
Owner's standing plan (2026-10-14): keep writing more figured questions and re-review the whole bank over
time; this rule is the mechanism, so new figures need no other switch. Write them with
`tools/figures/batch.py` (`docs/content-authoring.md` «سوالِ شکل‌دار»). The lesson-level list
(`/api/lessons/{id}/questions`) does not reorder; only the island quiz does.
**A figure must not give the answer away** (title, labels, or drawing the thing the question asks for); the
rules and the fix-up tool (`tools/figures/apply_fixups.py`) are in `docs/content-authoring.md`.
- `POST /api/nodes/{id}/quiz/complete` completes the island at any score (score sets stars/XP) and returns
`nextNodeId`. It refuses a locked island (403 `node_locked`, same rule as `/api/home`) and a user who has
not answered every question (409 `answers_missing`; required = `max(client totalQuestions, min(4, questions
in node))`). An island with no questions passes when unlocked. `POST /api/lessons/{id}/complete` has the same
rules. XP counts only answers recorded in `UserAnswer`.
- Hearts are enforced (`HEARTS_ENFORCED`): a wrong answer costs one, one returns every 5 h, all return 24 h
after the first loss; zero hearts → 403 `no_hearts`. An active subscription means unlimited hearts.
- `UNLOCK_ALL_PHONES` (server `.env`: the owner's and a tester's numbers, users 34 and 35) removes the island
order lock for testers; their content access comes from a `Subscription` row.
- Streaks and the leaderboard are computed live from activity (Tehran time); companions (sigma, delta, pi,
omega) unlock by XP.
### Paywall, subscriptions, payment
- Only the first unit of **each book** is free. Elsewhere, without an active subscription, lesson questions,
island quizzes, answers and completion return 402 `subscription_required`; `/api/home` marks each unit
`requiresSubscription`.
- Only subscriptions are sold (question packs were removed at the root): 1/3/6 months at
598/898/1,398 thousand toman; the tutor allows 4 questions a day for subscribers and
`AI_DAILY_QUOTA_FREE` (2) for free users; `AI_GLOBAL_DAILY_MAX` 3,000 for the whole system.
- Zarinpal: `paymentUrl` points at our `GET /api/payment/orders/{id}/pay`, which calls PaymentRequest and
redirects; the callback always re-verifies server-to-server. Unverified paid orders are re-checked when the
app reads status and by `payment_reconcile`. The merchant is the one `Tadrisino_backend` uses (registered
for `tadrisino2.ir`); PaymentRequest returns 100. **No real payment has gone through to verify yet.**
- Cafe Bazaar: subscription SKUs `10342`/`10343`/`10344` under package `com.tadrisino.math.app`, set on the
plans in admin; dynamic-price discounts via `BAZAAR_DYNAMIC_PRICE_KEY`; renewals are polled from Bazaar
(`sync_bazaar_renewals`). The server talks to Bazaar through the company account's OAuth. Payment methods
differ only in `payment/providers.py`. **No real Bazaar purchase or renewal has been tested.**
- Every purchase step writes a `PaymentEvent` row. Cancel and refund are admin actions on
`/admin/payment/order/` (the money itself is returned in the gateway's panel).
- Both gateways and SMS fail closed (503 / invalid) without credentials, never a fake success.
### AI tutor
- `POST /api/ai/ask` is RAG: Qdrant retrieval filtered by book and chapter → GapGPT generation
(`OPENAI_BASE_URL`, model `gpt-4.1-mini`, prompt `tutor-v12-age-tone`). `usedChunks` names the book chunks
used. Without `OPENAI_API_KEY`/`QDRANT_API_KEY` and `DEBUG=0` it answers 503, never a stub.
- Answers are plain text, step by step, math-only (`rag/services/guardrails.py`; self-harm messages get a
kind reply with helpline numbers), tone by age, with an optional `<diagram>` JSON block that the frontend
draws (`rag/services/diagram.py`, frontend `src/diagram/`). Solutions marked `disagrees` never reach the
model. With `questionId` it sees the quiz question and the student's last answer.
- Cost ≈ 0.2 cents per question (plan with ~700 toman). `/api/health` → `aiProvider` shows when GapGPT credit
runs out; every live call spends the same production credit, laptop included.
### Accounts, SMS, users
- Kavenegar sends OTP only through the approved template `SMSVerify` (`verify/lookup`,
`KAVENEGAR_OTP_TEMPLATE`); plain `sms/send` is cancelled on this line, so never unset the template. Every
OTP costs credit. Limits are counted in the DB: per-phone cooldown and 10/day, system-wide
`OTP_SMS_GLOBAL_DAILY_MAX` (2,000 on the server), per-IP 100 requests and 300 logins per hour.
- No fixed-code test logins on the server (`TEST_LOGIN_PHONES` empty; health warns if set) and no demo users.
To test without SMS, a superuser creates an `OTPCode` in `/admin/accounts/otpcode/add/` (random code, a few
minutes), then `POST /api/auth/logout` afterwards. For a browser check, put the tokens in
`localStorage['tadrisino.tokens']` and drive Chrome with `puppeteer-core` from the scratchpad; never print
the access token.
- Profile: Jalali `birthDate`, display id `User482917` (`accounts/public_id.py`), weekly report
(`/api/user/weekly-report`, minutes are estimated), invoices, account deletion with a fresh SMS code
(frontend: «حساب کاربری»).
### Frontend and Android
- Frontend: React 18 + TypeScript (strict) + Vite + Tailwind; the client is `src/api/` and is the only
place that calls the server. Its README is the map. Never write the client from memory: read the schema and
check shapes against a running server (`content/tests_frontend_contract.py` is the backend-side guard).
- Android: WebView shell plus Poolakey for Bazaar. The installed APK lags the code; what the next build must
carry and what to check on a phone is in `android/README.md`. Signing belongs to teammate MH-W90; never
commit a keystore. Until the new APK, card payment from the app goes through the frontend's `pay.html`
(Zarinpal refuses StartPay without our domain as Referer).
### Still open before real users
`docs/security-before-production.md` is the list (public `/admin/`, plaintext OTP in the admin, SMS-cap
exhaustion, shared server account, …). Do not call this deployment production-ready until it is empty. When
one is closed, move it to that file's «حفاظ‌هایی که هست» table with the test that holds it.
---
## Lessons that were expensive
- **GapGPT** (OpenAI-compatible proxy reachable from Iran; it answers 504 from outside Iran). Two failures
look alike: `429 initial_usage_limit_exceeded` is an hours-long account cap that retries *extend*;
`403 insufficient user quota` (with spaces) is exhausted credit. `is_account_limit()` in
`rag_verify_solutions` is the shared guard. `/dashboard/billing/usage` (cents) is the only reliable balance;
probe it before any bulk run. Credit has run dry more than once.
- **GapGPT spend stays at cents per session**: the laptop key is the production tutor's account (another
person uses it too); one benchmark of ~250 calls once emptied it and the live tutor returned 503. Estimate
first, test plumbing with `tools/fake_openai.py`, ask before anything bigger.
- **Measure models; don't read price tables.** GapGPT prices differ from official ones; the judge model in
`rag_model_benchmark` misjudges ~7%, so hand-check samples before deciding. Vision ingest uses
`gpt-4.1-mini` (`docs/extraction-playbook.md`).
- **Bulk content is written by Claude Code subagents, not the paid API**: Sonnet authors questions well
(~88% yield), Haiku does not (~40% broken). The weekly Sonnet limit arrives suddenly: predict the cost and
import results between batches. Every generated batch gets an independent blind re-solve.
- **Verification is three-state** (`agrees`/`disagrees`/`unjudgeable`); a boolean verdict reported ~3× the
real error rate. Question generation reads only `agrees` chunks, and `agrees` is a first filter, not a
guarantee. `solution` is never rewritten; the independent answer is in `checked_answer`. In these books
`۳/۱۰۴` is the decimal 3.104, and in primary books «/» is both decimal separator and fraction bar.
- `rag_verify_solutions --recheck` resends everything; to re-check a subset, clear its verdicts. Never run two
writing management commands at once on local SQLite ("database is locked" left a run half done, silently).
- In shell wait loops use `pgrep -f`, not `ps | grep` (it counts itself).
- A load test or wait loop on the server must not `wait` on a background gunicorn (it hangs forever).