Files
MathApp/CLAUDE.md
2026-10-18 00:48:23 +03:30

32 KiB
Raw Permalink Blame History

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. Both accounts use the password on the AdminUser-Password line of the password file (the Password line under NewServer is older). 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):

sed -n '/^Password/{s/^[^:]*: *//;p;q;}' ~/Desktop/'رمز سرور.txt' | tr -d '\r\n' > "$SCRATCH/.pw" && chmod 600 "$SCRATCH/.pw"   # 249/247 (first Password line)
awk '/^AdminUser-Password/{sub(/^[^:]*: */,"");printf "%s",$0;exit}' ~/Desktop/'رمز سرور.txt' | tr -d '\r\n' > "$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):

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

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).