# 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/-before--`, 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 '' sshpass -f "$SCRATCH/.pw" ssh administrator@78.38.35.249 'sudo -S -p "" ' < "$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 `` 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).