32 KiB
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)
- 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. - 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. - Never
git clean, never delete and re-clone the project..env,.django_secret_key, logs and backups are untracked;git reset --hard origin/mainis the way back to a known tree. - Restart only
zareian-mathapp.systemctl restart gunicornis another team's app. nginx is shared:sudo nginx -tthenreload, neverrestart. - Back up before editing anything under
/etcor.env, into~/mathapp-v2/.deploy-backups/<what>-before-<change>-<YYYYMMDD-HHMMSS>, and say where it is. Database:deploy.shalready takes apg_dumpbeforemigrate; for any manual data change take one yourself first. - Disk is the standing risk. Check
df -h /before anything that writes a lot. A full disk takes Gitea and mail down with us. - 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.
- Secrets stay put. Read
.envonly through Django (venv/bin/python manage.py shell -cprinting booleans), nevercat .env, never paste values into chat.DEBUGmust never be1on the server (it enablesDEV_MASTER_OTP=0000, a login for any phone). - Load tests and bulk jobs follow
tools/loadtest/README.md: never touch production throttles, stay under the connection budget, clean up. - 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 ActiveEnterTimestampis 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=%hequals your localmain;curl https://mathapi.tadrisino2.ir/api/health→{"status":"ok"}(details needX-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).
- Fail loudly over guessing. A missing setting, row or field in production is an error the user and
/api/healthcan see. A default is fine only when it is a real product choice, and its comment says why that value is right. - Demo/stub paths only behind
DEBUGorRAG_FORCE_MOCK; never reachable on the server. - 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. - Never
except: pass. At leastlogger.exception, and swallow only where the comment names the bigger thing it protects (e.g. affiliate commission must not undo a user's payment). - 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.Useris deliberately notAUTH_USER_MODEL. Access token = signeduser_id(1 h,django.core.signing,accounts/authentication.py); refresh tokens live (hashed) inSession; logout setsUser.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); thedjango-insecure-…fallback inconfig/settings.pymust never sign tokens on a server. - No cross-app foreign keys. Apps refer to each other with
user_id/object_id+labelso any app can be split out. Follow the pattern. - Throttling: every sensitive view has a
throttle_scopeand a matching rate inREST_FRAMEWORK['DEFAULT_THROTTLE_RATES']. New endpoint = both. Anonymous requests are limited by IP;config.middleware.TrustedProxyMiddlewaretrustsX-Forwarded-Foronly 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_URLandFRONTEND_URL; everything else derives from them (config/tests_domains.py). Domain-move procedure:docs/operations.md. - Output is plain Persian (company decision):
config.renderers.PlainPersianJSONRendererstrips diacritics and the written ezafe «ی» from every JSON response, andrag.services.mathtext.to_plaindoes 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'splainMath.tsis the last net; the two converters must behave alike. - Structured logging:
APIRequestLoggingMiddlewarelogs 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 withlist(...).- 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; withoutDB_NAMEthe app uses SQLite, which is what local dev and tests use).max_connections=60. - Connections come from a per-worker pool (
DB_POOL_MAX_SIZE8); a full pool answers 503 +Retry-After(config/db.py), and/api/health→dbPoolchecksworkers × pool + 6 ≤ max_connections.DB_CONN_MAX_AGEin.envstops startup on purpose. - gunicorn:
gunicorn.conf.py, 4gthreadworkers × 32 threads (the unit passes no--workers). The tutor gets at mostAI_MAX_CONCURRENT_PER_WORKER(12) threads per worker; beyond that/api/ai/askanswers 503Retry-After: 10before reserving quota. - Cache: our own Redis (
zareian-redis, 6390, password, 256 MBallkeys-lru, no persistence) viaCACHE_URL.config.cache.ResilientRedisCachefails 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 throughpay.htmlto 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 withDB_POOL_MAX_SIZE6). /api/health: public answer is only ok/error; full details (DB, migrations, cache, pool, integrations,aiProvider,offsiteBackup, warnings,clientIp) needX-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.shrunsseed_contenton every release.seed_contentis idempotent, never deletes, and does not undo admin work: theSeedRecordledger remembers what it delivered, so anything the admin deleted or renamed is not recreated; lesson text,example,stepsandfiguresare 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.figuresandQuestion.figureare fixed diagrams (same spec as the tutor's, validated byrag.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-runstill calls the API).- Students vote 👍/👎 and report problems on every question and lesson (
ContentVote, admin columns and report queues). - Not yet filled:
Unit.blurbfor some units,Plan.features/badge; no video content exists althoughLesson.kind/video_urlcan express it.
Playing: islands, quizzes, hearts
GET /api/nodes/{id}/quiz: at most one question per lesson, shuffled onnodeId:userIdonly, 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 afigureover 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 withtools/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 indocs/content-authoring.md.POST /api/nodes/{id}/quiz/completecompletes the island at any score (score sets stars/XP) and returnsnextNodeId. It refuses a locked island (403node_locked, same rule as/api/home) and a user who has not answered every question (409answers_missing; required =max(client totalQuestions, min(4, questions in node))). An island with no questions passes when unlocked.POST /api/lessons/{id}/completehas the same rules. XP counts only answers recorded inUserAnswer.- 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 → 403no_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 aSubscriptionrow.- 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/homemarks each unitrequiresSubscription. - 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_MAX3,000 for the whole system. - Zarinpal:
paymentUrlpoints at ourGET /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 bypayment_reconcile. The merchant is the oneTadrisino_backenduses (registered fortadrisino2.ir); PaymentRequest returns 100. No real payment has gone through to verify yet. - Cafe Bazaar: subscription SKUs
10342/10343/10344under packagecom.tadrisino.math.app, set on the plans in admin; dynamic-price discounts viaBAZAAR_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 inpayment/providers.py. No real Bazaar purchase or renewal has been tested. - Every purchase step writes a
PaymentEventrow. 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/askis RAG: Qdrant retrieval filtered by book and chapter → GapGPT generation (OPENAI_BASE_URL, modelgpt-4.1-mini, prompttutor-v12-age-tone).usedChunksnames the book chunks used. WithoutOPENAI_API_KEY/QDRANT_API_KEYandDEBUG=0it 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, frontendsrc/diagram/). Solutions markeddisagreesnever reach the model. WithquestionIdit sees the quiz question and the student's last answer. - Cost ≈ 0.2 cents per question (plan with ~700 toman).
/api/health→aiProvidershows 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); plainsms/sendis 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-wideOTP_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_PHONESempty; health warns if set) and no demo users. To test without SMS, a superuser creates anOTPCodein/admin/accounts/otpcode/add/(random code, a few minutes), thenPOST /api/auth/logoutafterwards. For a browser check, put the tokens inlocalStorage['tadrisino.tokens']and drive Chrome withpuppeteer-corefrom the scratchpad; never print the access token. - Profile: Jalali
birthDate, display idUser482917(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.pyis 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'spay.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_exceededis an hours-long account cap that retries extend;403 insufficient user quota(with spaces) is exhausted credit.is_account_limit()inrag_verify_solutionsis 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_benchmarkmisjudges ~7%, so hand-check samples before deciding. Vision ingest usesgpt-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 onlyagreeschunks, andagreesis a first filter, not a guarantee.solutionis never rewritten; the independent answer is inchecked_answer. In these books۳/۱۰۴is the decimal 3.104, and in primary books «/» is both decimal separator and fraction bar. rag_verify_solutions --recheckresends 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, notps | grep(it counts itself). - A load test or wait loop on the server must not
waiton a background gunicorn (it hangs forever).