# 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. ## No silent fallbacks, no speculative backward compatibility Agents keep adding "helpful" fallbacks (`x or default`, `except: pass`, accepting an old field name "just in case") and they cause the hardest bugs here, because the failure never shows up anywhere. An audit on 2026-10-03 found and reproduced these: - With RAG keys missing from `.env` and `DEBUG=0`, `/api/ai/ask` answered **200** with «این یک پاسخ نمونه (stub)… OPENAI_API_KEY را تنظیم کنید» and still used up the student's daily quota. It now returns 503 (`StubNeverReachesProductionTests`). - A paid order whose plan had been deleted quietly became 30 days (a yearly buyer got a month). It still activates, but now logs ERROR. - `?grade=` on `/api/home` was an alias "for clients already out there". No such client existed. Removed, and sending it is a 400, not a silent fallback to the user's own grade. - `resolve_grade_title` had `except Exception: pass` (now logged); `curriculum_io` read `intro_text` that no file ever contains (removed). On the server since 2026-10-03 12:02 (`7307bfc`, service really restarted; backup `.deploy-backups/db-before-no-fallbacks-20261003-120205.sqlite3`). Checked from outside: `/api/home?grade=8` → 400. The server had RAG configured and no paid order without a plan. Rules: 1. **Fail loudly over guessing.** A missing setting, row or field in production should be an error the user and `/api/health` can see, not a default that looks like success. A default is fine only when it is a real product choice. When you add one, say in the comment why that value is right, not "just in case". 2. **Demo/stub paths only behind `DEBUG` or `RAG_FORCE_MOCK`.** Never reachable on the server. 3. **No backward compatibility without a named client.** Before keeping an old field, param or code path, check who sends it (`~/Desktop/MathApp_frontend/src/api/`, the server DB). The only API client is that frontend; there is no Android app yet. 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`. Swallowing is acceptable only where the comment names the bigger thing it protects (e.g. `affiliate/services.py`: commission failure must not undo the user's payment) and it still logs. 5. **Removing a fallback means making the old input fail visibly** (400/503), not silently routing it somewhere else. 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 (server still uses OAuth); `birthYear` alone in `PATCH /api/user/profile` (frontend type still allows it); `totalQuestions` accepted but ignored (frontend sends it); orders with no `plan_id` from before 2026-09-25 (none paid on the server on 2026-10-03); orders without the `question_credits`/`hearts_days`/ `duration_days` snapshot (`payment.0011`) grant and refund from the plan's current values. ## Before any real user: read `docs/security-before-production.md` The server is deliberately left in demo mode for the internship presentation: seven fixed-code demo logins exist, demo users sit in the production DB, and the leaderboard and `/admin/` are public (`/api/health` shows only ok/error without `X-Internal-Key`; Swagger/ReDoc/schema need an admin session since 2026-10-08). (The token signing key is no longer the committed one: replaced 2026-10-07, see the status block below.) Each is listed there with its risk, why it was left, and the fix. **Do not treat this deployment as production-ready until every item there is closed**, and when one is closed, move it to that file's «حفاظ‌هایی که هست» table with the test that holds it (the date lives in the commit message; the owner wants docs to describe the current state only). ## Commands ```bash 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 since 2026-10-08 it points at our own Redis (`zareian-redis.service`, port 6390, localhost, password-protected, 256 MB `allkeys-lru`, no persistence), never the company's Redis on 6379. Its outage is logged and rate limits stop applying (fail-open); not tested under real Redis failure on the server. 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` | | `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 `). 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`. Requests without a login 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 visitor 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`. **Postgres** is the server's DB since 2026-10-08 (own cluster `14/zareian`, port 5440, localhost only; DB `zareian_mathapp`, role `zareian_app`; selected by `DB_NAME`/`DB_USER`/`DB_PASSWORD`/`DB_HOST`/`DB_PORT` in `.env`; passwords in `~/mathapp-v2/.pg_password`, `.redis_password`). It is separate from the company's `main` cluster on 5432 — never touch that. Without `DB_NAME` the app falls back to SQLite (local dev, tests); both run the full suite green. Data was moved with `dumpdata`/`loaddata`; model-count comparison matched. Guards that relied on SQLite's `IMMEDIATE` now rely on real row locks; concurrency tests pass on Postgres, not load-tested. `deploy.sh` backs up with `pg_dump -Fc` (restore into a scratch DB not tested). Operator guide: `~/Desktop/گزارشات Tadrisino/گزارش-۲۹/راهنمای-Postgres-و-Redis.html`. The old `db.sqlite3` stays on the server as a frozen pre-move copy. **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 `Tadrisino/MathApp_frontend` (deployed at `math.tadrisino2.ir`) and this backend (`mathapi.tadrisino2.ir`; it was `api.math.tadrisino2.ir` until 2026-09-26, whose DNS record no longer exists). **Since 2026-09-26 the owner maintains both:** the frontend developer is on leave and `AliReza_Zareian` has `write` on the frontend repo (branch protection on its `main` unknown — not visible without admin). Local clone: `~/Desktop/MathApp_frontend`. **State on 2026-09-27:** the rebuilt frontend (typed, modular; its README is the map) is on `main` and **live**. It is served by nginx from `~/mathapp_frontend_new/dist` on the same shared box; release with `cd ~/mathapp_frontend_new && ./deploy.sh` (fetch, `npm ci`, type-check, build into `dist.next`, swap; previous build kept in `dist.prev`). The owner authorized pushing the frontend straight to `main` and deploying it without asking. Verified live with the demo accounts (guest 6, student 10, parent 15): all screens load their data. Real SMS delivery and a real Zarinpal payment are still unverified. **Domains are configuration, not code.** Backend: only `PUBLIC_BASE_URL` and `FRONTEND_URL`; hosts, CSRF, CORS, Zarinpal callback/return, Bazaar redirect and the `deploy.sh` health URL derive from them (`config/tests_domains.py` guards it). Frontend: `dist/config.js` at runtime, else `VITE_API_BASE_URL` from `.env.production`. A domain move is expected (Zarinpal `-14`); the procedure is in `docs/operations.md` («عوض کردنِ دامنه»). **Never hand-write the client from memory.** Three of fifteen calls were silently wrong the first time, and the rebuilt client found two more the old one had (`POST` instead of `PATCH` on notifications, a dropped guest refresh token). Read the contract from `python manage.py spectacular --file schema.yaml --format openapi` and check shapes against a running server. `content/tests_frontend_contract.py` is the backend-side guard. **Content the frontend shows but nobody has filled in yet:** `Unit.blurb`, `Plan.features`/`badge`, `Lesson.example`/`steps`. All optional, all editable from the admin panel. ## Operations **The whole release, as one line:** ```bash 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/`, 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/`, 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, and since 2026-10-03 it does not undo admin work: a `SeedRecord` ledger (`content/seed_sync.py`) remembers what was delivered, so anything the admin later deleted, renamed or re-worded is not recreated, and lesson `intro_text` is refreshed from the file only if nobody edited it. New rows in the files still arrive; an empty DB ignores the ledger. 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`. Since 2026-10-07 13:42 (live, `7ecb337`) it refuses a node that is locked on the map (403 `node_locked`, same rule as `/api/home`, `content.views.node_is_unlocked`), and a user who has not answered every quiz question (409 `answers_missing`; the required count is `max(client totalQuestions, min(4, questions in node))`, so the client cannot understate it). A node or lesson with no questions has nothing to answer: it passes when unlocked (no stars, no XP) so it does not block the chain, and is still refused when locked. `POST /api/lessons/{id}/complete` has the same lock and answer rules. Before that, only a user with zero hearts and no answers was refused; anyone else finished any island, locked or not. Verified by 860 local tests and by outside probes (health, 401s, throttle); **not** verified by a real login on the server, so do one manual quiz after the next deploy. 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 **Measured prices live in `docs/model-prices.md`** — a ledger of what GapGPT actually billed per model, with dates and evidence. When a price changes, add a row there; never overwrite the old one. `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. **Credit was topped up again by 2026-09-25 afternoon** (live tutor answered, `aiProvider: ok`); it has run dry twice before, so probe before any bulk run. 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) - Postgres is live; no off-machine backup yet (owner waiting for the destination from the senior). - 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 live on the server since 2026-09-25 and **delivers since 2026-09-27**: OTP goes through the approved template `SMSVerify` (`verify/lookup`, `KAVENEGAR_OTP_TEMPLATE` in the server `.env`), and a real code reached the owner's phone (message `894247231`, status 10). Every OTP costs real credit. Plain `sms/send` from this account's line is cancelled (status 13), so never unset the template. An `OTPCode` row made in the admin (`/admin/accounts/otpcode/add/`) still hands a tester a login without SMS. ## The server: read this before running anything on it `mathapi.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. ```bash 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. 8. **Touch only our own files and our own service.** Do not connect to, write into or "just use" anything another team runs on this box — their Redis (even an empty database), databases, queues, nginx or firewall rules. Those are requests to the server admin (listed in the launch report), not things to do ourselves. Owner's rule, 2026-10-07, after an agent pointed `CACHE_URL` at the shared Redis. ### 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: ```bash printf '%s' '' > "$SCRATCH/.pw" # scratchpad, never the repo chmod 600 "$SCRATCH/.pw" sshpass -f "$SCRATCH/.pw" ssh administrator@78.38.35.249 '' ssh administrator@78.38.35.249 'sudo -S -p "" ' < "$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.** ## اشتراک و دیوارِ پرداخت — روی سرور از ۲۰۲۶-۱۰-۱۰ ۰۱:۱۹ (بک‌اند `64c4c09`، فرانت `a8a5351`) - **فقط فصلِ اولِ هر پایه رایگان است** (`content.views.unit_is_free`). بیرون از آن، بی‌اشتراکِ فعال، سوال‌های درس، آزمونِ جزیره، ثبتِ جواب و پایانِ درس/جزیره ۴۰۲ `subscription_required` می‌دهند؛ `/api/home` برای هر فصل `requiresSubscription` دارد. تست: `content/tests_paywall.py`. - **اشتراک (`subscription.0013`):** یک‌ماهه ۴۹۸، سه‌ماهه ۷۹۸، شش‌ماهه ۱٬۲۹۸ هزار تومان؛ همه‌ی فصل‌ها، قلبِ نامحدود و ۳ سوال در روز از دستیار. **دستیار جدا فروخته می‌شود** (بسته‌های سوال روی فروش ماندند). رایگان ۲ سوال در روز (`AI_DAILY_QUOTA_FREE`). حساب: بدترین حالتِ شش‌ماهه با سقفِ ۳ ≈ ۲۹٪ِ سهمِ ماهانه‌ی قیمت؛ با سقفِ ۸ ≈ ۷۸٪ بود. - **اشتراک داخلِ اپِ بازار خریدنی نیست** (SKU ندارد، `PurchaseView` فقط بسته را از بازار می‌فروشد). - حساب‌های دمو اشتراک ندارند، پس از فصلِ دوم به بعد برایشان قفل است. - از سرور، `git fetch` از Gitea در این روز کار کرد (بی `DEPLOY_BUNDLE`). ## شکلِ ثابتِ درس‌ها (`Lesson.figures`) و کشِ فرانت — روی سرور از ۲۰۲۶-۱۰-۰۷ ۱۶:۴۸ (`b93f43e`) **شکلِ درس یک بار ساخته و تأیید می‌شود و بعد ثابت است؛ هنگامِ نمایش هیچ فراخوانیِ مدلی نیست.** `Lesson.figures` فهرستِ `{where: intro|example|step, index?, diagram}` است (`index` برای step از صفر). `GET /api/lessons/{id}/intro` آن را برمی‌گرداند و فرانت (`LessonScreen`) با همان `Diagram` دستیار می‌کشد. شکل در `content/seed/curriculum.json` هم می‌رود (`figures`)، و `seed_content` آن را **فقط در خانه‌ی خالی** می‌نشاند (مثلِ `example`/`steps`). پس برای عوض‌کردنِ شکلِ درسی که روی سرور هست باید `figures` آن درس را خالی کرد و دوباره دیپلو کرد؛ **شناسه‌ی درس روی سرور با لپ‌تاپ فرق دارد** (۳۰۰۳..۳۰۰۶ محلی = ۲۲۹۸..۲۳۰۱ سرور)، پس با شناسه‌ی محلی روی سرور کار نکن. - **ساخت:** `python manage.py gen_lesson_figures --grade 12 --limit 4 [--lesson ID] [--redo] [--dry-run]`. همان مدلِ چت و کاتالوگِ `` و اعتبارسنجِ `rag.services.diagram.validate` (مسیرِ ارزانِ دستیار). مدل می‌تواند `[]` بدهد؛ همه‌ی درس‌ها شکل نمی‌خواهند. شماره‌ی قدم‌ها در پرامپت ۱‌به‌بعد است (همان شماره‌ای که کاربر می‌بیند) و کد یکی کم می‌کند؛ با شمارش از صفر شکل زیرِ قدمِ اشتباه می‌نشست. - **هزینه (سنجیده ۲۰۲۶-۱۰-۰۷):** هر درس ≈ ۳٫۵ هزار توکنِ ورودی و ۰٫۱ تا ۰٫۶ هزار خروجی. صورتحسابِ GapGPT برای یک فراخوانی ≈ ۰٫۰۳ سنت داد و فرمولِ رسمیِ `gpt-4.1-mini` ≈ ۰٫۱۵ سنت؛ این دو نمی‌خوانند و علتش پیدا نشد. برآوردِ ۳۰۰۰ درس ۱ تا ۴٫۵ دلار. - **باید با چشم دید.** در همین چهار درس مدل خط‌شکسته را با `line` (بی‌پایان) کشید، یک بار فقط نقطه بی‌خط داد، نمودارِ تابع را با بازه‌ی بزرگ له کرد، و برای قدمِ ۳ شکل را زیرِ قدمِ ۲ گذاشت. چهار درسِ اولِ حسابانِ ۱۲ (فصل «تابع») بازبینی شدند؛ هیچ درسِ دیگری شکل ندارد. برچسب‌هایی مثلِ «A۲» و «g(x)=f(x)+۲» کنارِ لبه کمی ناجورند و رفع نشدند. - **ایرادِ محتوا، نه شکل:** مثال و قدم‌های درسِ «تابع درجه سوم، توابع یک‌نوا…» کپیِ مثالِ «تبدیل نمودار توابع» است و با عنوانش نمی‌خواند (از `fill_lesson_texts`). شکل‌هایش با همان متن می‌خوانند. - **بازبینیِ زنده بی‌پیامک:** با یک `OTPCode` که خودت برای یکی از دموها (مثلاً `09120000105`، پایه‌ی ۱۲، کاربر ۱۴) در دیتابیسِ سرور می‌سازی (کدِ تصادفی، ۵ دقیقه، از stdin نه از argv) وارد شو، توکن را در `localStorage['tadrisino.tokens']` (`{token, refreshToken}`) بگذار و با `puppeteer-core` (در scratchpad، نه ریپو) از «نقشه ← فصل ← جزیره ← اول درس‌هاش رو بخونم» برو. آخرش `POST /api/auth/logout`. توکنِ دسترسی را چاپ نکن (یک بار چاپ شد). **کشِ مرورگر:** سینیور شکلِ درس‌ها را در یک مرورگر نمی‌دید و در مرورگرِ دیگر می‌دید؛ API و bundleِ زنده درست بودند. علتِ احتمالی (مستقیم دیده نشد): `index.html` فقط `etag` داشت و بی `Cache-Control` از کش بالا می‌آمد. در `/etc/nginx/sites-available/mathapp-front` (فقط سایتِ ما؛ بکاپ کنارش `…bak-20261007`) حالا `Cache-Control: no-cache` برای همه و `immutable, max-age=1y` برای `/assets/` هست (`nginx -t` و `reload`، نه restart). از بیرون `index.html` و مسیرهای اپ `no-cache` دادند؛ هدرِ `/assets/` را نشد دید چون ArvanCloud فایل‌های js را خودش نگه می‌دارد (`x-cache: HIT`). **`config.js` هم از کشِ آروان می‌آید**؛ با عوض‌شدنِ دامنه‌ی API کشِ آن را در پنلِ آروان پاک کن (نه فقط `index.html`). مرورگری که پیش از این تغییر `index.html` را کش کرده یک بار باید «Clear site data» بزند. بارِ هم‌زمانِ هزار نفر آزموده نشد؛ استدلال این است که فایل‌های سنگین از CDN می‌آیند و `index.html` ۳۰۴ می‌گیرد. ## وضعیت ۲۰۲۶-۱۰-۰۷ — روی سرور (`2779270`، ری‌استارت ۱۴:۱۶) بکاپ‌های همان روز در `.deploy-backups/`: `env-before-secretkey-redis-20261007-135850`، `unit-gunicorn-mathapp-before-20261007-135850.service`، `db-before-deploy-20261007-13*.sqlite3`. - **کلیدِ امضای توکن عوض شد** (بند ۱ سندِ امنیتی). از `.env` و از یونیتِ `gunicorn-mathapp.service` برداشته شد (یونیت فقط `DJANGO_ALLOWED_HOSTS` و `EnvironmentFile` دارد؛ `DJANGO_DEBUG` هم از یونیت رفت و فقط `.env` می‌ماند). برنامه خودش کلید را در `.django_secret_key` (۶۰۰) ساخت. سنجش: توکنِ جعلی با کلیدِ داخلِ ریپو برای کاربرهای ۱۰، ۱۱، ۱۲ پیش از تغییر `200` و بعدش `401` گرفت. توکنِ refresh در دیتابیس است، نه امضاشده، پس نشست‌ها نمی‌میرند و فرانت بی‌صدا توکنِ تازه می‌گیرد؛ این را با ورودِ واقعی **نسنجیدم**. - **Redis: وصل نیست، عمداً** (تصمیمِ مالک ۲۰۲۶-۱۰-۰۷). Redisِ روی آن ماشین مالِ تیم‌های دیگر است و به آن، یا به هیچ حافظه‌ی موقت و سرویسِ دیگرانی، وصل نمی‌شویم. `CACHE_URL` در `.env` خالی و کش `LocMemCache` است، پس تا Redisِ اختصاصیِ خودمان نیست هر سقفِ مبتنی بر کش ≈ ۳ برابرِ عدد است (۳ worker)؛ سقف‌های دیتابیسی (پیامک، حدسِ کد) درست‌اند. «Redisِ اختصاصی» در گزارشِ لانچ یک تصمیمِ باز است (کنارِ Postgres). کد برای آن روز آماده است: `config.cache.ResilientRedisCache` (فقط با `CACHE_URL` فعال می‌شود) قطعیِ Redis را ۵۰۰ نمی‌کند، ERROR لاگ می‌کند، و تا برگشتنش سقف‌های مبتنی بر کش اعمال نمی‌شوند (fail-open) و `/api/health` ۵۰۳ می‌دهد؛ ۱۱ تستِ واحد دارد، از جمله redis-serverِ محلی که وسطِ کار می‌میرد و برمی‌گردد. یک بار ۲۰۲۶-۱۰-۰۷ چند ساعت به آن Redisِ مشترک وصل بود و قطع و کلیدهایمان پاک شد؛ آن‌جا آزموده شد که کش روی سرور کار می‌کند. **آزموده نشد:** قطعیِ واقعیِ Redis، و رفتارش زیرِ فشارِ حافظه. - **آزمونِ زنده با کاربرِ آزمایشی** (۳۲ از ۳۲ سبز؛ کاربر و داده‌اش پاک شد، شمارش‌ها با پیش از آزمون برابر): ورود با OTP، توکنِ تازه، تمدید (توکنِ تمدیدِ قدیمی ۴۰۱)، جزیره‌ی قفل ۴۰۳، بی‌جواب ۴۰۹، ادعای کمتر از تعدادِ سوال ۴۰۹، پایانِ جزیره و درس بعد از جواب، Idempotency-Key، و خروج. رابطِ کاربریِ فرانت در مرورگر رانده **نشد** (همان فراخوانی‌ها با API زده شد). - پایانِ درس/جزیره بی‌جواب و روی جزیره‌ی قفل پذیرفته نمی‌شود، ۱۵ مسیر سقفِ نرخ گرفتند، `deploy.sh` پیش از `migrate` بکاپ می‌گیرد و سلامت را از سوکتِ gunicorn می‌سنجد. - نسخه‌ی کتابخانه‌های `requirements.txt` قفل شد (نسخه‌های روی سرور). ## وضعیت ۲۰۲۶-۱۰-۰۵ (عصر) — ورودِ مهمان از ریشه حذف شد **روی سرور از ۲۰۲۶-۱۰-۰۵ ۱۸:۳۹** (بک‌اند `5b7e6fb`، سرویس واقعاً ری‌استارت شد، `accounts.0008` اعمال شد؛ فرانت `0db6526`). بکاپ‌ها: `.deploy-backups/db-before-guest-removal-20261005-181900.sqlite3`، `db-before-phone-swap-20261005-182656.sqlite3`، `env-before-ali-phone-20261005-183839`. از بیرون سنجیده شد: `POST /api/auth/session` ۴۰۴، `/api/health` ok، ۳٬۶۷۹ سوال. - **تنها راهِ ورود شماره و کدِ پیامکی است.** `User.is_guest`/`device_id`، `Session.device_id`، throttleهای `GuestAware*`، `DEMO_GUEST_DEVICE_IDS` و حذفِ حساب با تایپِ «حذف» رفتند؛ حذفِ حساب فقط با کدِ پیامکی است. migrationِ `0008` تا مهمانی مانده باشد عمداً می‌شکند. ۱۵ مهمانِ سرور با `delete_users` پاک شدند (۸ کاربر ماند). - **«علی رضایی» (کاربر ۱۰)** حالا به شماره‌ی مالکِ پروژه وصل است؛ شماره‌ی دموی قبلی‌اش از `TEST_LOGIN_PHONES` برداشته شد. - **۷۲۴ سوالِ تازه** برای درس‌هایی که سوال نداشتند (پایه‌های ۳ تا ۱۱) از مسئله‌های تأییدشده نوشته شد و هرکدام را حلِ کورِ جدا سنجید (۱۲ ناهمخوان پاک شد). ۵۴۵ درس هنوز سوال ندارند چون مسئله‌ی تأییدشده‌ی خودکفایی در کتاب نیست. حسابانِ دوازدهم کامل است (۳۲۶ chunk در DB و Qdrant)؛ ۱۵ درسش بی‌سوال ماند. - چک‌لیستِ لانچ: `~/Desktop/گزارشات Tadrisino/گزارش-۲۸/چک‌لیست-لانچ.html`. ## وضعیت ۲۰۲۶-۱۰-۰۵ — بازبینیِ امنیتیِ پیش از تحویل **روی سرور از ۲۰۲۶-۱۰-۰۵ ۰۵:۰۷** (بک‌اند `a973c41`، سرویس واقعاً ری‌استارت شد، `payment.0011` اعمال شد؛ فرانت `e850f6d`). بکاپ: `.deploy-backups/db-before-security-review-20261005-050627.sqlite3`. از بیرون سنجیده شد: callbackِ بی‌Authority ۴۰۰، حذفِ مهمانِ نمایشی ۴۰۳، `characterId="²"` ۴۰۰، Swagger روی `swagger-ui-dist@5.33.1`. شش بررسی‌کننده‌ی موازی (هر کدام یک بخش، فقط محلی) کد را بدبینانه وارسی کردند؛ هر یافته با تست بازتولید شد. هر ایرادی که رفعش ارائه را به خطر نمی‌انداخت رفع شد و در `*/tests_security_review.py` حفاظ دارد (۸۳۲ تست سبز)؛ مهم‌ترینش actionهای لغو/بازپرداخت که برای Supervisorِ فقط‌خواندنی هم اجرا می‌شدند، و ورودِ فایلِ سوال برای هر کارمند. آنچه ماند (تصمیمِ طراحی یا خطرِ دمو) مواردِ ۲۴ تا ۳۱ِ `docs/security-before-production.md` است؛ گزارشِ سینیور همان `گزارش-۲۷/فهرستِ-مسائلِ-امنیتی.html` (۲۳ مورد). **رفعِ کلیدِ امضا حالا امن است:** فایلِ `.django_secret_key` اتمی ساخته می‌شود؛ پیش از آن سه worker در ۱۶ از ۲۰ بار کلیدِ جدا می‌ساختند. ## وضعیت ۲۰۲۶-۱۰-۰۳ (عصر) — پایه‌ی چهارم و پنجم، خریدِ بازار از اپ، بهبودِ UI **روی سرور از ۲۰۲۶-۱۰-۰۴ ۰۳:۰۵** (بک‌اند `8cb326b`، سرویس واقعاً ری‌استارت شد، `subscription.0012` اعمال شد؛ فرانت `f8cf86e`). بکاپ: `.deploy-backups/db-before-grades45-20261004-025926.sqlite3`. روی DBِ سرور: چهارم ۲۸ جزیره / ۲۰۴ سوال، پنجم ۲۸ / ۱۹۴، هیچ جزیره‌ی زیرِ ۴ سوال نیست؛ `/api/grades` ده پایه می‌دهد. هیچ بسته‌ای هنوز `bazaar_sku` ندارد. `.deploy-backups/` آن روز ۳۴ فایل و ۸۰ مگابایت بود (دیسک ۸۳٪)؛ پاک نشد. - **چهارم و پنجم** (درخواستِ دکتر: «از سوم به بعد»): `MATH_4`/`MATH_5` در `rag/services/textbooks.py` (فهرست و offset از خودِ PDF؛ پنجم چاپِ ۱۴۰۵ با offset صفر). ingest با gpt-4.1-mini ≈ ۰٫۷ دلار (۵۹۰ + ۵۹۴ chunk)، verify ≈ ۰٫۲؛ هر دو روی Qdrant. هر پایه ۷ فصل و ۲۸ جزیره؛ **۲۰۴ و ۱۹۴ سوال** با subagentِ Sonnet (حداکثر ۸ نامزد برای هر جزیره، نه همه‌ی ۵۷۳ جفت)، و هر جزیره دستِ‌کم ۴ سوال. **همه‌ی ۳۹۸ سوال یک بار کور دوباره حل شدند** (subagentِ دیگر، بی‌دیدنِ کلید): ۳ مورد واقعاً غلط/مبهم بود و اصلاح شد (یکی کلیدِ غلط: «عدد ششمِ ۱۲۰، ۲۴۰، ۳۶۰» = ۷۲۰ نه ۶۰۰). دو جزیره‌ی تقارنِ پنجم در کتاب فقط رسم روی شطرنجی دارند؛ ۱۲ سوالِ بی‌شکل برایشان نوشته شد. `seed_content` روی DBِ خالی آزموده شد. ⚠️ `fill_lesson_texts` هم‌زمان با `rag_verify_solutions` روی SQLite «database is locked» داد و verify را نیمه‌کاره گذاشت (بی‌صدا تا شمردنِ رأی‌ها)؛ دو فرمانِ نویسنده را هم‌زمان اجرا نکنید. - **خریدِ بازار از مسیرِ کاربر:** تا امروز سفارشِ بازار فقط با کلیدِ داخلی ساخته می‌شد و `paymentMethod` هر مقداری را بی‌صدا نادیده می‌گرفت. حالا `Plan.bazaar_sku` (ادمین، `subscription.0012`) و `POST /api/subscription/purchase` با `paymentMethod: "bazaar"` (فقط بسته)؛ کدِ تخفیف روی سفارشِ بازار ۴۰۰، Idempotency-Key با روشِ پرداختِ دیگر ۴۰۹. فرانت: `src/lib/bazaar.ts` (پل به اپِ اندروید) و `android/` (پوسته‌ی WebView + Poolakey) — **هرگز build نشده** (این ماشین Android SDK ندارد). از سنیور لازم: اسمِ پکیج، کلیدِ RSA، SKUِ بسته‌ها در پیشخان. شرح: `docs/operations.md` «فروشِ بسته در کافه‌بازار». - **UI** (فرانت `f8cf86e`): رنگِ متنِ کم‌رنگ ‎#727299 (کنتراست ۲٫۶ → ۴٫۵)، برچسبِ تب ۱۱px، عنوانِ فصل در نقشه دوخطی، کاهشِ حرکت، حلقه‌ی فوکوس، بی pull-to-refresh، لرزشِ کوتاهِ جواب. با اسکرین‌شاتِ قبل/بعد وارسی شد. manifestِ PWA عمداً اضافه نشد (رفتارِ برگشت از زرین‌پال در حالتِ standalone آزموده نشده). ## وضعیت ۲۰۲۶-۱۰-۰۲ — تصمیم‌های سینیور پس از ارائه - **بخشِ والدین از ریشه حذف شد.** اپِ `family` دیگر وجود ندارد. جدول‌هایش، مجوزهای ادمینش و ردش در `django_migrations` را migrationِ `progress.0006` پاک می‌کند. `Profile.parent_phone`، دو نوع اعلانِ والدین و پارامترِ `?userId=` در `/api/progress/nodes` هم رفتند. جایش «گزارشِ والدین» در پروفایلِ خودِ دانش‌آموز است: `GET /api/user/weekly-report` (`progress/report.py`)، ۷ روزِ اخیر در کنارِ ۷ روزِ قبل. **`minutes` تخمین است**، از فاصله‌ی جواب‌ها؛ heartbeat عمداً ساخته نشد. - **تاریخِ تولدِ شمسی** به‌جای «سن»: `birthDate` («1385-04-12»)، میلادی ذخیره می‌شود و تبدیلش فقط در `accounts/jalali.py` است. `birthYear` از آن پر می‌شود و `age` حذف شد. بازه‌ی سن ۵ تا ۹۹ است. در فرانت سه فهرستِ روز/ماه/سال (`BirthDatePicker`) هست و برای ۱۹+ سوالِ «ریاضیِ کدوم پایه؟» پرسیده می‌شود. - **شناسه‌ی کاربری** زیرِ نام در پروفایل. **فاکتورها:** `GET /api/payment/invoices` و صفحه‌ی «پرداخت‌ها و فاکتورها». `Order.list_price` و `plan_title` در لحظه‌ی خرید ثبت می‌شوند تا فاکتور با عوض شدنِ پلن تغییر نکند. - **دستیار فقط ریاضی** (`rag/services/guardrails.py`، پرامپت `tutor-v11-math-only`). آزمونِ زنده ۷ از ۷ رد شد؛ شرحش در `docs/ai-cost-and-guardrails.md` است. - **هزینه:** هر سوال حدودِ ۰٫۲ سنت است؛ برای قیمت‌گذاری دستِ بالا ~۷۰۰ تومان حساب می‌شود و ۷۰٪ِ آن ورودی است. **سقفِ روزانه:** رایگان `AI_DAILY_QUOTA_FREE` (پیش‌فرض ۳)، مشترک `Plan.ai_daily_quota` (ادمین، پیش‌فرض ۱۵). تا امروز همه ۱۵ داشتند. گزارشِ مدیرعامل و گزارشِ ۲۶ در `~/Desktop/گزارشات Tadrisino/گزارش-۲۶/` است. مسیرِ استریم حالا توکن‌ها را ثبت می‌کند (پیش از این صفر بود). خرجِ GapGPTِ آن روز حدودِ ۴ سنت بود. - **لحنِ دستیار از سن** (`tone_for`، پرامپت `tutor-v12-age-tone`): ۱۸+ در هر پایه لحنِ بزرگسال می‌گیرد؛ نمادگذاری از کتابِ پایه. کشِ جواب با لحن کلید می‌خورد. فقط یک مقایسه‌ی زنده (سوم، ۸ و ۴۵ سال). - **شناسه‌ی نمایشی** `User482917` (`accounts/public_id.py`، از id، بی‌ستون؛ ادمین با آن جستجو می‌کند). - **بسته‌ی سوال به‌جای اشتراک** (`subscription.0009/0010`): ۲۰ / ۶۰ / ۱۵۰ سوال به ۴۵ / ۱۱۹ / ۲۵۹ هزار تومان، بی‌انقضا، با ۷ / ۳۰ / ۶۰ روز قلبِ نامحدود (`HeartsPass`). اعتبار در دفترِ `QuestionCredit`، اول سهمیه‌ی رایگانِ روز خرج می‌شود؛ هر سوالی که به API برسد اعتبار می‌خورد، ردِ صافی/کش/خطا نه (`Message.charge`). پلن‌های ماهانه/سالانه `on_sale=False` (مشترکِ فعلی سقفش را دارد). مسیرِ قیمت‌ها پیش از بسته: ۲۹۹ هزار سالانه → ۹۹/۹۹۰ با ۵ سوال → ۱۷۹/۱٬۷۹۰ با ۱۰ سوال. شرح: `docs/ai-cost-and-guardrails.md`. - **قلب واقعی است:** `HEARTS_ENFORCED` پیش‌فرض روشن؛ قلبِ صفر جلوی جواب را می‌گیرد (فرانت صفحه‌ی «قلب‌هات تموم شد» را دارد). روی سرور از ۲۰۲۶-۱۰-۰۲ ۱۱:۴۲. - **حذفِ حساب** `POST /api/user/delete`: شماره‌دار با کدِ پیامکیِ تازه (`verify_phone_otp`، مشترک با ورود)، مهمان با «حذف»؛ سفارش‌ها و statuslog می‌مانند؛ دموها رد می‌شوند. «ورود با حسابِ دیگر» فقط خروج است؛ چند حساب روی یک دستگاه عمداً نگه داشته نمی‌شود. - **پاک‌سازیِ ۲۰۲۶-۱۰-۰۲ روی سرور**: `delete_users --real-phones --skip-paid` پنج حسابِ شماره‌دار (۱، ۱۷، ۱۹، ۲۰، ۲۱) را پاک کرد؛ دموها و کاربرِ ۲۶ (پرداختِ موفق) ماندند. بکاپ: `.deploy-backups/db-before-user-cleanup-20261002-061142.sqlite3`. دستور فقط مدل‌های خودِ پروژه را می‌شمارد؛ بارِ اول `admin.LogEntry` (کارمندِ ادمین) را هم گرفته بود و پیش از اجرا اصلاح شد. - `spectacular --validate` یک خطای از پیش موجود دارد (روی `d5dbd1b` هم بود) که به این تغییرها ربطی ندارد. - **بعد از بازبینیِ مستقلِ همان روز — روی سرور از ۲۰۲۶-۱۰-۰۲ ۲۳:۱۷** (بک‌اند `7683920`، سرویس واقعاً ری‌استارت شد؛ فرانت `33f5f8d`؛ بکاپ: `.deploy-backups/db-before-hearts-refund-20261002-231637.sqlite3`): خریدِ بسته از بازار اعتبار نمی‌داد (`planId` در `POST /api/payment/orders`)؛ صافیِ دستیار شکل‌های بی‌نیم‌فاصله‌ی «می‌خوام بمیرم» را نمی‌گرفت؛ قلبِ صفر حالا پایانِ درس/جزیره‌ی بی‌جواب را هم می‌بندد و چکش داخلِ تراکنش است (`content/tests_hearts_gate.py`)؛ **لغو و بازپرداختِ سفارش** با دو action در `/admin/payment/order/` (پول را خودِ درگاه برمی‌گرداند؛ `docs/operations.md`)؛ دفترِ قیمتِ مدل‌ها `docs/model-prices.md`. فرانت: صفحه‌ی تازه‌ی «قلب‌هات تموم شد» با شمارشِ معکوس. ۷۷۹ تست سبز. دو migration تازه: `payment.0010`، `subscription.0011`. PDFِ مستندِ فنی حالا از Markdown ساخته می‌شود: `python docs/build_handbook.py` (pandoc + Chrome). ## وضعیت ۲۰۲۶-۰۹-۳۰ — بازار وصل شد - حسابِ **شرکت** در بازار «اجازه» داد (۰۹-۳۰ ۱۲:۰۶، `bazaar: true`). اسمِ پکیج را کسی نداد؛ با توکنِ خودمان پیدا شد: از اسم‌های حدسی فقط `com.tadrisino.app` «Product is not found» داد (یعنی هست و دسترسی داریم؛ اپِ دیگران ۴۰۳ می‌دهد). صفحه‌ی عمومی‌اش ۴۰۴ است، پس منتشر نشده. **این‌که همان اپِ ریاضی است را سنیور تأیید نکرده.** `BAZAAR_PACKAGE_NAME=com.tadrisino.app` در `.env` سرور — **از ۲۰۲۶-۱۰-۰۸ `com.tadrisino.math.app`** است، برابرِ `applicationId`ِ APKِ واقعی (aapt2)؛ ثبت‌بودنش در پیشخان تأیید نشده (بکاپ: `.deploy-backups/env-before-bazaar-package-20260930-122311`)، ری‌استارت ۱۲:۲۳. با SKU و توکنِ ساختگی: تک‌خرید «Product is not found» (پس ۵۰۳ در کدِ ما)، اشتراک `not_found`. **هنوز نداریم:** SKUِ پلن‌ها و اپِ اندرویدیِ دارای Poolakey؛ هیچ خریدِ واقعی‌ای تأیید نشده. ## وضعیت ۲۰۲۶-۰۹-۲۹ (شب) — بازبینیِ نهایی پیش از ارائه (روی سرور) - بک‌اند `93b451d` (سرویس واقعاً ری‌استارت شد، ۲۱:۲۵)، فرانت `db6d694`. بکاپ: `.deploy-backups/db-before-final-polish-20260929-212452.sqlite3`. **۶۸۵ تست سبز**، اسکیما بی‌خطا، ۶۱ اندپوینت. - تازه: `GET /api/support/tickets` (پیش از آن اپ شناسه‌ی تیکت را فقط در localStorage داشت)؛ اعلانِ «تمام کردی» با رقمِ فارسی؛ `.env.example` از نو و مطابقِ کد. فرانت: صفحه‌ها lazy با preload (ورودیِ اپ ۵۰۵ ← ۸۵ کیلوبایت)، `MathText` روی صورت/گزینه‌ی آزمون و مثالِ درس، جداکننده‌ی «·» کنارِ صفرِ فارسی عوض شد، برچسبِ تکراریِ ریشه/رأس. - **دستیار آن شب ~۳۰ ثانیه برای هر جوابِ کامل طول کشید.** روی خودِ سرور اندازه شد: `gpt-4.1-mini` از GapGPT برای «فقط بگو سلام» ۶٫۸ ثانیه، و stream اولین تکه را همراهِ آخرین می‌دهد. کدِ ما نیست. کشِ جواب per-worker است (Redis نیست)، پس «یک بار قبل از ارائه بپرس» قابلِ اتکا نیست. - وارسیِ صفحه‌ها با `puppeteer-core` در scratchpad و Chromeِ نصب‌شده (توکن از API، گذاشتن در `localStorage` `tadrisino.tokens`). این تست‌ها روی دیتای واقعی نوشتند: سارا (کاربرِ ۱۱) دو جزیره جلو رفت، و علی/نگار/سارا هرکدام ۱–۲ سؤال از دستیار پرسیدند. - راهنمای ارائه و گزارشِ نهایی در `~/Desktop/گزارشات Tadrisino/گزارش-نهایی/` به‌روز شدند. ## وضعیت ۲۰۲۶-۰۹-۲۸/۲۹ — شکل در دستیار، آزمونِ جزیره، همراهِ چهارم، محتوای کامل (روی سرور) - ✅ **۲۰۲۶-۰۹-۲۹ دیپلوی شد:** بک‌اند `2c759a7` (سرویس واقعاً ری‌استارت شد، ۰۷:۱۹؛ migrationهای `ai_tutor.0006` و `progress.0005` اعمال شدند؛ `questions: 2545`)، فرانت `63f4ea8` روی `math.tadrisino2.ir`. بکاپِ پیش از آن: `.deploy-backups/db-before-diagrams-20260929-071459.sqlite3`. تستِ زنده روی خودِ سایت: ۵ سوال (مثلث، سوالِ ادامه‌ای، سهمی، ضربِ بی‌شکل، الگو)، همه درست و شکل‌ها در `Message.diagram` ذخیره شدند؛ `aiProvider: ok`. این تست **سه کاربرِ مهمانِ تستی** ساخت (کاربرهای ۲۲ تا ۲۴، نامِ «کیان») که مثلِ داده‌ی دمو پیش از کاربرِ واقعی باید پاک شوند. فرانت از `upstream` (`Tadrisino/MathApp_frontend`) دیپلوی می‌شود، نه از `origin`ِ شخصی؛ پوش به هر دو. - اعتبارِ GapGPT در ۰۹-۲۹ دوباره شارژ شد (بندِ ⛔ پایین تاریخی است). - ⛔ **اعتبارِ GapGPT تمام شد (همان روز، ~۱۷:۲۰) و دستیارِ سایتِ زنده ۵۰۳ می‌دهد.** کلیدِ `.env`ِ لپ‌تاپ همان حسابِ سرور است؛ سنجش‌های شکل (~۲۵۰ فراخوانی، ≈۰٫۳ دلار) باقی‌مانده را خورد. **هر سنجشِ انبوهِ محلی از اعتبارِ پروداکشن خرج می‌کند؛ پیش از آن موجودی را بسنجید.** - **شکل در جوابِ دستیار:** مدل در انتهای جواب `{JSON}` می‌نویسد؛ `rag/services/diagram.py` آن را پیش از `mathtext` جدا، خطاهای قالبیِ شناخته‌شده را تعمیر (رقمِ فارسی، `2*pi`، کامنت، ونِ با اسمِ فارسی) و سخت‌گیرانه اعتبارسنجی می‌کند؛ روی `Message.diagram` (migration `ai_tutor.0006`) می‌نشیند و در `done`ِ SSE و تاریخچه می‌آید. فرانت (`src/diagram/`) خودش SVG می‌کشد و ریشه، رأس، مماس، پای عمود و تبدیل‌ها را حساب می‌کند؛ HTML/SVGِ مدل هرگز وارد صفحه نمی‌شود. ۱۴ نوع (plane، numberline، fraction، grid، groups، clock، bar/linechart/pie، table، venn، mapping، tree، solid)؛ گالریِ آزمایشی در `?gallery` فقط در حالتِ توسعه. کاتالوگ بخش‌بخش است و کنارِ هر سوال فقط بخشِ مربوط می‌رود (`hint_for`)؛ سوالِ ادامه‌ای بخشِ سوالِ قبلی را می‌گیرد (`follow_up_hint_for`)، و درخواستِ صریحِ «شکلش رو بکش» بی‌کلمه‌ی موضوعی کلِ کاتالوگ را (`asked_hint_for`، پرامپت `tutor-v8-asked-figure`؛ یک بار با GapGPT آزموده شد: مستطیلِ باغچه). فرانت هیچ تشخیصی نمی‌دهد. - **۲۰۲۶-۰۹-۲۹، پرامپت `tutor-v10-shape-anchor`:** ابزارهای `triangle` (مثلث از ضلع و زاویه، با قانونِ سینوس/کسینوس در `src/diagram/shapes.ts`)، `quad` (مستطیل، مربع، متوازی‌الاضلاع، لوزی، ذوزنقه)، نوعِ `pattern` (الگوی شکلی و چوب‌کبریت)، نقطه‌ی `translate`؛ شکلِ دوم روی ضلعِ مشترک یا کنارِ شکلِ قبلی می‌نشیند. تعمیرِ عددِ حسابی («-2 * 3.14»)، گیومه‌ی جاافتاده، کلیدِ تکراری، `kind` با نامِ شکل؛ `fits()` ساعتِ دیواری را برای سوالِ بی‌ساعت دور می‌ریزد. سنجشِ ۶۴ مسئله‌ی تصادفیِ کتاب (همان مسئله‌ها قبل و بعد، وارسیِ چشمی): درست ۳۹ → ۴۰، غلط ۹ → ۷، بی‌شکل ۷ → ۵ — در این اندازه‌ی نمونه معنی‌دار نیست؛ برآوردِ ۹۵٪: درست ۵۰–۷۳٪، غلط ۵–۲۱٪. ۴۰ مسئله‌ی هندسیِ جدا: ۲۲ درست، ۱۲ قابلِ قبول، ۴ غلط. خرج ≈ ۰٫۷ دلار (به اجازه‌ی صاحبِ پروژه تا ~۱ دلار). اسکریپتِ سنجش در scratchpad بود، نه در ریپو. پرامپت `tutor-v6-diagrams`. سنجش روی ۹۶ مسئله‌ی واقعیِ کتاب: ۴۵ از ۴۸ شکلِ معتبر (دورِ ۲)، ۰ از ۸ برای سوالِ بی‌شکل؛ وارسیِ چشمیِ ۸۳ شکل: ۶۴ درست، ۳ ضعیف، ۱۶ غلط. اصلاح‌های بعد از آن (خط از معادله، کسرِ مقایسه‌ای، محرکِ ساعت، …) روی مدل سنجیده نشده‌اند. - **فرانت آزمونِ جزیره را بازی می‌کند** (`/nodes/{id}/quiz`); خانه خودِ مسیر است با «شروع». - **امگا** (ω؛ تا ۲۰۲۶-۱۰-۱۰ «تدی»، slug هنوز `tedi`؛ شکلکِ `mobile-preview/mascot-pack`، شش حالت) با `progress.0005`/`0007`، **۵۰۰ امتیاز** (همان فاصله‌ی ۲۰۰تاییِ دلتا تا پی؛ خواستِ صاحبِ پروژه). - **محتوا:** ۸۲ سوالِ تازه، پس هیچ جزیره‌ای زیرِ ۴ سوال نیست (۲٬۵۴۵ سوال). `fill_lesson_texts` (بی‌هزینه) `Unit.blurb` را برای ۵۸ فصل و مثال/قدم‌ها را برای ۵۹۰ درس از مسئله‌های تأییدشده‌ی کتاب پر کرد، هرگز همان مسئله‌ی آزمونِ درس؛ `seed_content` این‌ها را فقط در خانه‌ی خالی می‌نشاند، پس متنِ ادمین با دیپلوی بازنویسی نمی‌شود. - محلی: `PUBLIC_BASE_URL` پیش‌فرضش دامنه‌ی اصلی است، پس «پرداخت» روی لپ‌تاپ به سرورِ واقعی می‌رود و ۴۰۴ می‌گیرد؛ باگِ سرور نیست. گزارش: `~/Desktop/گزارشات Tadrisino/گزارش-۲۵/`. ## وضعیت ۲۰۲۶-۰۹-۲۷ — دامنه‌ی تازه و فرانتِ تازه، هر دو زنده - ✅ **پیامکِ کدِ ورود واقعاً می‌رسد** (۲۱:۴۴). اسمِ الگو را سینیور داد: `SMSVerify`، با همان سه پارامترِ `receptor`/`token`/`template` که کد از قبل می‌فرستاد. فقط `KAVENEGAR_OTP_TEMPLATE=SMSVerify` به `.env` سرور اضافه شد (بکاپ: `.deploy-backups/env-before-smsverify-20260927-214351`) و سرویس ری‌استارت شد؛ کدی عوض نشد. یک دقیقه پیش از آن جلسه‌ی دیگری کلیدِ بازار را عوض و ری‌استارت کرده بود (`env-before-bazaar-client2-…`)؛ سنجیده شد که هیچ کلیدی از آن گم نشده. - **API روی `mathapi.tadrisino2.ir` است**؛ ادمین از ۲۰۲۶-۰۹-۲۶ ساعتِ ۲۳:۲۲ nginx را عوض کرد و رکوردِ DNSِ `api.math.tadrisino2.ir` دیگر وجود ندارد. `.env` سرور `PUBLIC_BASE_URL=https://mathapi.tadrisino2.ir` شد (بکاپ: `.deploy-backups/env-before-mathapi-20260927-010751` و `db-before-mathapi-…sqlite3`)، `1f38465` دیپلوی و سرویس واقعاً ری‌استارت شد (۰۱:۰۹). - ⛔ **زرین‌پال هنوز `-14` می‌دهد** («دامنه‌ی callback با دامنه‌ی ثبت‌شده‌ی ترمینال نمی‌خواند»)، این بار برای `mathapi`؛ فراخوانیِ زنده‌ی PaymentRequest در همان شب. رفعش ثبتِ دامنه در پنلِ زرین‌پال است، نه کد. Redirect URIِ بازار هم با این دامنه عوض شد؛ لینکِ «اجازه»ِ تازه با `mathapi` در ۲۰۲۶-۰۹-۲۷ ساخته شد (۷۲ ساعت، لینک‌های قبلی باطل) و برای سنیور رفت؛ پیش از کلیک باید Redirect URIِ پنلِ بازار هم `mathapi` شده باشد. - **۲۰۲۶-۰۹-۲۷ عصر:** سنیور یک Client تازه‌ی بازار داد (Redirect روی `mathapi`)؛ در `.env` سرور جایگزین شد (بکاپ: `.deploy-backups/env-before-bazaar-client2-20260927-214302`)، ری‌استارت ۲۱:۴۳، و لینکِ «اجازه»ِ تازه با همین Client ساخته شد (لینک‌های قبلی باطل). به گفته‌ی سنیور «شناسه‌ی توسعه‌دهنده» هنوز در بررسیِ بازار است و تا آن تمام نشود لینک خطا می‌دهد. - **علتِ `-14`ِ زرین‌پال پیدا شد (فراخوانیِ زنده، ۰۹-۲۷):** مرچنتِ `.env` ما برای `tadrisino.org` ثبت است — callback روی `tadrisino.org` کدِ ۱۰۰ می‌گیرد و هر آدرسِ `tadrisino2.ir` (با `mathapi`) `-14`. بک‌اندِ دیگرِ شرکت (`Tadrisino/Tadrisino_backend`) callback را روی `api.tadrisino2.ir` می‌فرستد و کار می‌کند، پس مرچنتِ آن برای `tadrisino2.ir` است. رفع: همان مرچنت در `ZARINPAL_MERCHANT_ID`، نه تغییرِ کد. کدِ آن‌ها همان فیلدهای ما را دارد جز `currency` (آن‌ها نمی‌فرستند، یعنی ریال)؛ verifyِ ما هنوز با پرداختِ واقعی آزموده نشده. **۰۹-۲۷ ۲۲:۲۴ انجام شد** (به خواستِ صاحبِ پروژه): مرچنتِ `Tadrisino_backend` روی خودِ سرور از `~/deployment/Tadrisino_backend/MOOC/.env` در `.env` ما کپی شد (مقدار هرگز چاپ نشد؛ بکاپ: `.deploy-backups/env-before-zarinpal-merchant-20260927-222314`)، ری‌استارت، و PaymentRequest با callbackِ `mathapi` حالا **۱۰۰** می‌دهد. پرداخت‌های دو اپ حالا به یک حسابِ زرین‌پال می‌روند. هنوز آزموده نشده: یک پرداختِ واقعی تا verify. - **فرانتِ بازسازی‌شده روی `math.tadrisino2.ir` زنده است** (`main` = `2b4db90`). دو ایراد فقط روی سایتِ زنده با حساب‌های دمو پیدا شد و رفع شد: متنِ بخشِ والدین برای فرزند غلط بود، و حسابِ والد (بی‌پایه) به فرمِ «کدوم پایه‌ای؟» می‌افتاد. - **داده‌ی دمو دست‌نخورده است** (۱۸ کاربر؛ ۶، ۱۰ تا ۱۵، ۱۷، ۱۸ با امتیاز، گفتگو، تیکت و اعلان‌هایشان). تستِ زنده فقط نشستِ ورود ساخت، هیچ جوابی ثبت نکرد. ## وضعیت ۲۰۲۶-۰۹-۲۶ — دستیار روی gpt-4.1-mini **روی سرور:** `11bbe1c` دیپلوی شد و سرویس واقعاً ری‌استارت شد (۰۲:۵۴). `OPENAI_CHAT_MODEL` در `.env` سرور از `gpt-4o-mini` به `gpt-4.1-mini` عوض شد؛ بکاپ: `.deploy-backups/env-before-gpt41mini-20260926-025320`. پیش‌فرضِ `settings.py` هم همین است. یک سوالِ زنده با مهمانِ تستیِ **کاربرِ ۱۸** جواب داد (پیامِ ۴۹: `gpt-4.1-mini`، `tutor-v5-slash-in-question`، ۴ chunk، ۲٫۵ ثانیه). - **چرا:** دو دور سنجش (گزارش ۲۳، شرح در `docs/architecture.md`). روی ۱۰۲ مسئله با تصحیحِ دستی، 4.1-mini ۹۵–۹۶ و 4o-mini ۸۵–۹۰ گرفت. هر پاسخ ۰٫۱۲ سنت است، ۲٫۹ برابرِ 4o-mini. ورودِ کتاب هم با همین مدل است (`OPENAI_VISION_MODEL`). - **«/» در دبستان** هم ممیز است هم کسر. جمله‌ی «در کتاب / ممیز است، نه کسر» فقط به دبیرستان می‌رود و قاعده‌ی تشخیص کنارِ سوالِ دبستانی می‌آید. سه مسئله از ۱۷ هنوز غلط است. - **مانده:** پاسخِ دبستانی کسر را گاهی با «/» می‌نویسد (۵/۶)، که در نمادِ کتاب یعنی ۵٫۶. یک خطِ پرامپت برای «بر» (v6) آزموده شد و درستی را کمی پایین آورد، پس **منتشر نشد** (سرور روی v5 ماند). شرح در `docs/architecture.md`؛ راهِ بعدی احتمالاً در فرانت است. - داورِ خودکارِ `rag_model_benchmark` (gpt-4.1) حدودِ ۷٪ اشتباه می‌کند؛ نتیجه‌ی هر سنجش را پیش از تصمیم دستی وارسی کنید. ## وضعیت ۲۰۲۶-۰۹-۲۶ — اتصالِ API بازار `7d205ca` روی سرور است (ری‌استارت ۲۲:۲۱، migration `payment.0008`؛ `/api/health` → `integrations.bazaar`). Client ID/Secretِ بازار در `.env` سرور است (بکاپِ پیشش: `.deploy-backups/env-before-bazaar-20260926-213108`). Redirect URIِ ثبت‌شده در پنلِ بازار خودِ `/api/payment/bazaar/verify` است: GET کدِ بازار را می‌گیرد، POST خریدِ اپ. اولین تأیید با حسابِ شخصیِ صاحبِ پروژه زده شد: توکن کار کرد ولی به هیچ اپی دسترسی نداشت (۴۰۳ «You don't have access to this app») و همین نشان داد بازار `Bearer` را نمی‌پذیرد. **هر حسابِ بازاری می‌تواند تأیید بزند؛ فقط حسابِ شرکت به کار می‌آید.** توکن پاک شد و لینکِ تازه (۲۲:۱۸، ۷۲ ساعت) برای سنیور رفت؛ تا تأییدِ حسابِ شرکت `bazaar: false` است. اسم‌های حدسیِ `ir.tadrisino.app` / `ir.tadrisino` / `com.tadrisino` روی بازار وجود ندارند. **هنوز نداریم:** اسمِ پکیجِ اپ (کد حدسِ `ir.tadrisino.app` را دارد)، SKUِ پلن‌ها، و اپِ اندرویدیِ دارای Poolakey. آدرسِ سنجشِ خرید با سندِ فعلیِ بازار مقایسه شد و **هر دو آدرسِ سنجشِ خرید غلط بودند** (یعنی هیچ خریدِ واقعی‌ای تأیید نمی‌شد)؛ درست شدند، به‌علاوه‌ی روشِ جدیدِ بازار (توکنِ ثابتِ پیشخان در `BAZAAR_PISHKHAN_API_SECRET`، بی‌OAuth) که ترجیحی است. سندِ بازار JS است ولی متنش از `developers.cafebazaar.ir/wp-json/wp/v2/document?search=...` خوانده می‌شود. جزئیات در `docs/operations.md`. ## وضعیت ۲۰۲۶-۰۹-۲۵ (عصر) — پیامکِ واقعی روشن شد **روی سرور:** `0580aec` دیپلوی و سرویس واقعاً ری‌استارت شد (۱۲:۳۲). `KAVENEGAR_API_KEY` در `.env` سرور و لوکال است (حسابِ Master، ~۳۲۶ هزار ریال اعتبار در آن روز). بکاپ‌های پیش از آن: `.deploy-backups/env-before-kavenegar-20260925-122959` و `db-before-otp-hardening-20260925-122959.sqlite3`. - **از این به بعد هر درخواستِ OTP پول است.** برای همین: شماره به `09XXXXXXXXX` نرمال می‌شود و غیرِ موبایلِ ایران ۴۰۰ می‌گیرد؛ cooldown و سقفِ روزانه‌ی هر شماره (۱۰) و سقفِ روزانه‌ی کلِ سامانه (۳۰۰، ضدِ SMS pumping) در دیتابیس شمرده می‌شوند نه کشِ per-worker. تست‌ها در `accounts/tests_otp_limits.py`. - مسیرِ کاملِ کاربرِ تازه روی سرورِ زنده سبز شد، **بدونِ پیامکِ واقعی**: کد مستقیم در دیتابیس ساخته شد. کاربرِ تستیِ آن **کاربرِ ۱۷ با شماره‌ی `09000000001`** (نام «کاربر تست»، یک جزیره تمام‌شده) روی پروداکشن مانده و مثلِ داده‌ی دمو پیش از کاربرِ واقعی باید پاک شود. - **بررسیِ همه‌ی اتصال‌ها روی سرور (همان عصر، فراخوانیِ زنده، فقط بولین/کدِ پاسخ چاپ شد):** GapGPT دوباره اعتبار دارد (دستیار برای مهمانِ تستیِ **کاربرِ ۱۸**، `deviceId = deploy-check-ai-20260925`، با ۴ chunk جواب داد و `aiProvider` خودش `ok` شد)؛ Qdrant سالم (۱۲ collection). **زرین‌پال: مرچنت معتبر ولی `-14` «دامنه‌ی callback با دامنه‌ی ثبت‌شده‌ی ترمینال نمی‌خواند»** — ترمینال برای `api.math.tadrisino2.ir` ثبت نشده، پس هیچ پرداختی شروع نمی‌شود؛ رفعش در پنلِ زرین‌پال است. **بازار: هر سه `BAZAAR_*` خالی‌اند** (فقط برای خریدِ درون‌برنامه‌ایِ اپِ اندروید لازم است). `CACHE_URL` و `INTERNAL_SERVICE_KEY` خالی (اولی کارِ ادمین، دومی فقط مسیرهای داخلی)، کلیدِ امضا هنوز `django-insecure` (تصمیمِ صاحبِ پروژه). - ⛔ **اولین پیامکِ واقعی (۱۲:۳۵، به شماره‌ی خودِ صاحبِ پروژه) نرسید.** API ۲۰۰ داد و کاوه‌نگار پذیرفت، ولی وضعیت شد **۱۳ «لغو شده»**؛ یک متنِ سادهِ بی‌کد هم لغو شد، پس ایراد از حساب/خطِ `10009000907070` است نه از کد یا متن. **سینیور (۰۹-۲۶): حساب سالم است، فقط اسمِ الگو را نداشتیم** — این خط کدِ ورود را فقط با «ارسال با الگو» (`verify/lookup`) می‌فرستد. کدش آماده است: با `KAVENEGAR_OTP_TEMPLATE=<نامِ الگو>` در `.env` کدِ ورود از الگو می‌رود، بدونِ آن همان `sms/send`ِ قبلی (که روی این خط لغو می‌شود). اسمِ الگو هنوز نرسیده. وضعیت را بدونِ SSH با `sms/statusbyreceptor.json` بسنجید (`latestoutbox` برای این کلید ۴۰۷ است). - ⚠️ زیاد SSH زدن پشتِ سرِ هم یک بار «Permission denied» با رمزِ درست داد؛ با `ControlMaster` (سوکت در مسیرِ کوتاهِ `/tmp`، macOS سقفِ ۱۰۴ بایت دارد) یک اتصال باز کنید. ## وضعیت ۲۰۲۶-۰۹-۲۵ — این را اول بخوان کیفیتِ دستیار و گیرِ نقشه (جزئیات و دلیل‌ها در `docs/architecture.md`). **۵۷۹ تست سبز.** **روی سرور است:** `42783f9` دیپلوی و سرویس واقعاً ری‌استارت شد (۲۰۲۶-۰۹-۲۵ ۰۶:۲۶)، migrationها اعمال شدند، سه‌یار و `nextLessonId` از بیرون تأیید شدند. بکاپِ پیش از آن: `.deploy-backups/db-before-audit-fixes-20260925-062458.sqlite3`. پوشه‌ی `.bench` پاک شد. - **پرامپت `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 می‌ماند** (۰۹-۲۶ با سنجشِ بعدی به gpt-4.1-mini عوض شد) (ارزان‌ترین). اولین کاندیدا برای سنجش بعد از شارژ: `deepseek-v4-flash` (قیمتِ رسمی از 4o-mini کمتر، ریاضیِ خیلی قوی‌تر، ولی فارسی و حالتِ بدونِ thinking‌اش اندازه نشده) و `gpt-4.1-mini`. تیمِ Rag_backend برای جوابِ ریاضی `o3-mini` استفاده می‌کند. - فرانت: 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` خودش بکاپ می‌گیرد (`db-before-deploy-*`، ۲ تای آخر، بی‌بکاپ migrate نمی‌شود)؛ روی سرور هم اجرا شد (۲۰۲۶-۱۰-۰۷، `integrity_check: ok`). بیرون از ماشین بکاپی نیست. **⚠️ Gitea از روی سرور:** از ۲۰۲۶-۱۰-۰۷ `https://gitea.tadrisino2.ir` از خودِ سرور (حل‌شده به IPهای ArvanCloud) ۵۰۴ می‌دهد و از لپ‌تاپ ۲۰۰؛ علتش پیدا نشد (مسیرِ آروان↔سرور). `deploy.sh` در `git fetch` می‌ایستد. راه: `DEPLOY_BUNDLE` (بخشِ «دیپلو» در `docs/operations.md`)، با مقایسه‌ی هشِ کامیت. مرحله‌ی «بررسیِ سلامت» حالا از سوکتِ gunicorn می‌سنجد و از داخلِ سرور هم کار می‌کند. `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 -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`). (A Redis runs on the box, but it belongs to other teams: **do not connect to it**, owner's decision 2026-10-07. The way out is our own Redis, see the status block above.) - 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 را کش می‌کند.