436 lines
31 KiB
Markdown
436 lines
31 KiB
Markdown
# 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/`
|
||
and the `zareian-pg-*` / `zareian-applog-pull` units. It also runs other teams' services and its
|
||
own `postgresql@14-main`, which we never use.
|
||
|
||
### 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;}' ~/Desktop/'رمز سرور.txt' > "$SCRATCH/.pw" && chmod 600 "$SCRATCH/.pw"
|
||
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), 23,964 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).
|