Files
MathApp/CLAUDE.md
Zareian 42783f9ba4 tutor answers step by step in plain text, islands finish, and the audit's money and login holes are closed
Tutor (prompt tutor-v3-stepwise-plain): plain text instead of LaTeX/markdown,
with rag/services/mathtext.py converting whatever still slips through; full
step-by-step solutions with a check before the final answer; three tones by
grade; the last 6 messages of the conversation go to the model and follow-ups
are no longer served from another user's cache; book solutions the checker
disputes are kept out of the context; a questionId gives the tutor the
practice question and the student's wrong answer. /api/health reports when
the AI credit runs out. rag_model_benchmark compares chat models on verified
book problems (run on the server; credit ran out mid-run).

Map: only lessons that have questions count toward finishing an island,
lessonIds[0] / nextLessonId is always the next lesson to play, and a finished
island stays finished. The Seyar characters (sigma/delta/pi) are seeded.

Audit fixes, each reproduced before fixing:
- the plan is stored on the order, so paying a monthly order after starting
  a yearly checkout no longer grants a year
- opening checkout no longer demotes an active subscription; renewals extend
  from the current expiry; expired subscriptions are not reported active
- one discount code per order and only before the gateway; a 100% code
  completes the purchase; lost Zarinpal confirmations are re-verified
- the unused /api/payment/webhook "mark paid" endpoint is removed
- XP counts only answers recorded on the server
- 6-digit OTP, each code dies after 5 wrong guesses; POST /api/auth/logout
- a guest who signs up keeps their account and progress (test phones excluded)
- affiliate commission on the first real payment; guests excluded
- parents with several children no longer get a 500
- SQLite runs transactions IMMEDIATE, since it ignores select_for_update
- admin login is rate-limited; display names drop phone numbers and links;
  userQuestion is capped at 2000 characters
- server-side heart enforcement exists behind HEARTS_ENFORCED (off until the
  frontend handles 403 no_hearts)

Not done on purpose: rotating the server's secret key and moving to Postgres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 06:24:13 +03:30

48 KiB
Raw Blame History

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Project

Django + DRF backend for Tadrisino, an educational app (Persian/Farsi). Codebase and API responses are in Persian; comments in source are Persian too.

Reviewing this codebase: comments are claims, not evidence

Comments here were mostly written by earlier agents to justify their own change. They explain intent; they do not prove the code is right. An audit on 2026-09-25 found real bugs directly under comments calling the same code "safe" or "race-free" (plan swap at checkout, select_for_update guards that SQLite silently ignores).

  • When asked to find problems, review adversarially. Judge by behaviour, not by the comment above it: write a throwaway test or call the endpoint and watch what happens. Ask "how would a cheating student, an attacker, or a confused parent break this?" A finding counts once it is reproduced; say which ones were only read.
  • When writing comments, report — don't vouch. Say what the code does and why it was chosen, and name what was not checked. Avoid "this is safe / correct / race-free / the right architecture"; write "guards X; not tested under Y" instead. Record uncertainty plainly so the next reader re-checks rather than trusts.

Commands

source venv/bin/activate
python manage.py runserver
python manage.py migrate
python manage.py test                 # all tests
python manage.py test accounts        # single app
python manage.py test accounts.tests.SomeTestCase.test_something  # single test

Local dev requires DJANGO_DEBUG=1 (enables Swagger auth via DEV_MASTER_OTP/devOtp in responses). DEBUG defaults to False. Production also requires DJANGO_SECRET_KEY, DJANGO_ALLOWED_HOSTS (comma-separated), and ideally CACHE_URL (Redis). Without it the app still boots on per-process LocMemCache and /api/health warns — throttles and admin-driven cache invalidation are then per worker. On the server it is still unset (left to the server admin, 2026-09-24).

No pytest; uses Django's built-in test runner. No linter/formatter configured.

Docker (docker compose up --build) adds a local Loki+Grafana Alloy+Grafana log stack; not a production setup (still runserver, not gunicorn). See comments in docker-compose.yml, docker/alloy/config.alloy, and LOG_DIR in config/settings.py.

API docs: Swagger /api/docs/, Redoc /api/redoc/, admin /admin/.

Architecture

Apps (each owns its own models, one responsibility):

app responsibility key models
accounts identity, sessions, OTP auth User, Profile, Session, OTPCode
content lessons/curriculum Grade, Unit, Node, Lesson, Question, Choice, NodeProgress
progress stats, leaderboard, streaks, gamification characters UserStats, LeaderboardEntry, StreakRecord, UserHearts, Character
ai_tutor AI assistant chat Conversation, Message, MessageFeedback
subscription plans Plan, Subscription
payment orders/transactions (Zarinpal gateway + Cafe Bazaar IAP) Order, Transaction
discount discount codes/offers DiscountCode, Redemption, Offer, UserOffer
notifications notifications Notification
affiliate referral program AffiliateAccount, Conversion
support tickets/bug reports BugReport, Ticket, TicketMessage
family parental control FamilyLink, ParentalSettings, WeeklyReportLog
statuslog generic cross-app status-change log StatusChangeLog
campaigns messaging campaign storage only, no send logic yet MessageCampaign
config project settings, root urls, middleware —

Auth is custom, not Django's. accounts.User is deliberately not AUTH_USER_MODEL — it's an independent business model. Auth is a stateless signed bearer token (django.core.signing, no JWT library) issued/verified in accounts/authentication.py (TadrisinoTokenAuthentication, header Authorization: Bearer <token>). The access token is only a signed user_id (1 h); accounts.Session holds refresh tokens, and POST /api/auth/logout revokes older access tokens via User.tokens_valid_after. Missing/invalid header → anonymous request, not a hard failure; each view's permission class decides if auth is required.

Apps are decoupled by convention: instead of real FKs across app boundaries, code uses loose refs (user_id, object_id + label) so any app can be split out later. Don't add cross-app FKs — follow the existing loose-reference pattern.

Throttling: DRF ScopedRateThrottle with per-endpoint throttle_scope strings, rates centralized in REST_FRAMEWORK['DEFAULT_THROTTLE_RATES'] in config/settings.py. Guest users are throttled by IP (NUM_PROXIES setting controls how many XFF hops are trusted — must match real proxy count in prod or IP spoofing/throttle-bypass is possible). Prod is ArvanCloud → nginx → gunicorn, so the default is 2; it was 1 until 2026-09-24, which made every guest share ArvanCloud's IP bucket. /api/health returns clientIp — it should be your own public IP. Adding a new sensitive endpoint means adding both a throttle_scope on the view and a matching rate entry in settings.

POST /api/ai/ask is real RAG (Qdrant retrieval filtered by chapter → GapGPT generation) whenever OPENAI_API_KEY and QDRANT_API_KEY are set, which they are on the server since 2026-09-23; without them it falls back to a deterministic stub. Entry point is generate_tutor_answer in ai_tutor/views.py; usedChunks in the response names the book chunks the answer was grounded on.

Structured logging: config.middleware.APIRequestLoggingMiddleware (last in MIDDLEWARE, closest to the view) logs every /api/ request as JSON (method/path/status/duration/user, no body/headers/secrets) via config/log_formatters.py.

SQLite is current DB (OPTIONS.timeout=20, and transaction_mode: IMMEDIATE so every atomic() takes the write lock up front — SQLite ignores select_for_update, so without it those guards locked nothing). Tests run on a file DB (.test-db.sqlite3). Postgres migration is still pending.

SMS goes through Kavenegar (KAVENEGAR_API_KEY/KAVENEGAR_SENDER); failures return explicit 503, not a fake success.

Gamification characters (progress.Character, Duolingo-mascot-style): an admin-managed catalog (like subscription.Plan — created via Django admin or seed_test_data, not by users). Unlocking is currently just UserStats.xp >= Character.unlock_xp (no separate ownership table); UserStats.selected_character_id is the user's active pick, set via POST /api/characters/select after that check. First real content beyond the model/plumbing (art assets, more characters, purchase-with-coins, etc.) is expected to land incrementally later.

Payment (Zarinpal): create_order/CreateOrderView never call Zarinpal directly — paymentUrl points at our own GET /api/payment/orders/{id}/pay (PaymentRedirectView), which calls Zarinpal's PaymentRequest API on first visit (storing Order.gateway_authority) and redirects to StartPay; this keeps order creation from depending on Zarinpal's uptime. Zarinpal redirects the user back to GET /api/payment/zarinpal/callback (ZarinpalCallbackView), which always re-verifies server-to-server via payment/zarinpal.py::verify_payment before marking an order paid — a forged Status=OK in the query string alone does nothing. Both payment/zarinpal.py and payment/bazaar.py fail closed (503 / invalid) when their credentials aren't configured, never a fake success.

Where this is heading (the owner's intent)

The owner writes this backend; another developer writes the frontend; a third person administers the server. None of them share a machine, and until recently the two halves were built in parallel without a shared contract — which is why reconciling them cost rework. The goal is not to merge the repos. Two repos, one API contract is the intended shape, and the contract lives in this repo's OpenAPI schema (/api/docs/), not in anyone's head.

What the owner is steering toward, in rough order:

  1. Both halves running against each other, on real content, with nobody hand-editing files on the server. Most of the plumbing for this now exists.
  2. Nothing manual in a release. Adding a dependency, a setting or a service must not add a step someone has to remember. When a change would, fold it into deploy.sh or make startup fail loudly and legibly instead.
  3. Content editable without a developer — questions, curriculum, plan copy, all from the admin panel. A management command run over SSH is a last resort, not a workflow.
  4. Answer quality measurable, not assumed: per-question reports, wrong-answer rates, prompt-version feedback. The senior's stated priority is proving the AI actually teaches, especially where students get stuck.
  5. Scale without drama — thousands of concurrent users should be a non-event. Prefer the boring fix (an index, a cache, a cheap guard) over new infrastructure.

The owner is early in their career and learning the collaboration workflow as they go. Explain the why alongside the what, flag when something is a product decision rather than a coding one, and never assume a step was done just because it was described.

Frontend ↔ backend (read before wiring the two together)

The app is two repos and two domains: the React frontend lives in Tadrisino/MathApp_frontend (deployed at math.tadrisino2.ir, owned by another developer) and this backend serves api.math.tadrisino2.ir. Our typed client for them is src/api/ inside their repo, delivered by pull request — we have read-only Gitea access there, so changes go via a fork + PR, never a direct push.

The one trap that keeps biting. Their App.tsx calls relative paths (fetch('/api/auth/login')). Those resolve against the frontend origin, where nginx answers any unknown path with index.html — so the response is 200 with an HTML body and the code dies on res.json() with Unexpected token '<'. Verify with curl https://math.tadrisino2.ir/api/grades: HTTP 200, HTML. Two fixes exist, both shipped in PR #3: a vite dev proxy (dev works unchanged) and an exported apiUrl() for builds. The better long-term fix is infrastructure, not code — proxy /api on the frontend domain to this backend, which makes everything same-origin and removes CORS entirely. That needs the server admin, not us.

State on 2026-09-23 (checked against upstream main, last commit 2026-09-21): the screens are wired to the API (PR #4/#5 upstream), but App.tsx builds the client with env.VITE_API_URL ?? "/api" — while their own .env.example and client.ts name the variable VITE_API_BASE_URL. The deployed bundle has neither, so it calls the relative /api and gets HTML back: math.tadrisino2.ir cannot reach the backend until either that line falls back to https://api.math.tadrisino2.ir/api or nginx proxies /api. Our PR #3 fixed the same trap and is still open, now likely conflicting with their rewrite. Every path their client calls exists and was driven live against the server. The app also generates a random deviceId per browser, so the seeded frontend-test-device account only loads after localStorage.setItem("deviceId", "frontend-test-device").

Order matters when bringing the stack up: backend first, then frontend. If the frontend is tested while api.math.tadrisino2.ir is down, every screen fails and it looks like the client is broken.

Never hand-write the client from memory. Three of fifteen calls were silently wrong the first time. Dump the real contract with python manage.py spectacular --file schema.yaml --format openapi, then drive the actual client module against a running server (runserver on a spare port + npx tsx) before sending anything. content/tests_frontend_contract.py is the backend-side guard.

Content the frontend expects but nobody has filled in yet: Unit.blurb, Plan.features/badge, Lesson.example/steps. All optional, all editable from the admin panel. Node.icon/color fall back to a deterministic palette so cards are never colourless.

Operations

The whole release, as one line:

ssh -t administrator@78.38.35.249 'cd ~/mathapp-v2 && ./deploy.sh; sudo systemctl restart zareian-mathapp'

It asks for the password twice — once for ssh, once for sudo. Wait for each prompt; pasting several lines at once feeds the queued text to the password prompt and locks you out for three tries.

The trailing restart is separate because sudo inside deploy.sh cannot prompt for a password. The script no longer pretends that worked: it compares the service's start time before and after and exits non-zero when the restart did not happen, so a half-finished deploy can never again look like a successful one. Giving administrator a NOPASSWD sudoers line for just systemctl restart zareian-mathapp would collapse this to a single command; that is the server admin's call, not ours.

./deploy.sh is the whole release: fetch, reset to origin/<branch>, install, migrate, seed content, collectstatic, restart, then it polls /api/health and prints the likely cause if the service does not come back.

It repairs the server rather than stopping at it. A hand-edited file used to make git pull --ff-only fail, which aborted the whole script — and because every later step was skipped, the server silently stayed on old code. Now local modifications (and local commits) are saved to a timestamped patch under .deploy-backups/ and then discarded with git reset --hard origin/<branch>, and the deploy continues. Whatever state the server is left in, one run puts it back on exactly what Gitea has.

git clean is never run there and must never be added. db.sqlite3, .env and .django_secret_key are untracked and live in that directory; reset --hard leaves them alone, clean -fd would destroy the database, the config and every signed-in session.

Settings boot with working defaults (hosts, CSRF, CORS, secret key, cache), so a stale or partial .env on the server no longer breaks startup — env values are merged with those defaults, not replaced by them.

GET /api/health is the outside view: database, unapplied migrations, cache, content counts, security warnings, and recent traffic. It is how you check whether anyone has actually connected without asking a human.

Content: where it lives and how it gets to a server

content/seed/ holds the whole curriculum and question bank as committed JSON — curriculum.json (8 grades, 58 units, 263 nodes, 2,617 lessons) and questions.json (2,463 multiple-choice questions). deploy.sh runs seed_content on every release, so a fresh server fills itself with one command: no Qdrant, no AI credit, no SSH session.

seed_content is idempotent and never deletes. Questions bind to lessons by title path (grade › unit › node › lesson), never by primary key — ids differ between any two databases, and binding by id silently attaches questions to the wrong lesson.

seed_content --export writes the files back from the local DB. It refuses to overwrite a non-empty questions.json from a DB that has no questions (that used to wipe the file silently); use --export --only curriculum when you only mean the curriculum.

Answer quality: the verification layer

Books are read by a vision model from page images, so extraction errors enter there — a misread digit, a dropped decimal separator, a solution attached to the neighbouring problem. These are invisible in retrieval and only surface when something trusts the text.

rag_verify_solutions re-solves every extracted problem independently and compares. LessonChunk.solution_verdict is three-state — agrees / disagrees / unjudgeable — and the verdict also rides in the Qdrant payload under a key retrieval._payload_text does not read, so the tutor's context is unchanged. Current state (2026-09-22, across all 3,517 chunks): 2,466 agree, 354 disagree, 508 unjudgeable, 189 not yet checked — those 189 are the other team's علوم/مطالعات collections, never run through the checker. Question generation only ever reads agrees chunks.

Two things that were learned the expensive way and are already encoded in the prompts:

  • A boolean verdict is wrong. With two states every "I can't tell" becomes "wrong", which reported ~3× the real rate. Read samples before trusting any bulk run.
  • ۳/۱۰۴ in these books is the decimal 3.104, not the fraction 3÷104.

solution itself is never rewritten — the extracted text stays as the record, and the independent answer is stored separately in checked_answer.

The figure gap (largest open data issue)

508 chunks (~14%) reference a figure or table that is not in the text, so they cannot be answered or turned into questions. The vision model saw the figure; it was simply never asked to write it down. rag/services/pdf.py now carries a self-contained rule telling it to inline measurements and labels into the problem text, and re-reading the five math books under that rule is what brought this down from 881 (42%). What is left is concentrated in the three books that predate the rule (سوم/ششم/هفتم, the other team's); recovering them means re-ingesting those pages ($2.7 of API credit).

Quiz assembly

The app plays at node level (the island on the map) but questions hang off lessons, and a lesson here is the finest grain — one problem type from the book, not a class session. GET /api/nodes/{id}/quiz assembles a quiz from a node's lessons, at most one question per lesson. Shuffle is seeded on nodeId:userId, so a refresh returns the same quiz; questionCount is reported separately from requested so a thin node cannot look like an error. 224 of 263 nodes hold a full four-question quiz, and every node in grades 8–12 does; the 39 that fall short are all in grades 3/6/7.

Finishing the quiz finishes the island: POST /api/nodes/{id}/quiz/complete marks the node completed whatever the score (the owner's call on 2026-09-24: keep it simple) and returns nextNodeId. Before it existed, a node only completed when every one of its 8–13 lessons was completed one by one, so a student playing node quizzes was stuck after the first island of every unit. Details in docs/architecture.md.

AI provider notes

OPENAI_BASE_URL points at GapGPT (an OpenAI proxy reachable from Iran) and it exposes ~127 models, including reasoning ones. Two failures look alike and must not be treated alike — is_account_limit() in rag_verify_solutions is the shared guard:

  • 429 initial_usage_limit_exceeded — account cap, hours long. Retrying extends it.
  • 403 insufficient user quota — credit exhausted. Note the spaces: an earlier guard matched insufficient_quota with an underscore, missed it, and burned 770 doomed calls.

/dashboard/billing/usage returns cents and is the only reliable balance signal; the hard_limit_usd in /dashboard/billing/subscription is a placeholder, not a balance.

The key is currently out of credit. Bulk work since then runs on Claude Code subagents instead. Sonnet handles question authoring well (~88% yield); Haiku does not — it cannot reliably pull the final answer out of a solution and produced ~40% broken output.

Known gaps (per README pre-deploy checklist)

  • Still on SQLite, not Postgres.
  • Throttle rates need periodic review against real traffic.
  • DEV_MASTER_OTP (0000) must stay gated behind DEBUG — never enabled in prod.
  • No video content exists yet, though Lesson.kind/video_url can express it.
  • Grades 8–12 are fully covered: every node holds a four-question quiz. 39 of the 145 nodes in grades 3/6/7 (the other team's books) still hold fewer than four; none is empty, so every node still plays.
  • Kavenegar is unconfigured, so POST /api/auth/otp/request returns 503. With DEBUG=0 there is no way to log into the API without creating an OTPCode row in the admin (/admin/accounts/otpcode/add/) — this is how to hand a tester a working login.

The server: read this before running anything on it

api.math.tadrisino2.ir runs on a shared company machine (administrator@78.38.35.249, hostname haghzad), not a box that belongs to this project. Our app is one tenant among many. Also on it, all in production use by other people:

  • Gitea itself (gitea.service) — the company's git server, including this repo's origin. Breaking it means nobody can push or pull anything, anywhere.
  • Mail (postfix, dovecot), DNS (named), mariadb, postgresql@14, rabbitmq, metabase, redis-server, nginx.
  • Other apps: apirag, artinafront, metaverse, celerybeat, and a generic gunicorn.service that is not ours.
  • Other people's home directories: aria, artina, behdad, zahra, git, gitea. They are mode 0750 — unreadable as administrator, trivially readable under sudo. Do not read them. Nothing in this project needs them.

Our account is administrator, and sudo -l reports (ALL : ALL) ALL. It is also in the docker and lxd groups, each of which is root by another route. There is no technical guard rail between a careless command and the whole company's data. The only guard rail is the operator.

Ours is exactly one directory: /home/administrator/mathapp-v2, served by gunicorn-mathapp.service (unix socket, proxied by nginx). Note ~/mathapp also exists, is not a git repo, and is not what runs — do not touch it.

The same unit also answers to zareian-mathapp (a symlink alias in /etc/systemd/system/, added 2026-09-18). Prefer that name in anything a human will type or copy: gunicorn alone is a different team's service on this box, and tadrisino is too generic for a machine where other people's projects may claim it later.

sudo systemctl restart zareian-mathapp     # ours, unambiguous
sudo systemctl restart gunicorn            # NOT ours — someone else's app

Rules that follow from this, and are not negotiable:

  1. Never run a destructive command outside ~/mathapp-v2. No rm -rf, no git clean, no chown -R, no dropping databases, anywhere else. When a command takes a path, write the path out in full rather than relying on the current directory.
  2. Restart only gunicorn-mathapp. systemctl restart gunicorn is a different team's app. deploy.sh no longer guesses that name, and neither should you.
  3. Never delete the project directory or "re-clone fresh". db.sqlite3, .env and .django_secret_key live inside it and are all gitignored. A fresh clone silently destroys the database, the config and every logged-in session. Use git reset --hard origin/main when the tree needs to go back to a known state.
  4. Never git clean there for the same reason.
  5. Read .env only through Django. python -c printing settings.CACHES[...] and booleans is fine; cat .env is not, and neither is pasting its values into a chat.
  6. Take a timestamped backup before editing anything under /etc, and say where you put it.
  7. Disk is the standing risk. It has been sitting at ~95% full (about 6 GB free on a 113 GB volume). Check df -h / before anything that writes a lot — a full disk on this machine takes the mail server and Gitea down with us, not just our app.

Handling the server password (every agent, every time)

Access is a password for the shared administrator account, not a key. Handle it exactly like this — the steps exist for reasons, not ceremony:

printf '%s' '<password>' > "$SCRATCH/.pw"   # scratchpad, never the repo
chmod 600 "$SCRATCH/.pw"
sshpass -f "$SCRATCH/.pw" ssh administrator@78.38.35.249 '<command>'
ssh administrator@78.38.35.249 'sudo -S -p "" <command>' < "$SCRATCH/.pw"
shred -u "$SCRATCH/.pw"                      # immediately after, not "later"
  • Never put the password in the command line. ps on this box shows every process's full argv to every logged-in user, and several other teams have accounts here.
  • Never write it into the repo, a memory file, a commit, or a note. The scratchpad only, and only for as long as the commands take.
  • shred -u as soon as the work is done, not at the end of the session. Recreate it if more work comes; that costs nothing.
  • Never echo it back into the conversation, even partially, and never paste .env values either.
  • Do not create SSH keys, edit sshd_config, or change the password unless the user asks for that specifically. It is a shared account; changing it locks the admin out.

The password reaches an agent only because the user supplies it in that session. If it is not in the conversation, it is not available — say so and ask, rather than hunting for it on disk. If the user wants to stop pasting it into chat, the macOS Keychain is the local fix (security add-generic-password -s tadrisino-server -a administrator -w, then read it back with security find-generic-password -s tadrisino-server -w), and an SSH key is the real one.

DEBUG must never be 1 on this server: it turns on DEV_MASTER_OTP (0000), which accepts any phone number as a valid login, and renders tracebacks with settings in them. DJANGO_DEBUG belongs in .env only — not in the systemd unit, where it once sat as Environment="DJANGO_DEBUG=1" and was saved only by .env happening to override it.

DJANGO_SECRET_KEY is what signs our bearer tokens. It must be a real random value that exists nowhere in git. If the env var is simply absent and DEBUG=0, config/settings.py generates a strong one and stores it in .django_secret_key (mode 0600) — that is the preferred setup, because there is then no key to leak. Never let the server run on the django-insecure-… fallback from config/settings.py: that string is committed here, so anyone who can read this repo can forge a token for any user.

وضعیت ۲۰۲۶-۰۹-۲۵ — این را اول بخوان

کیفیتِ دستیار و گیرِ نقشه (جزئیات و دلیل‌ها در docs/architecture.md). ۵۵۵ تست سبز. این کارها روی سرور هنوز دیپلوی نشده‌اند، مگر خلافش اینجا ثبت شده باشد.

  • پرامپت tutor-v3-stepwise-plain: متنِ ساده (بی LaTeX/markdown، با تورِ ایمنیِ rag/services/mathtext.py)، حلِ کامل و مرحله‌به‌مرحله با وارسی و «✅ جواب»، لحنِ سه‌سطحی، حافظه‌ی ۶ پیامِ آخرِ گفتگو، و حذفِ راه‌حل‌های disagrees از context.
  • جزیره‌ها: فقط درس‌های سوال‌دار شمرده می‌شوند، nextLessonId + lessonIds[0] همیشه درسِ بعدی است، و جزیره‌ی تمام‌شده تمام می‌ماند. بدونِ این، فرانتِ فعلی (که همیشه lessonIds[0] را باز می‌کند) هیچ جزیره‌ای را تمام نمی‌کرد.
  • سه‌یار (sigma/delta/pi، باز شدن با ۰/۱۰۰/۳۰۰ امتیاز) با migration progress.0004؛ کاتالوگ روی سرور خالی بود.
  • /api/health حالا تمام شدنِ اعتبارِ GapGPT را نشان می‌دهد (aiProvider).
  • ⛔ اعتبارِ GapGPT در ۲۰۲۶-۰۹-۲۵ تمام شد (وسطِ rag_model_benchmark؛ مانده ‎−$0.0016). تا شارژ، دستیار برای همه ۵۰۳ می‌دهد. انتخابِ مدلِ چت منتظرِ همین است: هزینه‌ی واقعیِ هر پاسخ با پرامپتِ تازه — gpt-4o-mini ۰٫۰۴۴، gpt-4.1-mini ۰٫۱۱۶، gpt-5-mini ۰٫۱۷۱، gpt-5.4-mini ۰٫۲۹، o4-mini ۰٫۴۱ سنت. دقتشان هنوز اندازه نشده. تصمیمِ صاحبِ پروژه (۰۹-۲۵): فعلاً gpt-4o-mini می‌ماند (ارزان‌ترین). اولین کاندیدا برای سنجش بعد از شارژ: deepseek-v4-flash (قیمتِ رسمی از 4o-mini کمتر، ریاضیِ خیلی قوی‌تر، ولی فارسی و حالتِ بدونِ thinking‌اش اندازه نشده) و gpt-4.1-mini. تیمِ Rag_backend برای جوابِ ریاضی o3-mini استفاده می‌کند. پوشه‌ی موقتِ سنجش روی سرور: ~/mathapp-v2/.bench/ (پس از اتمام پاک شود).
  • فرانت: PRِ ما (#3) بسته شد؛ هرچه فرانت باید بکند فقط در ~/Desktop/کارهای-فرانت-تدریسینو.html است (نه PR). شاخه‌ی محلیِ zareian/ai-chat-readability فقط مرجع است.

بازبینیِ بدبینانه‌ی همان روز و رفع‌ها (۵۷۹ تست سبز؛ جزئیات در docs/architecture.md): پلن روی سفارش (باگِ «ماهانه بده، سالانه بگیر»)، صفحه‌ی خرید اشتراکِ فعال را دست نمی‌زند، تمدید از انقضای فعلی، یک کدِ تخفیف پیش از درگاه و کدِ ۱۰۰٪ = پرداخت، بازسنجیِ خودکارِ پرداختِ تأییدنشده، حذفِ /api/payment/webhook، پورسانتِ affiliate روی پرداختِ واقعی (مهمان بیرون)، امتیاز فقط از جوابِ ثبت‌شده، HEARTS_ENFORCED (خاموش)، OTPِ ۶ رقمی با ۵ حدس، خروج از حساب (tokens_valid_after)، مهمانِ ثبت‌نام‌کننده همان کاربر می‌ماند، والدِ چندفرزندی، سقفِ ورودِ ادمین، پاک‌سازیِ نامِ نمایشی، questionId در دستیار، SQLite با transaction_mode: IMMEDIATE. عمداً انجام نشد (تصمیمِ صاحبِ پروژه: برای ارائه نقطه‌ی شکست می‌سازند): عوض کردنِ DJANGO_SECRET_KEYِ سرور (همه را بیرون می‌اندازد) و مهاجرت به Postgres.

وضعیت ۲۰۲۶-۰۹-۲۴

سرور روی 81348ce به بعد است (سرویس واقعاً ری‌استارت شد)، ۵۳۰ تست سبز، ۶۰ اندپوینت. پروژه در این تاریخ بسته شد؛ فایل‌های تحویل (گزارشِ ۲۲، راهنمای ارائه، zipِ بک‌اند) در ~/Desktop/گزارشات Tadrisino/گزارش-۲۲/ به‌روزند. آن روز کلِ فرانت (MathApp_frontend، upstream main = d223336) صفحه‌به‌صفحه با API زنده تطبیق داده شد و هرچه سمتِ بک‌اند کم بود ساخته شد. کارهای ماندهٔ فرانت در ~/Desktop/کارهای-فرانت-تدریسینو.html برای تیمِ فرانت فرستاده شد.

  • جزیره با آزمونش تمام می‌شود: POST /api/nodes/{id}/quiz/complete (هر نمره‌ای؛ تصمیمِ صاحبِ پروژه). بدونِ آن دانش‌آموز بعد از جزیرهٔ اولِ هر فصل گیر می‌کرد.
  • «از پی بپرس» بدونِ lessonId هم از کتابِ پایهٔ کاربر جواب می‌دهد.
  • استریک و جدولِ رتبه‌بندی زنده‌اند؛ قبلاً هیچ‌جای اپ استریک نمی‌نوشت و جدول فقط با اجرای دستی عوض می‌شد.
  • قلب‌ها مثلِ دولینگو: هر ۵ ساعت یکی، ۲۴ ساعت بعد از اولین قلبِ ازدست‌رفته همه.
  • پلنِ یک‌ساله ۲۹۹٬۰۰۰ تومان با migration ساخته شد؛ تنها مزیتِ واقعیِ اشتراک در کد هنوز «قلبِ نامحدود» است (تصمیمِ محصولی).
  • زیرساخت: NUM_PROXIES=2 (ArvanCloud جلوی سرور است)، gunicorn.conf.py با gthread ۳×۸ و timeoutِ ۱۲۰ (قبلاً سه چتِ هم‌زمانِ دستیار کلِ API را قفل می‌کرد)، Cache-Control: private, no-store روی همهٔ /api/، بازگشت از زرین‌پال به خودِ اپ، refresh token برای مهمان، PATCH /api/notifications/read-all.
  • استریکِ حساب‌های دمو از ۲۰۲۶-۰۹-۲۵ صفر نشان داده می‌شود (آخرین روزِ فعالشان ۰۹-۲۳ است) و لیدربردِ هفتگی بعد از ۰۹-۳۰ از آن‌ها خالی می‌شود؛ این رفتارِ درستِ استریکِ زنده است، نه باگ.
  • سرور هنوز روی کلیدِ django-insecure-… است (۲۰۲۶-۰۹-۲۴ دوباره تأیید شد، فقط به‌صورتِ بولین).
  • دستِ ما نیست: GapGPT کلِ جواب را ~۵ ثانیه نگه می‌دارد و بعد یک‌جا می‌فرستد. CACHE_URL (Redis db1) به تصمیمِ صاحبِ پروژه به ادمینِ سرور سپرده شد. پورتِ ۸۰ِ سرور بدونِ آروان هم جواب می‌دهد (کارِ ادمین).
  • بکاپ‌های پیش از هر دیپلو در .deploy-backups/db-before-*-20260924-*.sqlite3.

وضعیت ۲۰۲۶-۰۹-۲۳ (اعداد کهنه)

پروژه در این تاریخ بسته و تحویل داده شد. سرور روی api.math.tadrisino2.ir زنده است و کامیتِ 5003b8a را سرو می‌کند (سرویس واقعاً ری‌استارت شد، نه فقط فایل‌ها روی دیسک). /api/health می‌دهد: grades: 8, lessons: 2617, questions: 2463 و هیچ مهاجرتِ اعمال‌نشده‌ای ندارد.

  • هر نودِ پایه‌های هشتم تا دوازدهم آزمونِ کاملِ چهارسوالی دارد. پایه‌های سوم/ششم/هفتم کتابِ تیمِ دیگرند: ۳۹ نود از ۱۴۵ کمتر از چهار سوال دارند، ولی هیچ‌کدام خالی نیست، پس روی نقشه جزیره‌ی مرده وجود ندارد.
  • ۴۸۹ تست سبز، check و makemigrations --check تمیز، و spectacular بدونِ خطا و هشدار. ۵۶ اندپوینت.
  • تستِ هم‌زمانیِ campaigns که مدت‌ها flaky بود دیگر flaky نیست — علتش یک باگِ واقعی بود، نه طبیعتِ تست؛ پایینِ همین فایل توضیح داده شده.

مستندات در ۲۰۲۶-۰۹-۲۳ بازسازی شد. README.md حالا درِ ورودی است نه دفترچه‌ی راهنما؛ رویه‌ها رفتند به docs/operations.md (دیپلوی، متغیرهای محیطی، بکاپ و بازیابی، ورودِ توسعه‌دهنده‌ها، قواعدِ سرور)، docs/content-authoring.md، docs/architecture.md و docs/docker.md. قبل از نوشتنِ هر سندِ تازه آنجا را نگاه کن، وگرنه همان حرف در سه جا تکرار می‌شود.

سه چیزِ کوچک که در همان روز اضافه شد و دانستنشان لازم است:

  • TEST_LOGIN_PHONES — فهرستِ «شماره:کد» در .env که ورود بدونِ کاوه‌نگار را ممکن می‌کند. از ۲۰۲۶-۰۹-۲۳ شب، به تصمیمِ صاحبِ پروژه، روی سرور ۷ شماره‌ی دمو دارد (09120000101 تا 09120000107). کدها فقط در راهنمای ارائه‌ی خودِ صاحبِ پروژه‌اند، نه در ریپو. جزئیات در docs/operations.md.
  • RAG روی سرور روشن است (همان شب): OPENAI_API_KEY/QDRANT_API_KEY در .env سرور، /api/health می‌دهد qdrant: true, openai: true. هر سؤالِ کاربرِ واقعی از اعتبارِ GapGPT خرج می‌کند؛ سقفِ ۱۵ در روز برای هر کاربر آن را محدود نگه می‌دارد.
  • دیتای دمو روی دیتابیسِ پروداکشن، همه از مسیرِ واقعیِ viewها ساخته شده: ۵ دانش‌آموز (پایه ۸ تا ۱۲، کاربرهای ۱۰ تا ۱۴)، یک والد (۱۵، وصل به ۱۰)، و حسابِ مهمانِ تستِ فرانت (deviceId = frontend-test-device، کاربرِ ۶) که پربارترین است. هرکدام درس، streak، گفتگوی RAG، تیکت با پاسخ و زیرمجموعه دارند؛ لیدربرد با rebuild_leaderboard از فعالیتِ واقعی ساخته شد. purge_demo_data هیچ‌کدام را نمی‌گیرد و باید پیش از آمدنِ کاربرِ واقعی پاک شوند. بکاپِ پیش از آن: .deploy-backups/db-before-demo-20260923-213830.sqlite3.
  • idِ هر پایه = شماره‌ی پایه (migration‌ِ content.0008): هشتم=۸، دوازدهم=۱۲، و فیلدِ Grade.number. پیش از آن هشتم=۵ و نهم=۱ بود. FKِ Unit، grade_idِ accounts/ai_tutor/rag و target_filterِ کمپین‌ها همراهش منتقل شدند؛ ادمین و API به ترتیبِ کلاس‌اند. شرح در docs/architecture.md بخشِ دیتابیس.
  • صفحه‌های /api/docs/ و /api/redoc/ از قالبِ خودمان رد می‌شوند (templates/tadrisino/) تا متنِ فارسی راست‌به‌چپ رندر شود. قالبِ drf-spectacular دست‌نخورده است و با {{ block.super }} گسترش داده شده.

بکاپ هنوز خودکار نیست. deploy.sh پیش از migrate بکاپ نمی‌گیرد؛ روشِ دستی در docs/operations.md است و در سه دیپلویِ آن روز دستی گرفته شد. خودکار کردنش ارزشمندترین کارِ بعدی‌ست. sqlite3 روی سرور نصب نیست — با venv/bin/python و sqlite3.Connection.backup() بکاپ بگیر، نه با cp.

بخش‌های تاریخیِ زیر به‌ترتیبِ زمان نگه داشته شده‌اند چون درس‌هایشان هنوز معتبر است؛ اعدادِ محتوایی‌شان کهنه‌اند و ملاک همین بخش است.


تستِ flakyِ campaigns یک باگِ واقعی بود (۲۰۲۶-۰۹-۲۲)

ExecuteCampaignRealConcurrencyTests ماه‌ها به‌عنوانِ «flakyِ شناخته‌شده» کنار گذاشته شده بود (~۴۵٪ قرمز روی اجرای کاملِ سوئیت). علتش تصادفی‌بودنِ زمان‌بندی نبود:

execute_campaign با .iterator() روی کاربرها حلقه می‌زد، یعنی کرسرِ خواندن در تمامِ طولِ حلقه باز می‌ماند — حلقه‌ای که وسطش CampaignDelivery می‌نویسد و در شاخه‌ی sms یک فراخوانیِ شبکه‌ای به کاوه‌نگار هم می‌کند. روی SQLite همین کرسرِ باز جدول را برای هر نویسنده‌ی هم‌زمان قفل می‌کند، و قفل تا پایانِ حلقه رها نمی‌شود — پس _retry_on_sqlite_lock هیچ‌وقت نمی‌توانست نجاتش دهد، هر چقدر هم صبر می‌کرد.

حالا هر دو شاخه پیش از اولین نوشتن، نتیجه را با list(...values_list(...)) مادی می‌کنند. بعد از این تغییر: ۲۰ اجرای پیاپیِ خودِ تست و ۳ اجرای کاملِ سوئیت، همه سبز.

⚠️ درسِ کلی: در این کدبیس .iterator() را دورِ حلقه‌ای که می‌نویسد یا I/O بیرونی می‌زند نگذارید. تا وقتی روی SQLite هستیم، این الگو در پروداکشن هم همان قفل را می‌سازد — آنجا به‌شکلِ «database is locked» برای درخواست‌های بی‌ربطِ دیگر.

تاریخچه — وضعیت ۲۰۲۶-۰۹-۱۷ (اعداد کهنه)

The server is live and running current code. main is at the latest commit, the service gunicorn-mathapp was restarted against it, and /api/health reports grades: 6, lessons: 1928, questions: 1441. /api/nodes/1/quiz returns a real four-question quiz. 435 tests green.

What that took, and the lessons worth keeping:

  1. The server ran two-day-old code while every file on disk was current. The pull and seed_content had happened; the service had simply never been restarted, so gunicorn still served the code it loaded on Sep 15. WhiteNoise builds its static-file index at startup too, which is why collectstatic had run and the admin panel still had no CSS. A file on disk is not a deployed file. Check systemctl show <service> -p ActiveEnterTimestamp before believing anything.
  2. deploy.sh reported success while this was true. It health-checked a public URL that the old process answered with 200. It now compares the service start time before and after, and exits non-zero when the restart did not happen.
  3. Two files had been hand-edited on the server (config/urls.py, deploy.sh), both only to add a trailing slash to the health route. That is why git pull kept conflicting. api/health/ is now registered upstream alongside api/health, so the edits were reverted and are not needed again.

Still open, and deliberately not acted on:

  • DJANGO_SECRET_KEY on the server is the django-insecure-… fallback committed in config/settings.py. Auth tokens are signed with it, so anyone who can read this repo can forge one. Fixing it means removing the key from .env and from the systemd unit and letting settings generate one. The owner decided this is the server admin's call, not ours. manage.py check --deploy flags it as security.W009 on every release.
  • CACHE_URL is unset, so rate limiting is per-worker (LocMemCache). Redis is already running on the box at 127.0.0.1:6379; db0 belongs to another app, db1 is free.
  • The frontend is still not wired to any API. src/api/ was merged (PR #1) but is imported nowhere; QuestionScreen in App.tsx still renders four hardcoded multiplication questions, and PR #3 (zareian/api-client-2) is still open upstream. Until someone connects the screens, none of the 1,441 questions are visible to a user.

The frontend's Question type carries a type field that nothing reads, and count_objects in Question.TYPE_CHOICES is an empty shell — no rows, no serializer branch, no rendering anywhere. Do not build content for it without asking first.

تاریخچه — وضعیت ۱۴۰۵/۰۶/۲۹ (۲۰۲۶-۰۹-۲۰) (اعداد کهنه)

سرور روی 3026df0 است، کد و محتوا هر دو منتشر شدند، سرویس واقعاً ری‌استارت شد، و /api/health عدد questions: 1783 می‌دهد. /api/nodes/1/quiz چهار سوالِ واقعی برمی‌گرداند.

۵ کتاب روی Qdrant، همه با دیتابیس همگام: ریاضی هشتم (۵۱۸)، نهم (۴۵۰)، دهم (۴۵۸)، حسابان یازدهم (۳۷۶)، حسابان دوازدهم (۳۲۶). صفحه‌ی گم‌شده: صفر. سه کتابِ تیمِ دیگر (سوم/ششم/هفتم، مجموعاً ۱٬۲۵۲) عمداً دست‌نخورده ماندند.

خرجِ کلِ آن روز ۳٫۴۴ دلار بود. روشِ ارزان و دقیقِ استخراج در docs/extraction-playbook.md نوشته شده — قبل از هر ingest تازه آن را بخوان، مخصوصاً بخشِ انتخابِ مدل.

حسابان یازدهم و دوازدهم: وارد مسیرِ بازی شد (۲۰۲۶-۰۹-۲۱)

کاربر تصمیمِ محصولی را گرفت و هر دو پایه ساخته شدند. seed_curriculum_from_textbook اسکلت را رایگان ساخت — یازدهم ۵ فصل/۲۱ نود/۳۶۹ درس، دوازدهم ۵ فصل/۱۲ نود/۳۲۰ درس — و سوال‌ها با subagentهای Sonnet نوشته شدند: ۵۵۹ سوال (یازدهم ۳۲۲، دوازدهم ۲۳۷). هر ۳۳ نودِ هر دو پایه آزمونِ کاملِ چهارسوالی دارند.

GRADE_STYLE حالا برای یازدهم/دوازدهم رنگ دارد (فیروزه‌ای و نیلی، عمداً جدا از شش پایه‌ی عمومی چون حسابان فقط کتابِ رشته‌ی ریاضی‌فیزیک است).

روالِ ساختِ سوال با subagent، حالا ابزار دارد

generate_questions_from_chunks --export DIR جفت‌های (درس، مسئله‌ی تأییدشده) را در فایل‌های JSON می‌ریزد و هیچ فراخوانیِ پولی نمی‌کند؛ کنارشان یک *-RULES.txt با عینِ همان PROMPTی که به مدلِ پولی می‌رفت می‌نویسد، تا کیفیتِ دو شاخه از هم جدا نیفتد. خروجیِ subagent با content.question_io وارد می‌شود (فایلِ بد هیچ‌چیز نمی‌نویسد).

بازده روی ۷۷۸ ورودی: ۶۸۰ سوال، ۸۷٪ — بقیه عمداً رد شدند، بیشترشان مسئله‌هایی که بدونِ شکلِ کتاب خودکفا نمی‌شوند. چند subagent خطایِ حسابِ خودِ کتاب را هم گرفتند (راه‌حلی که √ را ۶ نوشته بود ولی ۶٫۵ بود؛ راه‌حلی که می‌گفت حد وجود ندارد درحالی‌که داشت) — یعنی solution_verdict='agrees' تضمینِ کامل نیست، فقط فیلترِ اول است.

باگی که همان‌جا پیدا و اصلاح شد: اجرای دوم با --per-lesson بالاتر همان chunkِ قبلی را دوباره تحویل می‌داد و سوالِ تقریباً تکراری می‌ساخت. حالا _targets به اندازه‌ی سوال‌هایی که درس از قبل دارد از ترتیبِ قطعیِ chunkها می‌پرد. این خطا نه لاگ می‌داد، نه در import گیر می‌افتاد (چون صورتِ سوال بازنویسی می‌شود و رشته‌اش یکی نیست).

⚠️ سقفِ هفتگیِ Sonnet واقعی است و ناگهانی می‌رسد. ۲۰ subagentِ هم‌زمان (هرکدام ~۹۵ هزار توکن برای ۳۰ سوال) آن را در یک نشست پر کرد. اگر کارِ انبوه داری، دسته‌ها را کوچک‌تر بگیر و بینشان نتیجه را وارد کن — خوشبختانه بیشترِ agentها فایلشان را نوشته بودند و فقط در تماسِ آخر خوردند، پس چیزی از دست نرفت.

چیزهایی که آن روز کشف شد و تکرارشان گران است

  • مدل را اندازه بگیر، از جدولِ قیمت نخوان. قیمتِ GapGPT با قیمتِ رسمی یکی نیست: gpt-4.1-mini هم از gpt-4o ارزان‌تر بود هم دقیق‌تر، و gemini-3.6-flash برخلافِ انتظار سه برابرِ gpt-4o گران درآمد. OPENAI_VISION_MODEL=gpt-4.1-mini الان در .env است و هر پنج کتاب با همان خوانده شد.
  • ساختِ سوال را با subagent انجام بده، نه با API. generate_questions_from_chunks پیش‌فرضش o4-mini است و ~۰٫۷۷ سنت برای هر سوال می‌گیرد (چهار برابرِ تخمینِ خودش، چون مدلِ ریزنینگ است). همان کار با subagentهای Sonnet رایگان است و بازده‌اش ۹۲٪ بود. روالش: جفت‌های (درس، مسئله‌ی تأییدشده) را به JSON بده، subagent خروجی بنویسد، و با content.question_io واردش کن.
  • --recheck در rag_verify_solutions همه‌چیز را دوباره می‌فرستد، نه فقط موارد تازه. برای بازبینیِ یک زیرمجموعه، رأیشان را خالی کن و اجرای عادی بزن.
  • شمارنده‌ی صفحات حینِ --refresh معنی ندارد — هر صفحه اول پاک و بعد خوانده می‌شود، پس عدد دورِ کل نوسان می‌کند. پیشرفت را از IngestedPage.created_at بسنج.
  • در untilِ انتظار، ps | grep خودش را می‌شمارد و حلقه هرگز تمام نمی‌شود. از pgrep -f استفاده کن.
  • کد و محتوا دو مسیرِ جدا دارند. db.sqlite3 در gitignore است؛ سوال‌ها فقط از راهِ seed_content --export --only questions و کامیتِ content/seed/questions.json به سرور می‌رسند. پوش کردنِ کد به‌تنهایی محتوا را نمی‌برد.

CORS

پورت‌های توسعه دیگر شمرده نمی‌شوند؛ CORS_ALLOWED_ORIGIN_REGEXES هر پورتی روی localhost/127.0.0.1 را می‌پذیرد. اگر باز هم گزارشِ CORS رسید، اول بپرس Hard Refresh زده‌اند یا نه — مرورگر نتیجه‌ی preflight را کش می‌کند.