diff --git a/CLAUDE.md b/CLAUDE.md index 55d8c22..880507f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,1225 +1,426 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +Guidance for agents working in this repo. **Current state only**: what is true now and the rules +that follow from it. History lives in git (`git log -p -- CLAUDE.md`; the long dated status log that +used to be here ends at `eb95209`) and in the owner's reports (`~/Desktop/گزارشات Tadrisino/`). +When something here stops being true, change the line; do not append a dated paragraph. -## Project +## The project in one paragraph -Django + DRF backend for Tadrisino, an educational app (Persian/Farsi). Codebase and API responses are in Persian; comments in source are Persian too. +Tadrisino is a Duolingo-style math app for Iranian students (grades 3–12): a map of islands per +textbook chapter, short quizzes built from the book's own problems, and an AI tutor («پی») that +answers from the same book. Two repos, one API contract: this Django + DRF backend +(`Tadrisino/MathApp`, live at `https://mathapi.tadrisino2.ir`) and the React frontend +(`Tadrisino/MathApp_frontend`, local clone `~/Desktop/MathApp_frontend`, live at +`https://math.tadrisino2.ir`, with an Android WebView shell in `android/`). The owner +(`AliReza_Zareian`) maintains both. Code, API messages, comments and docs are Persian. -## Reviewing this codebase: comments are claims, not evidence +## Where to look -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). +| Need | File | +|---|---| +| Deploy, env vars, backups, nginx, Bazaar sale, admin refunds | `docs/operations.md` | +| Design decisions and why (auth, payment, quiz, hearts, tutor prompt) | `docs/architecture.md` | +| Formal handover document for humans (also as PDF) | `docs/technical-handbook.md` → `python docs/build_handbook.py` | +| What is still open before real users, and the guards that hold what is closed | `docs/security-before-production.md` | +| Editing content, bulk questions, seed rules | `docs/content-authoring.md` | +| Reading a textbook with a vision model (costs, model choice) | `docs/extraction-playbook.md`, `data/textbooks/README.md` | +| Tutor cost per question, subscription margins, guardrails | `docs/ai-cost-and-guardrails.md` | +| Measured model prices (append-only ledger: add a dated row, never overwrite) | `docs/model-prices.md` | +| Load testing (laptop and the shared server) | `tools/loadtest/README.md` | +| Frontend structure and its deploy | `~/Desktop/MathApp_frontend/README.md` | +| Android build, signing, what the next APK must carry | `~/Desktop/MathApp_frontend/android/README.md` | -- **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. +Before writing a new doc, check this table: the same fact in three places drifts apart. -## 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: +## ⛔ Working on the servers — read before every SSH session -- 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). +The owner often says "put it on the server" / "deploy it" and expects you to do it yourself. You +may, but these machines are **shared company servers**, not ours. Our app is one tenant among many. +A careless command can take down the whole company's git, mail and other teams' production apps, +and there is no technical guard rail: our account has full root. **The only guard rail is you.** -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. +### What is ours — and nothing else is -Rules: +**`78.38.35.249` (`haghzad`, app server)**, account `administrator`: -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 +| Ours | Note | +|---|---| +| `/home/administrator/mathapp-v2` | backend checkout; `.env`, `.django_secret_key`, `logs/`, `.deploy-backups/` are untracked and live here | +| `/home/administrator/mathapp_frontend_new` | frontend checkout; nginx serves its `dist/` | +| `gunicorn-mathapp.service` = alias **`zareian-mathapp`** | always type the alias | +| `zareian-redis.service` (port 6390) | our own Redis | +| Postgres cluster `14/zareian` (port 5440, DB `zareian_mathapp`) | our own cluster | +| `zareian-applog-archive.timer` | nightly log archive | +| nginx: `sites-enabled/mathapp`, `sites-available/mathapp-front`, `snippets/zareian-*.conf` | source of truth is `ops/nginx/` | + +**Not ours, never touch:** `~/mathapp` (old, not git, not what runs), the generic `gunicorn.service` +(another team's app), `gitea`, `postfix`/`dovecot`, `named`, `mariadb`, `postgresql@14-main` (port 5432), +`redis-server` (6379), `rabbitmq`, `metabase`, `apirag`, `artinafront`, `metaverse`, `celerybeat`, +other people's home directories (`aria`, `artina`, `behdad`, `zahra`, `git`, `gitea`) and every +other nginx site. `metabase` and Artina's `celerybeat` crash-loop and eat ~3 of 8 cores; that is a +request to the server admin, not something to fix. + +**`78.38.35.247` (backup server)**, same account and password: ours is only `~/zareian-pgbackup/` +and the `zareian-pg-*` / `zareian-applog-pull` units. It also runs other teams' services and its +own `postgresql@14-main`, which we never use. + +### Rules (not negotiable) + +1. **Read-only first.** Before changing anything, look: `systemctl status`, `ls`, `git -C … log -1`, + `df -h /`. Know what is there before you act on it. +2. **No destructive command outside our directories.** No `rm -rf`, `chown -R`, `chmod -R`, + `find … -delete`, `DROP`, `docker system prune`, package upgrades, reboots, firewall changes. + Inside our directories, write paths out in full (`/home/administrator/mathapp-v2/...`), never + rely on the current directory. +3. **Never `git clean`, never delete and re-clone the project.** `.env`, `.django_secret_key`, logs + and backups are untracked; `git reset --hard origin/main` is the way back to a known tree. +4. **Restart only `zareian-mathapp`.** `systemctl restart gunicorn` is another team's app. nginx is + shared: `sudo nginx -t` then `reload`, never `restart`. +5. **Back up before editing anything under `/etc` or `.env`**, into + `~/mathapp-v2/.deploy-backups/-before--`, and say where it is. + Database: `deploy.sh` already takes a `pg_dump` before `migrate`; for any manual data change take + one yourself first. +6. **Disk is the standing risk.** Check `df -h /` before anything that writes a lot. A full disk + takes Gitea and mail down with us. +7. **Do not use other teams' resources**, even "just an empty database" on their Redis or Postgres, + their queues or their nginx sites. Ask the owner; it becomes a request to the server admin. +8. **Secrets stay put.** Read `.env` only through Django (`venv/bin/python manage.py shell -c` printing + booleans), never `cat .env`, never paste values into chat. `DEBUG` must never be `1` on the server + (it enables `DEV_MASTER_OTP=0000`, a login for any phone). +9. **Load tests and bulk jobs** follow `tools/loadtest/README.md`: never touch production throttles, + stay under the connection budget, clean up. +10. **When something is unexpected** (a file you did not expect, a service in a state you cannot + explain, a command that wants to prompt), stop and tell the owner. Do not improvise on a shared box. + +### Announce before you act + +Before every command that runs on a server, or that deletes anything (even locally), write one +short Persian line saying what it touches, where (local scratchpad or which directory on which +server), and that nothing outside our own files is affected. Never combine server commands and +local cleanup in one call: once, an agent closing its local SSH socket and destroying its local +password copy in the same command looked like deleting files on the server, and the owner (who +answers for that machine) was rightly alarmed. «سرور رو خاموش کن» means end the SSH session and +destroy the password copy, **never** power anything off. + +### The password + +Access is a password for the shared `administrator` account (same for 249 and 247). The owner +keeps it in `~/Desktop/رمز سرور.txt` and wants agents to read it from there rather than ask in +chat; the auto-mode classifier may still block that read until the owner confirms in the +session. Take only the password line, everything after the first `: ` (stripping only the label +once sent the whole line and SSH refused it): + +```bash +sed -n '/^Password/{s/^[^:]*: *//;p;}' ~/Desktop/'رمز سرور.txt' > "$SCRATCH/.pw" && chmod 600 "$SCRATCH/.pw" +sshpass -f "$SCRATCH/.pw" ssh administrator@78.38.35.249 '' +sshpass -f "$SCRATCH/.pw" ssh administrator@78.38.35.249 'sudo -S -p "" ' < "$SCRATCH/.pw" +rm -P "$SCRATCH/.pw" # as soon as the work is done (macOS has no shred); recreate it next time +``` + +Never put it on a command line (`ps` shows argv to every user on the server), in the repo, a +memory, a commit or a note; never echo it. Many SSH connections in a row can trip a lockout: open +one `ControlMaster` connection (socket under `/tmp`, macOS limits the path to 104 bytes). Do not +create SSH keys, edit `sshd_config` or change the password unless the owner asks for exactly that. + +### "Deploy it": the procedure + +Backend (commit and push to Gitea first; the server deploys what is on `origin/main`): + +```bash +sshpass -f "$SCRATCH/.pw" ssh administrator@78.38.35.249 'cd /home/administrator/mathapp-v2 && ./deploy.sh' +sshpass -f "$SCRATCH/.pw" ssh administrator@78.38.35.249 'sudo -S -p "" systemctl restart zareian-mathapp' < "$SCRATCH/.pw" +``` + +`deploy.sh` saves any hand edits as a patch, resets to `origin/main`, installs, backs up the DB, +migrates, runs `seed_content`, collectstatic, and checks health through the gunicorn socket. The +restart is separate because `sudo` inside the script cannot prompt. Then **verify; never assume**: + +- `systemctl show zareian-mathapp -p ActiveEnterTimestamp` is after your deploy (a file on disk is not + a deployed file: once the server served two-day-old code while every file was current); +- `git -C /home/administrator/mathapp-v2 log -1 --format=%h` equals your local `main`; +- `curl https://mathapi.tadrisino2.ir/api/health` → `{"status":"ok"}` (details need `X-Internal-Key`); +- the thing you changed, exercised once from outside. + +If the server cannot reach Gitea (`git fetch` 504 from the server, 200 from the laptop), ship a bundle: +`DEPLOY_BUNDLE` in `docs/operations.md` «دیپلوی». + +Frontend: push to `upstream` (`Tadrisino/MathApp_frontend`) `main` (the owner authorized pushing and +deploying it without asking), then `cd /home/administrator/mathapp_frontend_new && ./deploy.sh` (builds +into `dist.next`, swaps, keeps `dist.prev`). `config.js` and `index.html` are cached by ArvanCloud; +purge them there when they change. + +Report back: the commit hashes now live, the backup paths created, what you verified and what you did +not. + +--- + +## How to work in this codebase + +**Comments are claims, not evidence.** Most comments were written by earlier agents to justify their +own change. Real bugs have been found directly under comments calling the code "safe" or "race-free". +When asked to find problems, review adversarially and judge by behaviour: write a throwaway test or call +the endpoint. Ask how a cheating student, an attacker or a confused parent would break it. A finding +counts once reproduced; say which ones you only read. When writing comments, report, don't vouch: say +what the code does, why, and what was *not* checked. + +**No silent fallbacks, no speculative backward compatibility.** "Helpful" fallbacks (`x or default`, +`except: pass`, accepting an old field name "just in case") caused the hardest bugs here because the +failure never showed anywhere (a stub tutor answer served with 200 in production; a yearly buyer +silently given 30 days). + +1. Fail loudly over guessing. A missing setting, row or field in production is an error the user and + `/api/health` can see. A default is fine only when it is a real product choice, and its comment says + why that value is right. +2. Demo/stub paths only behind `DEBUG` or `RAG_FORCE_MOCK`; never reachable on the server. +3. No backward compatibility without a named client. The only API client is the frontend + (`~/Desktop/MathApp_frontend/src/api/`); check what it and the server DB actually send. If nobody sends it, delete it. If you must keep one, write who uses it and when it can go. -4. **Never `except: pass`.** At least `logger.exception`. 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. +4. Never `except: pass`. At least `logger.exception`, and swallow only where the comment names the + bigger thing it protects (e.g. affiliate commission must not undo a user's payment). +5. Removing a fallback means the old input fails visibly (400/503), not silently routing elsewhere. -Fallbacks that remain on purpose (re-check before relying on them): settings boot with -default hosts/CORS/cache/secret key (`/api/health` warns); `LocMemCache` without -`CACHE_URL`; `_annotation` defaults for answer-cache entries older than a field; Bazaar's -OAuth path beside the Pishkhan token (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. +Fallbacks that remain on purpose (re-check before relying on them): settings boot with default +hosts/CORS/cache/secret key (`/api/health` warns); `LocMemCache` without `CACHE_URL`; `_annotation` +defaults for answer-cache entries older than a field; Bazaar's OAuth path beside the Pishkhan token +(the server uses OAuth); `birthYear` alone in `PATCH /api/user/profile` (frontend type still allows it); +`totalQuestions` accepted but not trusted (frontend sends it); orders without a `plan_id` or without the +`payment.0011` snapshot fields grant and refund from the plan's current values; a paid order whose plan +was deleted activates for 30 days and logs ERROR. -## Before any real user: read `docs/security-before-production.md` - -Since 2026-10-13 there are no fixed-code logins and no demo users on the server (`TEST_LOGIN_PHONES` -empty; users 11–15 and 25 deleted; test logins are an `OTPCode` made in the admin). Still open: 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). +**The owner** is early in their career: explain the *why* with the *what*, flag product decisions as +product decisions, and never assume a step was done because it was described. **Reply in Persian**, every +sentence including progress lines between tool calls (code, paths and identifiers stay Latin). Keep chat +replies short; depth goes into the deliverable. Docs describe the current state only; reasons and dates belong in +commit messages. Commit in batches, not per small change. ## Commands ```bash source venv/bin/activate -python manage.py runserver +python manage.py runserver # needs DJANGO_DEBUG=1 locally (devOtp, DEV_MASTER_OTP) python manage.py migrate -python manage.py test --noinput # all tests (~60 s, serial) -python manage.py test --noinput accounts # single app -python manage.py test --noinput accounts.tests.SomeTestCase.test_something # single test +python manage.py test --noinput # whole suite (973 tests), serial, ~95 s +python manage.py test --noinput accounts # one app +python manage.py test --noinput accounts.tests.SomeTestCase.test_something +python manage.py spectacular --file schema.yaml --format openapi # the API contract ``` -**Never run the suite with `--parallel` on this Mac, and always pass `--noinput`.** Both hang forever -without an error (2026-10-13, cost 30+ minutes): the test DB is a SQLite *file* (`.test-db.sqlite3`, -needed by the real-concurrency tests), and on macOS Django's spawned parallel workers fail with -`unable to open database file` while the parent waits for them indefinitely. Separately, a test DB left -behind by a killed run makes Django ask «Type 'yes' to delete the old test database» on stdin; in a -background shell nobody answers and it waits forever. `--noinput` deletes it. Serial runs take ~60 s -for the whole suite, so there is nothing to gain. If a run prints nothing for more than ~3 minutes it -is stuck, not slow: kill it (`pkill -f "manage.py test"`) and check the log for the two causes above. +**Never run tests with `--parallel` on this Mac, always pass `--noinput`.** The test DB is a SQLite +*file* (`.test-db.sqlite3`, needed by the real-concurrency tests); parallel workers fail to open it and +the parent waits forever, and a leftover test DB makes Django ask "Type 'yes'" on stdin forever. If a +run prints nothing for ~3 minutes it is stuck: `pkill -f "manage.py test"`. No pytest, no linter. -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/`. +Docker (`docker compose up --build`) adds a local Loki + Alloy + Grafana log stack; dev only +(`docs/docker.md`). ## 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. **Connections come from a per-worker pool** (`OPTIONS.pool`, `DB_POOL_MAX_SIZE` 8; `CONN_MAX_AGE` is gone and `DB_CONN_MAX_AGE` in `.env` stops startup): Postgres here has `max_connections=60`, and the old one-connection-per-thread setup (3 workers × 32 threads) turned 21% of requests into 500s at 100 concurrent users in a local load test against a 60-connection Postgres; with the pool 0 errors. A full pool gives 503 + `Retry-After` (`config/db.py`), not 500; `/api/health` → `dbPool` checks `workers × pool + 6 ≤ max_connections`. The AI tutor and the OTP SMS call hand their connection back before the network wait (`release_db_connection`). Worker count lives in `gunicorn.conf.py` (4; the systemd unit no longer passes `--workers`). **The AI tutor has its own slice of threads** (`AI_MAX_CONCURRENT_PER_WORKER`, 12 of 32, `ai_tutor/concurrency.py`): beyond that `/api/ai/ask` answers 503 `Retry-After: 10` before reserving quota, so a chat spike cannot starve the map and quizzes. The frontend spreads retries over 1–2× `Retry-After`. Not load-tested on the server itself. `deploy.sh` backs up with `pg_dump -Fc` before `migrate`; the off-site live copy is in «بکاپ: کپیِ زنده» below. Operator guide: `~/Desktop/گزارشات Tadrisino/گزارش-۲۹/راهنمای-Postgres-و-Redis.html`. The pre-move `db.sqlite3` and migration dumps were deleted from the server; no SQLite file is left there. - -**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. - -**Android app (WebView shell in the frontend repo, `android/`):** the installed APK lags the code. What the -next build must carry (vibrate bridge, in-app Zarinpal with `paysInApp()`, media-gesture setting), what to -check on a phone, and who owns signing (MH-W90) are in `~/Desktop/MathApp_frontend/android/README.md` -(«نسخه‌ی بعدیِ APK»). Until then payment from the app goes through the frontend's `pay.html`, because -Zarinpal refuses StartPay without our domain as Referer (checked live 2026-10-10). - -**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 and streamed live to the backup server (see «بکاپ» below); restoring onto 249 has been done before (owner, 2026-10-13), so do not list it as an open item. -- 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.** - -## متنِ بی‌اعراب، نقشه‌ی بی‌بنر، برگه‌ی پرداختِ آشنا، حذفِ حساب در «حساب کاربری» — ۲۰۲۶-۱۰-۱۴ - -- **هیچ اعراب و «ی»ِ اضافه‌ای به کاربر نمی‌رسد** (تصمیمِ شرکت): `content.plain_math.plain_persian` (تنوین می‌ماند). - بک‌اند آن را **در خروجی** می‌زند، نه در داده: `config.renderers.PlainPersianJSONRenderer` روی بایت‌های هر پاسخِ JSON، - و `rag.services.mathtext.to_plain` روی پاسخِ دستیار (استریم هم). دیتابیس، seed و ادمین متنِ اصلی را نگه می‌دارند؛ - بازنویسیِ عنوان‌ها اتصالِ سوال به درس (مسیرِ عنوان) و کلیدهای `SeedRecord` را می‌شکست. هزینه: ~۵ میلی‌ثانیه برای - هر مگابایت پاسخ (محلی)؛ زیرِ بارِ سرور اندازه نشده. پیام‌های `JsonResponse`ِ بیرون از DRF دستی تمیز شدند. تست: - `config/tests_plain_persian.py`. فرانت متن‌هایش را مستقیم پاک کرد و `plainPersian` را در `MathText` دارد. -- فرانت: بنرِ همراه و حبابِ متن بالای نقشه رفت؛ همراهِ انتخابی کنارِ جزیره‌ی فعلی و هر سه پیچ (`IslandPath`). برگه‌ی - پرداخت ردیف‌های روش با نشانِ زرین‌پال/بازار دارد و روش‌ها از `paymentMethods` در `config.js` (مرورگر/اپ) می‌آیند. - «ورود با حسابِ دیگر» حذف شد؛ حذفِ حساب در برگه‌ی «حساب کاربری» است (Google Play و App Store «پیداشدنی» می‌خواهند؛ - قاعده‌ی منتشرشده‌ای از کافه‌بازار پیدا نشد). - -## بازبینیِ پروداکشن و سخت‌کردنِ سرور — ۲۰۲۶-۱۰-۱۳ (بک‌اند `e3bddb3` و بعد) - -چک‌لیستِ سینیور: `~/Desktop/گزارشات Tadrisino/گزارش-۳۴/` (HTML و PDF؛ ساز در scratchpad، نه ریپو). - -- **جعلِ IP بسته شد:** `config.middleware.TrustedProxyMiddleware` فقط XFFِ رسیده از رنج‌های آروان را می‌پذیرد - (`TRUSTED_PROXY_NETWORKS`؛ X-Real-IP را nginx می‌گذارد). پیش از آن درخواستِ مستقیم به IP با XFFِ چرخان همه‌ی سقف‌های - IP-محور را دور می‌زد (روی سرور بازتولید شد). -- **nginx (دو سایتِ ما، نسخه در `ops/nginx/`)**: فقط IPهای آروان (مستقیم ۴۰۳)، CSP و هدرهای امنیتی روی فرانت، سقفِ بدنه‌ی - API ۲۰ مگابایت، HSTS. بکاپِ قبلی: `.deploy-backups/nginx-*-before-20261013-nginx-hardening`. سنجیده: از بیرون، و اپ در Chrome - (همه‌ی تب‌ها و آزمونِ جزیره، با ورود) بی هیچ خطای CSP. نسنجیده: WebViewِ اندروید و `pay.html` تا درگاه. -- **حساب‌های دمو پاک شدند** (۱۱–۱۵، ۲۵) و `TEST_LOGIN_PHONES` خالی؛ بکاپ `.deploy-backups/db-before-demo-removal-20261013-190632.pgdump` - و `env-before-demo-removal-20261013-190632`. سقفِ پیامکِ روزانه ۲٬۰۰۰. اعتبارِ کاوه‌نگار آن روز ۲۵۳٬۸۴۱ ریال (شارژ: سینیور). -- **CPUِ ماشین:** `metabase` و `celerybeat`ِ Artina (مالِ دیگران) بی‌وقفه ری‌استارت می‌شوند (۱۷۶ و ~۱۲۰۰ بار در ساعت) و - حدودِ ۳ از ۸ هسته را می‌گیرند. به آن‌ها دست نزن؛ درخواست از ادمینِ سرور. -- **آزمونِ بارِ بازیکن‌ها روی سرور (۱۹:۴۰، `tools/loadtest/play.py`، ۱۰ پروسه، هر کاربر هر ۱ تا ۵ ثانیه یک درخواست):** - ۳۰۰ نفر ۸۸ درخواست/ثانیه، میانه ۰٫۱ ثانیه؛ ۶۰۰ نفر ۱۳۷، میانه ۱٫۴ ثانیه، بی‌خطا؛ ۱٬۰۰۰ نفر سقفِ ~۱۳۵، میانه ۴ و صدکِ ۹۵ ۱۵ ثانیه، - ۳٪ اتصالِ بی‌جواب. هر ۱۶٬۷۶۷ درخواستی که به اپ رسید ۲۰۰ گرفت؛ هیچ ردی برای poolِ دیتابیس نبود. گلوگاه CPU است: ماشین ۹۰ تا ۹۸٪ - مشغول و هر ۴ پروسه‌ی gunicorn نزدیکِ یک هسته. برای ۱٬۰۰۰ نفرِ دسته‌بالا: رفعِ دو سرویسِ خرابِ دیگران + هسته‌ی بیشتر و ۸ worker - (برآورد ~۲۷۰ درخواست/ثانیه؛ با ۸ worker `DB_POOL_MAX_SIZE` ۶ تا ۴۸+۶ ≤ ۵۷). ۸۰ سوالِ واقعیِ دستیار خرج شد. کاربرانِ آزمایشی پاک شدند. -- `/etc/nginx` را classifierِ حالتِ خودکار فقط با تأییدِ صریحِ مالک و گام‌به‌گام (بکاپ → نصب و `nginx -t` → reload) اجازه داد. - -## کافه‌بازار: اشتراک، تخفیفِ پویا، تمدیدِ خودکار — روی سرور از ۲۰۲۶-۱۰-۱۲ ۱۷:۵۴ (بک‌اند `8077ab4`، فرانت `fffdeac`) - -- **محصول‌های پیشخان** (پرسشِ API با توکنِ ساختگی، ۱۰۰۰۰–۱۰۶۹۹): فقط `10342` (یک‌ماهه، تأییدِ سینیور)، `10343` (۹۰ روزه)، - `10344` (۱۸۰ روزه)، زیرِ `com.tadrisino.math.app`؛ هر سه روی پلن‌ها (`subscription.0014`، `0015`). بسته‌ها دیگر فروخته نمی‌شوند. اتصالِ سرور به بازار از راهِ OAuthِ حسابِ شرکت است (کلیدِ پیشخان نداریم). -- **روش‌های پرداخت** در `payment/providers.py` (Strategy + factory)؛ تأییدِ خودِ پرداخت (کال‌بک/verify) عمداً بیرونش. -- **فقط اشتراک؛ بسته‌ی سوال از ریشه حذف شد** (`subscription.0016`–`0018`، `payment.0013`؛ مالک و سینیور): مدل‌های - `QuestionCredit`/`HeartsPass`، `Plan.kind`، خرجِ اعتبار در دستیار، مسیرِ تک‌خریدِ بازار (`inapp`/consume) و - `POST /api/payment/orders` (داخلی، بی‌مصرف‌کننده) رفتند. تنها سفارشِ پرداخت‌شده‌ی بسته مالِ حسابِ آزمایشیِ صاحبِ شرکت - (کاربرِ ۲۶) بود. یک/سه/شش‌ماهه ۵۹۸/۸۹۸/۱٬۳۹۸ هزار تومان (= پیشخانِ بازار به گفته‌ی سینیور)، سقفِ دستیار ۴ در روز برای - همه (و اشتراکِ هدیه‌ی بی‌پلن)؛ رایگان ۲. جدولِ سود در `docs/ai-cost-and-guardrails.md` «اشتراک‌ها». -- **مسیر** و تمدیدِ خودکار: `docs/operations.md` «فروش در کافه‌بازار». `BAZAAR_DYNAMIC_PRICE_KEY` در `.env` سرور است (بکاپ: - `.deploy-backups/env-before-bazaar-dynamic-20261012-175336`)؛ توکن فقط وقتی کدِ تخفیف قیمت را کم کرده می‌رود. -- **فرانت:** دو کاشیِ «کارتِ بانکی» (پیش‌فرض) و «کافه‌بازار» همیشه کنارِ هم؛ بازار جایی که ممکن نیست غیرفعال با دلیل. - هر کارتِ اشتراک مزیت‌ها و سقفِ دستیار را می‌گوید؛ تبِ فروشگاه «اشتراک». داخلِ اپ قیمتِ پیشخان از Poolakey - (`bazaarPrices(skusJson)`) روی کاشیِ بازار. پلِ `bazaarBuy(sku, orderId, dynamicPriceToken)` فقط `subscribeProduct`؛ کلیدِ - RSA و `versionCode` ۲ در `android/`. `compileDebugKotlin` سبز؛ **APK ساخته نشده**. -- **سنجیده:** ۹۷۹ تست؛ روی سایتِ زنده با دموی ۱۴ و پلِ اندرویدِ ساختگی در مرورگر: کاشی‌ها، و سرور SKUِ `10343`/«subscription» - را به اپ داد (سفارشِ ۱۴ بعد لغو شد). **نسنجیده:** هیچ خرید، JWT یا تمدیدِ واقعی در بازار؛ برابریِ قیمتِ پیشخان با ادمین. - -## جزیره‌های تمرینِ تازه و شکل در سوال — ۲۰۲۶-۱۰-۱۲ (عصر) - -- **۱۶۰ جزیره‌ی «تمرین: …»** آخرِ فصل‌های پایه‌های ۳ تا ۱۲، با **۱۳٬۶۸۹ سوالِ تازه** (کلِ `questions.json` ۱۸٬۵۲۲). هر جزیره ۳ تا ۶ درس دارد و هر درس - تا ۳۰ سوال. سوال‌ها با برنامه‌های کوتاهِ تولید ساخته شدند که جوابِ هر سوال را **محاسبه** می‌کنند (نه حفظ)، با گزینه‌ی غلطِ از جنسِ خطای رایج - (علامت، فراموشیِ مشتقِ داخل، …)؛ بخشی هم بانکِ مفهومیِ دست‌نویس است. **برنامه‌ها در ریپو نیستند**؛ فایل‌های seed خودشان منبعند و ویرایش از ادمین/JSON است. - سنجیده: ۹۵۸ تست سبز، چشمیِ نمونه‌ی هر خانواده، و ۳۶ شکلِ نمونه در مرورگر. **نسنجیده:** هر سوال با چشمِ معلم؛ قالبِ سوال‌ها در هر خانواده یکی است (عددها - عوض می‌شوند)، پس تنوعِ مفهومی کمتر از سوالِ دست‌نویس است. -- **`Question.figure`** (`content.0019`): همان قالبِ `Lesson.figures[].diagram`، با `rag.services.diagram.validate` در ورودِ فایل اعتبارسنجی می‌شود (شکلِ - نامعتبر فایل را رد می‌کند، نه بی‌صدا حذف). `/api/nodes/{id}/quiz` و `/api/lessons/{id}/questions` آن را برمی‌گردانند؛ فرانت (`QuizScreen`) زیرِ صورتِ سوال می‌کشد. -- **آزمونِ هر جزیره ثابت است** (تصمیمِ مالک ۲۰۲۶-۱۰-۱۲): مبنای درهم‌ریزی `nodeId:userId` است و هیچ‌چیزِ دیگری (شمارِ دفعات، زمان) در آن نیست؛ دانش‌آموز هر بار همان سوال‌ها را می‌بیند. `content/tests_node_completion.py::NodeQuizStabilityTests`. -- **هیچ LaTeX/markdownی به دانش‌آموز نمی‌رسد** (تصمیمِ مالک ۲۰۲۶-۱۰-۱۲): چهار لایه، هر چهار با یک تبدیل: (۱) `content/plain_math.py::convert` روی seed و روی ورودِ - سوال در `question_io`؛ (۲) migration `content.0021` روی سوال/گزینه/درس/پیام‌های دستیارِ دیتابیس (و حذفِ سوالِ تکراریِ بعد از تبدیل)؛ (۳) `rag.services.mathtext.to_plain` - روی پاسخِ زنده و تاریخچه‌ی دستیار (`ai_tutor/views.py`)؛ (۴) `MathApp_frontend/src/lib/plainMath.ts` در `MathText` به‌عنوانِ آخرین تور (دو پیاده‌سازی باید هم‌رفتار بمانند). - توانِ پیچیده به‌صورتِ «a به توانِ (…)» نوشته می‌شود، نه `^`. تست: `content/tests_plain_math.py` (seed بدونِ نشانه)، `ai_tutor/tests_plain_history.py`. -- **بازخوردِ دانش‌آموز** (`content.0023`): زیرِ هر سوالِ آزمون و هر درس سه دکمه است: 👍 «خوب بود»، 👎 «خوب نبود»، «گزارشِ ایراد» - (`MathApp_frontend/src/components/FeedbackBar.tsx`). `POST /api/questions/{id}/vote` و `/api/lessons/{id}/vote` (`value` ۱/−۱/۰)، - `POST /api/lessons/{id}/report` (جدیدِ درس) و `…/questions/{id}/report` (از قبل). ادمین: ستونِ 👍/👎 در فهرستِ سوال‌ها و درس‌ها، - صفِ «گزارش‌های سوال» و «گزارش‌های درس». رأی هر کاربر روی هر هدف یکی و تغییرپذیر است (`ContentVote`). -- **اصلاحِ جزیره‌های تمرین از فایل:** `seed_sync._refresh_practice` گزینه‌های سوالِ موجودِ «تمرین: …» را در جا (همان idها) با فایل یکی می‌کند - و سوالِ تحویل‌شده‌ی حذف‌شده از فایل را اگر جوابی ندارد پاک می‌کند؛ سوالِ دستیِ ادمین هرگز. -- **بازبینیِ خودکارِ سریع** (۲۰۲۶-۱۰-۱۲): بازحسابِ مستقلِ ۳۴۲ سوالِ حسابِ ساده برابرِ کلید بود؛ ۱۲ سوالِ گزینه‌ی هم‌ارزِ جواب (۰/۳ و ۰) و ۵ درسِ - با سوگیریِ «جوابِ درست بلندترین گزینه» پیدا و اصلاح شد. این جایگزینِ بازبینیِ معلم نیست. -- جزیره‌های تمرین آخرِ هر فصل‌اند و قفلِ ترتیبی دارند؛ فصلِ اولِ هر کتاب رایگان است، پس تمرین‌هایش هم رایگان‌اند. - -## کتاب‌ها (Subject)، جزیره‌های بیشتر و شکلِ درس‌های کتاب‌های تازه — ۲۰۲۶-۱۰-۱۲ - -- **`Subject`** (`content.0014..0018`): ریاضی، حسابان، هندسه، آمار و احتمال، گسسته؛ `Unit.subject` اجباری است. - `/api/home?subject=` (کتابِ بی‌ربط ۴۰۰)؛ `subjects` در پاسخ فقط کتاب‌های همان پایه است (دهم: ریاضی و هندسه؛ - یازدهم: حسابان، هندسه، آمار؛ دوازدهم: حسابان، هندسه، گسسته) و نوارِ فرانت (`SubjectBar`) فقط وقتی بیش از یکی باشد می‌آید. - فصلِ رایگان = اولین فصلِ **هر کتاب**. فایل‌های seed حالا `subject` دارند و بی‌آن رد می‌شوند. -- **پنج کتابِ تازه** (هندسه ۱۰/۱۱/۱۲، آمار ۱۱، گسسته ۱۲) روی Qdrant خوانده شده‌اند. ۲۰۲ جزیره‌ی تازه با نامِ دستی، - ۱۱۳۰ + ۲۰ سوالِ نوشته‌ی خودم (حلِ مستقل، نه از کلیدِ کتاب)، و مثال/قدم برای همه‌ی درس‌های هندسه (و برای درس‌های آمار/گسسته‌ای که شکل دارند). -- **شکل‌ها را دستی نوشتم، نه با API.** `gen_lesson_figures` روی هندسه حدود نیمی غلط داد و کنار گذاشته شد (بازبینیِ چشمی). حالا - `Lesson.figures` برای ۵۰۳ درسِ هندسه (از ۵۸۹)، ۸۲ درسِ آمار و ۶۸ درسِ گسسته با مشخصه‌ی `rag.services.diagram` نوشته و با - `rag.services.diagram.validate` اعتبارسنجی شده؛ هر شکل با Chrome رندر و با چشم دیده شد (دو دور روی نمونه‌ی تصادفی). - ندارند: درس‌های جبریِ ماتریس‌ها، اثبات‌های جبری، چند درس‌ِ آمار/گسسته‌ی مفهومی. - جدول‌های درستی با `kind: table` کشیده شده‌اند؛ ترتیبِ ستون‌ها در پیش‌نمایشِ من راست‌به‌چپ بود (به نمایشِ زنده نگاه کن). -- **تقسیمِ جزیره‌ها** (`content/node_split.py`، `0018`): جزیره‌ی بیش از ۷ درس به قسمت‌های ≤۶ درسی شکسته می‌شود؛ شناسه‌ی درس - و پیشرفتِ کاربر حفظ می‌شود. نامِ قسمت‌ها در کتاب‌های قدیمی «عنوان (۲)…» است و دستی نام‌گذاری نشده. -- `gen_lesson_figures` حالا `--subject` دارد و خطای شبکه یک درس را رد می‌کند (کلِ اجرا نمی‌افتد). نکته: `--dry-run` هم API را صدا می‌زند. -- **روی سرور از ۲۰۲۶-۱۰-۱۲ ۰۵:۳۷** (بک‌اند `8cb1ecb`، سرویس واقعاً ری‌استارت شد؛ فرانت `f569ece`). بکاپِ پیش از migrate: - `.deploy-backups/db-before-deploy-20261012-053603.pgdump`. `content.0014–0018` اعمال شد (۲۷۸ جزیره شکسته، ۴۶۴ جزیره‌ی تازه، - ۲٬۲۳۷ درس جابه‌جا، ۲۴ تکمیل کپی)، `seed_content`: ۲۰۲ جزیره، ۱۱۱۰ درس، ۱۱۳۰ سوالِ تازه. با کاربرِ آزمایشیِ پایه‌های ۱۰ و ۱۱ و ۱۲ - بررسی شد: نوارِ کتاب‌ها (۲/۳/۳ دکمه)، خانه و درس‌ها همگام، درسِ هندسه با شکل در مرورگر، آزمونِ جزیره (۴ سوال، پایان ← جزیره‌ی بعد)، - `?subject=` نامعتبر ۴۰۰، و دستیار روی هندسه ۱۰ و آمار ۱۱ از chunkهای همان کتاب جواب داد. این آزمون‌ها روی دموها اثر گذاشت: - کاربر ۱۲ جزیره‌ی اول هندسه را تمام کرد و کاربر ۱۳ دو سوالِ دستیار پرسید. -- **بعداً در خودِ اپ دیده شد (۲۰۲۶-۱۰-۱۲):** جدولِ درستی چپ‌به‌راست و درست رندر می‌شود (پیش‌نمایشِ بی‌CSS گمراه‌کننده بود)، - و برچسبِ جزیره در نقشه حالا تا ۳ خط می‌رود (`IslandPath.tsx`، فرانت `18eeb54`) تا پسوندِ «(۲)» بریده نشود. -- **حساب‌های آزمونگر (۲۰۲۶-۱۰-۱۲):** `UNLOCK_ALL_PHONES` در `.env` سرور (`09387731214`، `09211643376` = کاربرهای ۳۵ و ۳۴؛ بکاپِ - `.env`: `.deploy-backups/env-before-unlockall-20261012`) فقط قفلِ ترتیبِ جزیره‌ها را برمی‌دارد (`user_has_all_nodes_unlocked`). - اشتراکِ فصل‌ها را یک ردیفِ `Subscription` (پلنِ یک‌ساله تا ۲۰۲۸-۱۲-۳۱) می‌دهد، که قلب و سقفِ دستیار را هم عادی می‌کند. - روی سرور با `HomeView` برای هر دو سنجیده شد: صفر جزیره‌ی قفل و صفر فصلِ اشتراکی در هر سه کتابِ دوازدهم. -- **سوالِ جزیره‌ها:** چهار جزیره‌ی تقسیم‌شده‌ی پایه‌های ۳ تا ۷ خالی بودند و سوال گرفتند؛ آزمونِ `test_every_topic_has_at_least_one_question` نگهشان می‌دارد. - -## ظرفیت زیرِ بار — روی سرور از ۲۰۲۶-۱۰-۱۱ (بک‌اند `98bf05f`، فرانت `16cf5c9`) - -گزارشِ سینیور: `~/Desktop/گزارشات Tadrisino/گزارش-۳۳/ظرفیتِ-اپ-زیرِ-بار.html` (و PDF). بکاپِ تغییرهای یک‌باره‌ی -سرور: `.deploy-backups/env-before-db-pool-20261011-202211` (حذفِ `DB_CONN_MAX_AGE`) و -`unit-gunicorn-mathapp-before-db-pool-20261011-202211.service` (حذفِ `--workers 3`؛ تعداد حالا در `gunicorn.conf.py`). - -- **سنجیده:** لپ‌تاپ با Postgresِ ۱۴ و `max_connections=60` (مثلِ سرور)، ۱۰۰ کاربرِ هم‌زمان: پیش از pool ۲۱٪ خطای ۵۰۰، - بعد ۰ خطا و ۷۱۴ درخواست در ثانیه (۴ worker). روی خودِ سرور، نسخه‌ی موقتِ ۲ worker، ۴۰ کاربر، ۱۵ ثانیه: ۰ خطا، - ۱۱۲ درخواست در ثانیه، p99 ۰٫۶۷ ثانیه، وقتی CPUِ ماشین ~۸۰٪ مشغول بود. ثبتِ جواب ۱۵٫۴→۱۱ ms، `/api/home` ۱۲٫۴→۸٫۶ ms. -- **برآورد (نه سنجیده):** ۱۰۰۰ تا ۱۵۰۰ بازیکنِ هم‌زمان (فرض: یک درخواست در ۸ ثانیه)، ۴ تا ۶ هزار آنلاین. سقفِ واقعی CPUِ - مشترک است، نه حافظه یا دیتابیس؛ worker بیشتر روی همین ماشین ظرفیت نمی‌سازد، CPUِ دیگران را می‌گیرد. -- **سقف‌های تنظیمی:** پیامکِ کلِ سامانه روی سرور ۲٬۰۰۰ در روز (`OTP_SMS_GLOBAL_DAILY_MAX` در `.env`، از ۲۰۲۶-۱۰-۱۳ به - تصمیمِ مالک برای لانچِ ۶۰۰ نفره؛ پیش‌فرضِ کد ۳۰۰) و دستیار ۳۰۰۰ سوال در روز (`AI_GLOBAL_DAILY_MAX`، در `.env` نیست). - هر IP: ۱۰۰ درخواستِ کد و ۳۰۰ ثبتِ کد در ساعت (تصمیمِ مالک، برای کلاس روی یک وای‌فای). -- **پیشنهادشده، ساخته نشده:** جلسه‌ی آزمون به سبکِ دولینگو (سوال و کلید با هم دانلود، سنجش روی گوشی، ارسالِ یک‌جا - و سنجشِ دوباره در سرور) بارِ بازی را حدوداً نصف می‌کند؛ کلید روی گوشی می‌رود، پس تصمیمِ محصولی است. کلِ محتوا - فشرده ≈ ۱ مگابایت است. -- **نسنجیده:** GapGPT با ۴۸ سوالِ هم‌زمان، کاوه‌نگار زیرِ رگبار، رفتارِ ArvanCloud با صدها نفر از یک IP. - -**تستِ بار روی سرور (ماشینِ مشترک، با احتیاط):** throttleهای پروداکشن دست نمی‌خورند؛ یک gunicornِ موقت روی سوکتِ جدا -در `~/mathapp-v2/.lt/` با `READ_THROTTLE_RATE` بالا و **`DB_POOL_MAX_SIZE=4 --workers 2`** (تا با ۳۲ اتصالِ پروداکشن از ۵۷ -نگذرد)، فقط GET با یک حسابِ تستی، ۱۵ ثانیه، و آخرش پاک کردنِ `.lt`. در اسکریپت `wait` نزن: منتظرِ همان gunicornِ پس‌زمینه -می‌ماند و تا ابد گیر می‌کند (یک بار شد). روی لپ‌تاپ: `ab` در هم‌زمانیِ بالا خودش خراب می‌شود و macOS صفِ اتصال را ۱۲۸ و -پورت‌های کلاینت را محدود می‌کند؛ gunicorn را روی سوکتِ unix بالا بیاور و بارزنِ پایتونی بزن. - -## بکاپ: کپیِ زنده روی سرورِ ۲۴۷ — از ۲۰۲۶-۱۰-۱۰ - -**هر تغییرِ دیتابیس لحظه‌ای به سرورِ دیگری می‌رود؛ بکاپِ شبانه‌ی خودمان (`backup_db`/`backup_verify`/cron) حذف شد.** -شرحِ کامل و رویه‌ی بازیابی: `docs/operations.md` «بکاپ و بازیابی». فایل‌ها: `ops/pgbackup/`. - -- **سرورِ بکاپ:** `administrator@78.38.35.247`، همان رمزِ ۲۴۹ (همان فایلِ رمز). شبکه‌ی داخلی: ۲۴۹ = `192.168.200.164`، - ۲۴۷ = `192.168.200.167`. **۲۴۷ هم ماشینِ مشترک است** (artina، judge0، metaverse، docker، و `postgresql@14-main`ِ - دیگران روی ۵۴۳۲): همان قواعدِ «The server» این‌جا هم هست. مالِ ما فقط `~/zareian-pgbackup/` و یونیت‌های - `zareian-pg-*` است. Postgresِ آن‌جا را به کار نمی‌گیریم؛ فقط ابزارهای Postgres 14 را اجرا می‌کنیم. -- **چطور:** روی ۲۴۷ `zareian-pg-tunnel` (SSH با کلیدِ `~/.ssh/zareian_pgbackup` که در `authorized_keys`ِ ۲۴۹ به - `permitopen="127.0.0.1:5440"` محدود است، شِل نمی‌دهد) → `zareian-pg-wal` (`pg_receivewal`، اسلاتِ `zareian_247`، - نقشِ `zareian_replicator`، رمزش فقط در `~/.pgpass`ِ ۲۴۷) → `wal/`؛ و `zareian-pg-basebackup.timer` هر روز ۰۴:۳۰ → - `base/` (۷ تا، با `pg_verifybackup`؛ WALِ قدیمی‌تر از قدیمی‌ترین پاک می‌شود). -- **۲۴۹ را نمی‌خواباند:** تکثیر ناهمزمان است (`sync_state: async`)؛ `max_slot_wal_keep_size=1GB` (با `ALTER SYSTEM`، - نه `/etc`) سقفِ چیزی است که در قطعیِ ۲۴۷ روی دیسکِ ۲۴۹ جمع می‌شود. قطعیِ طولانی‌تر اسلات را `lost` می‌کند (رویه در docs). -- **پایش:** `OFFSITE_WAL_SLOT=zareian_247` در `.env`ِ ۲۴۹؛ `/api/health` (با کلید) `offsiteBackup` و هشدار می‌دهد - (`config/tests_offsite_backup.py`). شکستِ نسخه‌ی روزانه فقط در `journalctl -u zareian-pg-basebackup`ِ ۲۴۷ دیده می‌شود. -- **دفترِ خرید** (`payment.PaymentEvent`، از ۲۰۲۶-۱۰-۱۳، `44ad2ea`): هر قدمِ خرید یک ردیف؛ با WAL به ۲۴۷ می‌رود و هر ۶ ساعت - `zareian-pg-export-events` آن را (با سفارش/تراکنش) CSV در `~/zareian-pgbackup/payment-log/` می‌کند. سنجیده: کال‌بکِ جعلی روی ۲۴۹ → ردیف → CSVِ ۲۴۷. - نسنجیده: یک خریدِ واقعی تا آخر. شرح: `docs/operations.md` «دفترِ خرید». -- **لاگِ برنامه برای همیشه** (از ۲۰۲۶-۱۰-۱۳، `3559c43`): `LOG_FILE_ROTATION=external` در `.env`ِ ۲۴۹ (بکاپ: - `.deploy-backups/env-before-log-rotation-20261013-175453`)؛ آرشیوِ فشرده‌ی شبانه روی ۲۴۹ (۳۰ روز) و کشیدنِ ساعتی به - `~/zareian-pgbackup/app-logs/`ِ ۲۴۷ (هرگز پاک نمی‌شود) با کلیدِ forced-command. شرح: `docs/operations.md` «لاگِ برنامه». -- **سنجیده (۲۰۲۶-۱۰-۱۰):** کلید شِل نمی‌گیرد و تونل به پورتِ دیگر رد می‌شود؛ `restore-check.sh` روی ۲۴۷ شمارش و آخرین - زمانِ کاربر/ورود/سفارش/تراکنش/تغییرِ وضعیت را برابرِ ۲۴۹ داد؛ یک ردیفِ آزمایشی که بعد از نسخه‌ی کامل روی ۲۴۹ نوشته شد - ۲ ثانیه بعد در بازیابیِ ۲۴۷ بود (جدولش پاک شد). **نسنجیده:** ری‌بوتِ ۲۴۷، قطعیِ طولانی تا `lost`، اجرای خودکارِ timer - (اولین بار ۲۰۲۶-۱۰-۱۱ ۰۴:۳۰). برگرداندن روی خودِ ۲۴۹ پیش‌تر انجام شده (به گفته‌ی مالک، ۲۰۲۶-۱۰-۱۳). -- ایمیجِ روزانه‌ی کلِ ماشین کارِ ادمینِ سرور است؛ محتوایش را ما نسنجیده‌ایم. کپیِ ما به آن تکیه نمی‌کند. -- «بکاپِ خودکارِ قبلی روی ۲۴۷» وجود نداشت: `~/backup_mathapp.sh`ِ ۲۴۹ (کپیِ SQLite و journal با scp) فقط ۲۰۲۶-۰۸-۲۹ دستی اجرا شده و در هیچ cron/timerی نیست. خودش و خروجی‌هایش (`~/backups/` روی ۲۴۹، `~/backups/mathapp/` روی ۲۴۷) مرده‌اند؛ پاک کردنشان بیرون از `~/mathapp-v2` است و اجازه‌ی جدا می‌خواهد. - -## اشتراک و دیوارِ پرداخت — روی سرور از ۲۰۲۶-۱۰-۱۰ ۰۱:۱۹ (بک‌اند `64c4c09`، فرانت `a8a5351`) - -- **فقط فصلِ اولِ هر پایه رایگان است** (`content.views.unit_is_free`). بیرون از آن، بی‌اشتراکِ فعال، سوال‌های درس، - آزمونِ جزیره، ثبتِ جواب و پایانِ درس/جزیره ۴۰۲ `subscription_required` می‌دهند؛ `/api/home` برای هر فصل - `requiresSubscription` دارد. تست: `content/tests_paywall.py`. -- قیمت‌ها و سقفِ دستیار از `subscription.0016` (بخشِ کافه‌بازار بالا). رایگان ۲ سوال در روز (`AI_DAILY_QUOTA_FREE`). -- حساب‌های دمو اشتراک ندارند، پس از فصلِ دوم به بعد برایشان قفل است. -- از سرور، `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`** — فهرستِ «شماره:کد» برای ورود بی‌پیامک. **از ۲۰۲۶-۱۰-۱۳ روی سرور خالی است** - (تصمیمِ مالک: کدِ ثابت نکته‌ی امنیتی است) و حساب‌های دمو پاک شدند؛ `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 نمی‌شود) و مرحله‌ی «نگه‌داریِ بکاپ‌ها» بقیه‌ی `.deploy-backups` را مرتب می‌کند (بکاپِ دستیِ پیش از -تغییر بعد از ۳۰ روز پاک می‌شود). کپیِ واقعیِ داده بیرون از ماشین است؛ بخشِ پایین. - -**⚠️ 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`. - -بخش‌های تاریخیِ زیر به‌ترتیبِ زمان نگه داشته شده‌اند چون درس‌هایشان هنوز معتبر است؛ -**اعدادِ محتوایی‌شان کهنه‌اند و ملاک همین بخش است.** +| `accounts` | identity, OTP login, sessions, profile | `User`, `Profile`, `Session`, `OTPCode`, `LoginEvent` | +| `content` | subjects, curriculum, lessons, questions, quiz, votes/reports | `Grade`, `Subject`, `Unit`, `Node`, `Lesson`, `Question`, `Choice`, `UserAnswer`, `UserNodeCompletion`, `ContentVote`, `SeedRecord` | +| `progress` | stats, streaks, hearts, leaderboard, companions, weekly report | `UserStats`, `StreakRecord`, `UserHearts`, `LeaderboardEntry`, `Character` | +| `ai_tutor` · `rag` | tutor chat; retrieval from Qdrant + generation via GapGPT; textbook ingest | `Conversation`, `Message`, `LessonChunk`, `IngestedPage` (book list: `rag/services/textbooks.py`) | +| `subscription` | plans and subscriptions | `Plan`, `Subscription` | +| `payment` | orders, transactions, Zarinpal, Cafe Bazaar, payment event ledger | `Order`, `Transaction`, `PaymentEvent` | +| `discount` · `affiliate` | discount codes/offers; referral commissions | `DiscountCode`, `Redemption`, `Offer`, `AffiliateAccount`, `Conversion` | +| `notifications` · `support` · `campaigns` | notifications; tickets/bug reports; campaign storage (no sending) | | +| `statuslog` | cross-app status-change log | `StatusChangeLog` | +| `config` | settings, urls, middleware, health, renderers | | + +Invariants that are not obvious from one file: + +- **Auth is ours, not Django's.** `accounts.User` is deliberately not `AUTH_USER_MODEL`. Access token = + signed `user_id` (1 h, `django.core.signing`, `accounts/authentication.py`); refresh tokens live + (hashed) in `Session`; logout sets `User.tokens_valid_after`. A missing/invalid header means an anonymous + request; each view's permission class decides. Login is phone + SMS code only (6 digits, 5 guesses); + there is no guest login. The signing key is `.django_secret_key` (generated on the server, mode 600); + the `django-insecure-…` fallback in `config/settings.py` must never sign tokens on a server. +- **No cross-app foreign keys.** Apps refer to each other with `user_id` / `object_id` + `label` so any app + can be split out. Follow the pattern. +- **Throttling**: every sensitive view has a `throttle_scope` and a matching rate in + `REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']`. New endpoint = both. Anonymous requests are limited by IP; + `config.middleware.TrustedProxyMiddleware` trusts `X-Forwarded-For` only from ArvanCloud ranges + (`TRUSTED_PROXY_NETWORKS`), and nginx refuses anything not from Arvan. +- **Grade id = grade number** (8 = eighth). Units belong to a `Subject` (ریاضی، حسابان، هندسه، آمار و احتمال، + گسسته); `/api/home?subject=` picks the book, an unrelated subject is 400. +- **Domains are configuration**: only `PUBLIC_BASE_URL` and `FRONTEND_URL`; everything else derives from + them (`config/tests_domains.py`). Domain-move procedure: `docs/operations.md`. +- **Output is plain Persian** (company decision): `config.renderers.PlainPersianJSONRenderer` strips + diacritics and the written ezafe «ی» from every JSON response, and `rag.services.mathtext.to_plain` does + the same for the tutor (stream included). The DB, seed and admin keep the original text, because rewriting + titles would break question→lesson binding by title path. No LaTeX/markdown reaches a student: seed and + import convert (`content/plain_math.py`), the tutor converts, and the frontend's `plainMath.ts` is the + last net; the two converters must behave alike. +- **Structured logging**: `APIRequestLoggingMiddleware` logs every `/api/` request as one JSON line (no + body, headers or secrets). Unexpected errors always return JSON (`config/exceptions.py`). +- **`.iterator()` never wraps a loop that writes or does network I/O**: the open read cursor held locks + for the whole loop (a "flaky" test that was a real bug). Materialize with `list(...)`. +- **Network calls stay outside `transaction.atomic()`**, and the tutor and the OTP SMS call hand their DB + connection back before the network wait (`release_db_connection`). + +## Current state, by area + +### Infrastructure (server 249) + +- Postgres cluster `14/zareian`, port 5440, localhost only (`DB_*` in `.env`; without `DB_NAME` the app + uses SQLite, which is what local dev and tests use). `max_connections=60`. +- Connections come from a per-worker pool (`DB_POOL_MAX_SIZE` 8); a full pool answers 503 + `Retry-After` + (`config/db.py`), and `/api/health` → `dbPool` checks `workers × pool + 6 ≤ max_connections`. + `DB_CONN_MAX_AGE` in `.env` stops startup on purpose. +- gunicorn: `gunicorn.conf.py`, 4 `gthread` workers × 32 threads (the unit passes no `--workers`). The tutor + gets at most `AI_MAX_CONCURRENT_PER_WORKER` (12) threads per worker; beyond that `/api/ai/ask` answers 503 + `Retry-After: 10` before reserving quota. +- Cache: our own Redis (`zareian-redis`, 6390, password, 256 MB `allkeys-lru`, no persistence) via + `CACHE_URL`. `config.cache.ResilientRedisCache` fails open on a Redis outage (logged, health 503, cache-based + limits stop applying); not tested against a real outage on the server. +- nginx (versions in `ops/nginx/`): only ArvanCloud and the machine itself get through (direct IP → 403); + CSP and security headers on the frontend; API body limit 20 MB; HSTS. Verified in Chrome; **not** verified + in the Android WebView or through `pay.html` to the gateway. +- Capacity (load test on the server, `tools/loadtest/play.py`): 300 players ≈ 88 req/s, median 0.1 s; + 600 ≈ 137 req/s, median 1.4 s, no errors; 1,000 hits the ceiling (~135 req/s, p95 15 s). The bottleneck + is the shared CPU, not the DB pool. More capacity needs the neighbours' crash-loops fixed plus more cores + (then 8 workers with `DB_POOL_MAX_SIZE` 6). +- `/api/health`: public answer is only ok/error; full details (DB, migrations, cache, pool, integrations, + `aiProvider`, `offsiteBackup`, warnings, `clientIp`) need `X-Internal-Key`. Swagger/ReDoc/schema need an + admin session. + +### Backups and logs (server 247) + +Every Postgres change streams live to 247 (`pg_receivewal` over a restricted SSH tunnel, slot +`zareian_247`, async; `max_slot_wal_keep_size=1GB` protects 249's disk) plus a daily base backup (7 kept). +`deploy.sh` also takes a `pg_dump -Fc` before `migrate` (last 2 kept). Payment events are exported to CSV on +247 every 6 hours; the app log is archived nightly on 249 and pulled hourly to 247, where it is kept forever. +Restore procedure, monitoring and what was tested: `docs/operations.md` «بکاپ و بازیابی». Restoring onto 249 +has been done before. + +### Content + +- `content/seed/` is the whole curriculum and question bank as committed JSON: 10 grades (3–12), 5 subjects, + 89 units, 1,145 islands, 5,406 lessons, 18,522 questions. `deploy.sh` runs `seed_content` on every release. +- `seed_content` is idempotent, never deletes, and does not undo admin work: the `SeedRecord` ledger remembers + what it delivered, so anything the admin deleted or renamed is not recreated; lesson text, `example`, + `steps` and `figures` are only filled when empty. Questions bind to lessons **by title path** + (grade › unit › node › lesson), never by id. Practice islands («تمرین: …») are the exception: their + options are synced from the file in place (`seed_sync._refresh_practice`). +- Content changes reach the server only as committed seed files (`seed_content --export`); pushing code + alone moves no content. Lesson ids differ between laptop and server. +- Islands longer than 7 lessons are split into parts of ≤6 (`content/node_split.py`). 160 practice islands + close each chapter (grades 3–12) with computed-answer questions. +- `Lesson.figures` and `Question.figure` are fixed diagrams (same spec as the tutor's, validated by + `rag.services.diagram.validate`); nothing calls a model at display time. Geometry/statistics/discrete + figures were hand-written and checked by eye; API-generated figures (`gen_lesson_figures`) were about half + wrong on geometry and are only used with visual review (`--dry-run` still calls the API). +- Students vote 👍/👎 and report problems on every question and lesson (`ContentVote`, admin columns and + report queues). +- Not yet filled: `Unit.blurb` for some units, `Plan.features`/`badge`; no video content exists although + `Lesson.kind`/`video_url` can express it. + +### Playing: islands, quizzes, hearts + +- `GET /api/nodes/{id}/quiz`: at most one question per lesson, shuffled on `nodeId:userId` only, so a student + sees the same quiz every time (owner's decision; `NodeQuizStabilityTests`). +- `POST /api/nodes/{id}/quiz/complete` completes the island at any score (score sets stars/XP) and returns + `nextNodeId`. It refuses a locked island (403 `node_locked`, same rule as `/api/home`) and a user who has + not answered every question (409 `answers_missing`; required = `max(client totalQuestions, min(4, questions + in node))`). An island with no questions passes when unlocked. `POST /api/lessons/{id}/complete` has the same + rules. XP counts only answers recorded in `UserAnswer`. +- Hearts are enforced (`HEARTS_ENFORCED`): a wrong answer costs one, one returns every 5 h, all return 24 h + after the first loss; zero hearts → 403 `no_hearts`. An active subscription means unlimited hearts. +- `UNLOCK_ALL_PHONES` (server `.env`: the owner's and a tester's numbers, users 34 and 35) removes the island + order lock for testers; their content access comes from a `Subscription` row. +- Streaks and the leaderboard are computed live from activity (Tehran time); companions (sigma, delta, pi, + omega) unlock by XP. + +### Paywall, subscriptions, payment + +- Only the first unit of **each book** is free. Elsewhere, without an active subscription, lesson questions, + island quizzes, answers and completion return 402 `subscription_required`; `/api/home` marks each unit + `requiresSubscription`. +- Only subscriptions are sold (question packs were removed at the root): 1/3/6 months at + 598/898/1,398 thousand toman; the tutor allows 4 questions a day for subscribers and + `AI_DAILY_QUOTA_FREE` (2) for free users; `AI_GLOBAL_DAILY_MAX` 3,000 for the whole system. +- Zarinpal: `paymentUrl` points at our `GET /api/payment/orders/{id}/pay`, which calls PaymentRequest and + redirects; the callback always re-verifies server-to-server. Unverified paid orders are re-checked when the + app reads status and by `payment_reconcile`. The merchant is the one `Tadrisino_backend` uses (registered + for `tadrisino2.ir`); PaymentRequest returns 100. **No real payment has gone through to verify yet.** +- Cafe Bazaar: subscription SKUs `10342`/`10343`/`10344` under package `com.tadrisino.math.app`, set on the + plans in admin; dynamic-price discounts via `BAZAAR_DYNAMIC_PRICE_KEY`; renewals are polled from Bazaar + (`sync_bazaar_renewals`). The server talks to Bazaar through the company account's OAuth. Payment methods + differ only in `payment/providers.py`. **No real Bazaar purchase or renewal has been tested.** +- Every purchase step writes a `PaymentEvent` row. Cancel and refund are admin actions on + `/admin/payment/order/` (the money itself is returned in the gateway's panel). +- Both gateways and SMS fail closed (503 / invalid) without credentials, never a fake success. + +### AI tutor + +- `POST /api/ai/ask` is RAG: Qdrant retrieval filtered by book and chapter → GapGPT generation + (`OPENAI_BASE_URL`, model `gpt-4.1-mini`, prompt `tutor-v12-age-tone`). `usedChunks` names the book chunks + used. Without `OPENAI_API_KEY`/`QDRANT_API_KEY` and `DEBUG=0` it answers 503, never a stub. +- Answers are plain text, step by step, math-only (`rag/services/guardrails.py`; self-harm messages get a + kind reply with helpline numbers), tone by age, with an optional `` JSON block that the frontend + draws (`rag/services/diagram.py`, frontend `src/diagram/`). Solutions marked `disagrees` never reach the + model. With `questionId` it sees the quiz question and the student's last answer. +- Cost ≈ 0.2 cents per question (plan with ~700 toman). `/api/health` → `aiProvider` shows when GapGPT credit + runs out; every live call spends the same production credit, laptop included. + +### Accounts, SMS, users + +- Kavenegar sends OTP only through the approved template `SMSVerify` (`verify/lookup`, + `KAVENEGAR_OTP_TEMPLATE`); plain `sms/send` is cancelled on this line, so never unset the template. Every + OTP costs credit. Limits are counted in the DB: per-phone cooldown and 10/day, system-wide + `OTP_SMS_GLOBAL_DAILY_MAX` (2,000 on the server), per-IP 100 requests and 300 logins per hour. +- No fixed-code test logins on the server (`TEST_LOGIN_PHONES` empty; health warns if set) and no demo users. + To test without SMS, a superuser creates an `OTPCode` in `/admin/accounts/otpcode/add/` (random code, a few + minutes), then `POST /api/auth/logout` afterwards. For a browser check, put the tokens in + `localStorage['tadrisino.tokens']` and drive Chrome with `puppeteer-core` from the scratchpad; never print + the access token. +- Profile: Jalali `birthDate`, display id `User482917` (`accounts/public_id.py`), weekly report + (`/api/user/weekly-report`, minutes are estimated), invoices, account deletion with a fresh SMS code + (frontend: «حساب کاربری»). + +### Frontend and Android + +- Frontend: React 18 + TypeScript (strict) + Vite + Tailwind; the client is `src/api/` and is the only + place that calls the server. Its README is the map. Never write the client from memory: read the schema and + check shapes against a running server (`content/tests_frontend_contract.py` is the backend-side guard). +- Android: WebView shell plus Poolakey for Bazaar. The installed APK lags the code; what the next build must + carry and what to check on a phone is in `android/README.md`. Signing belongs to teammate MH-W90; never + commit a keystore. Until the new APK, card payment from the app goes through the frontend's `pay.html` + (Zarinpal refuses StartPay without our domain as Referer). + +### Still open before real users + +`docs/security-before-production.md` is the list (public `/admin/`, plaintext OTP in the admin, SMS-cap +exhaustion, shared server account, …). Do not call this deployment production-ready until it is empty. When +one is closed, move it to that file's «حفاظ‌هایی که هست» table with the test that holds it. --- -### تستِ flakyِ campaigns یک باگِ واقعی بود (۲۰۲۶-۰۹-۲۲) +## Lessons that were expensive -`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 را کش می‌کند. +- **GapGPT** (OpenAI-compatible proxy reachable from Iran; it answers 504 from outside Iran). Two failures + look alike: `429 initial_usage_limit_exceeded` is an hours-long account cap that retries *extend*; + `403 insufficient user quota` (with spaces) is exhausted credit. `is_account_limit()` in + `rag_verify_solutions` is the shared guard. `/dashboard/billing/usage` (cents) is the only reliable balance; + probe it before any bulk run. Credit has run dry more than once. +- **GapGPT spend stays at cents per session**: the laptop key is the production tutor's account (another + person uses it too); one benchmark of ~250 calls once emptied it and the live tutor returned 503. Estimate + first, test plumbing with `tools/fake_openai.py`, ask before anything bigger. +- **Measure models; don't read price tables.** GapGPT prices differ from official ones; the judge model in + `rag_model_benchmark` misjudges ~7%, so hand-check samples before deciding. Vision ingest uses + `gpt-4.1-mini` (`docs/extraction-playbook.md`). +- **Bulk content is written by Claude Code subagents, not the paid API**: Sonnet authors questions well + (~88% yield), Haiku does not (~40% broken). The weekly Sonnet limit arrives suddenly: predict the cost and + import results between batches. Every generated batch gets an independent blind re-solve. +- **Verification is three-state** (`agrees`/`disagrees`/`unjudgeable`); a boolean verdict reported ~3× the + real error rate. Question generation reads only `agrees` chunks, and `agrees` is a first filter, not a + guarantee. `solution` is never rewritten; the independent answer is in `checked_answer`. In these books + `۳/۱۰۴` is the decimal 3.104, and in primary books «/» is both decimal separator and fraction bar. +- `rag_verify_solutions --recheck` resends everything; to re-check a subset, clear its verdicts. Never run two + writing management commands at once on local SQLite ("database is locked" left a run half done, silently). +- In shell wait loops use `pgrep -f`, not `ps | grep` (it counts itself). +- A load test or wait loop on the server must not `wait` on a background gunicorn (it hangs forever). diff --git a/README.md b/README.md index d4d65fd..1bdeab4 100644 --- a/README.md +++ b/README.md @@ -1,117 +1,118 @@ # Tadrisino API -بک‌اندِ اپلیکیشنِ آموزشیِ تدریسینو — یک REST API روی Django که برنامه‌ی درسی، -آزمون‌ها، پیشرفتِ کاربر، دستیارِ هوشِ مصنوعی، اشتراک و پرداخت را سرو می‌کند. +بک‌اندِ **تدریسینو**، اپِ آموزشِ ریاضی به سبکِ دولینگو برای دانش‌آموزانِ پایه‌ی سوم تا دوازدهم. دانش‌آموز روی نقشه‌ای +از جزیره‌ها جلو می‌رود، هر جزیره آزمونی کوتاه از مسئله‌های خودِ کتابِ درسی است، و هر جا گیر کند از دستیارِ هوشِ +مصنوعی می‌پرسد که جوابش را از همان کتاب می‌سازد. -فرانت‌اند ریپوی جداگانه‌ای است ([`MathApp_frontend`](https://gitea.tadrisino2.ir/Tadrisino/MathApp_frontend)). -**قراردادِ بینِ دو طرف، اسکیمای OpenAPI همین ریپوست** — نه حافظه‌ی کسی، نه یک -فایلِ دستی. هر تغییری در API باید از آنجا دیده شود. +این مخزن یک REST API روی Django است: برنامه‌ی درسی و آزمون، پیشرفت و گیمیفیکیشن، دستیارِ مبتنی بر RAG، اشتراک +و پرداخت. فرانت‌اند مخزنِ جداگانه‌ای است ([`Tadrisino/MathApp_frontend`](https://gitea.tadrisino2.ir/Tadrisino/MathApp_frontend)). +**قراردادِ میانِ دو طرف اسکیمای OpenAPIِ همین مخزن است**؛ هر تغییری در API از آن‌جا دیده می‌شود. | | | |---|---| -| **وضعیت** | روی پروداکشن، در حالِ سرویس‌دهی | -| **آدرسِ زنده** | `https://mathapi.tadrisino2.ir` · [سلامت](https://mathapi.tadrisino2.ir/api/health) | -| **استک** | Python 3.11+ · Django 5.2 · DRF · SQLite · Qdrant (RAG) | -| **API** | ۵۹ اندپوینت · مستنداتِ زنده در `/api/docs/` | -| **تست** | ۷۷۹ تست، همه سبز (`python manage.py test`) | -| **محتوا** | ۸ پایه · ۵۸ فصل · ۲۶۳ نود · ۲٬۶۱۷ درس · ۲٬۵۴۵ سوال | +| **سرویسِ زنده** | `https://mathapi.tadrisino2.ir` · اپ: `https://math.tadrisino2.ir` | +| **فناوری** | Python 3.11 · Django 5.2 · Django REST Framework · PostgreSQL 14 · Redis · Qdrant · gunicorn پشتِ nginx و ArvanCloud | +| **API** | ۶۱ عملیات روی ۵۹ مسیر، مستند با OpenAPI (`/api/docs/`) | +| **محتوا** | ۱۰ پایه · ۵ کتاب · ۸۹ فصل · ۱٬۱۴۵ جزیره · ۵٬۴۰۶ درس · ۱۸٬۵۲۲ سوال | +| **تست** | ۹۷۳ تستِ خودکار، همه سبز | +| **سرویس‌های بیرونی** | GapGPT (مدلِ زبانی) · Qdrant (جستجوی کتاب) · کاوه‌نگار (پیامک) · زرین‌پال و کافه‌بازار (پرداخت) | + +--- + +## امکانات + +| بخش | چه می‌کند | +|---|---| +| **ورود** | فقط با شماره‌ی موبایل و کدِ پیامکی؛ توکنِ دسترسیِ امضاشده‌ی یک‌ساعته و توکنِ تمدید | +| **برنامه‌ی درسی** | پایه ← کتاب ← فصل ← جزیره ← درس ← سوال؛ کلِ محتوا به‌صورتِ JSONِ کامیت‌شده در `content/seed/` | +| **بازی** | آزمونِ هر جزیره، قفلِ ترتیبی، قلب، ستاره و امتیاز، زنجیره‌ی روزانه، جدولِ رتبه‌بندی، همراه‌ها | +| **دستیار** | پاسخِ گام‌به‌گام از روی همان کتاب (Qdrant + GapGPT)، فقط ریاضی، با شکلِ توصیفی که اپ می‌کشد | +| **اشتراک** | فصلِ اولِ هر کتاب رایگان؛ اشتراکِ یک/سه/شش‌ماهه از زرین‌پال یا کافه‌بازار، با کدِ تخفیف و برنامه‌ی معرفی | +| **پنلِ ادمین** | ویرایشِ محتوا، ورود و خروجِ فله‌ای سوال، پلن‌ها، سفارش‌ها (لغو و بازپرداخت)، تیکت‌ها، گزارش‌های کاربران | +| **پایش** | `/api/health` (دیتابیس، مهاجرت، کش، سرویس‌های بیرونی، بکاپ)، لاگِ JSON برای هر درخواست، دفترِ رویدادِ پرداخت | --- ## شروعِ سریع ```bash -python3 -m venv venv && source venv/bin/activate +python3.11 -m venv venv && source venv/bin/activate pip install -r requirements.txt -cp .env.example .env # برای توسعه‌ی محلی: DJANGO_DEBUG=1 را ست کنید +cp .env.example .env # برای توسعه‌ی محلی DJANGO_DEBUG=1 را ست کنید python manage.py migrate -python manage.py seed_content # برنامه‌ی درسی و سوال‌های واقعی از content/seed/ -python manage.py runserver +python manage.py seed_content # برنامه‌ی درسی و بانکِ سوال از content/seed/ +python manage.py createsuperuser # برای /admin/ +python manage.py runserver # http://127.0.0.1:8000 ``` -سرویس روی `http://127.0.0.1:8000` بالا می‌آید. `.env` خودکار خوانده می‌شود — -لازم نیست چیزی را `export` کنید. +بدونِ `DB_NAME` در `.env` دیتابیسِ محلی SQLite است و نیازی به نصبِ Postgres نیست. `.env` خودکار خوانده می‌شود. -| | | +| آدرس | چیست | |---|---| -| Swagger UI | `/api/docs/` | -| Redoc | `/api/redoc/` | -| اسکیمای OpenAPI | `/api/schema/` | -| پنلِ ادمین | `/admin/` | -| سلامتِ سرویس | `/api/health` | +| `/api/docs/` · `/api/redoc/` · `/api/schema/` | مستنداتِ زنده‌ی API (روی سرور فقط با ورود به پنلِ ادمین) | +| `/admin/` | پنلِ ادمین | +| `/api/health` | سلامتِ سرویس | -**اولین درخواست در Swagger:** در حالتِ `DJANGO_DEBUG=1` با `POST /api/auth/otp/request` -شماره‌ای بفرستید و با `POST /api/auth/login` و کدِ `DEV_MASTER_OTP` وارد شوید؛ `token` را -بردارید، روی **Authorize** کلیک کنید و همان رشته را (بدونِ کلمه‌ی `Bearer`) وارد کنید. -ورودِ مهمان نداریم. - -دستورهای پرکاربردِ دیگر: +**اولین درخواست:** با `DJANGO_DEBUG=1`، `POST /api/auth/otp/request` با یک شماره، سپس `POST /api/auth/login` با +کدی که در فیلدِ `devOtp` پاسخ آمده. `token` را در Swagger زیرِ **Authorize** بگذارید. ```bash -python manage.py test # کلِ تست‌سوییت -python manage.py createsuperuser # دسترسی به /admin -python manage.py seed_test_data # داده‌ی ساختگی برای توسعه (فقط محلی) +python manage.py test --noinput # کلِ تست‌ها (سریال؛ --parallel روی macOS گیر می‌کند) +python manage.py test --noinput content # تست‌های یک اپ +python manage.py spectacular --file schema.yaml --format openapi # خروجیِ قرارداد برای فرانت ``` +برای استکِ لاگِ محلی (Loki و Grafana) [`docs/docker.md`](docs/docker.md) را ببینید. + --- ## معماری -هر اپ یک مسئولیت دارد و مدل‌های خودش را نگه می‌دارد: +یک برنامه‌ی جنگو در قالبِ «مونولیتِ ماژولار»: هر اپ یک مسئولیت دارد و مدل‌های خودش را نگه می‌دارد. | اپ | مسئولیت | |---|---| -| `accounts` | هویت، session، ورود با OTP | -| `content` | برنامه‌ی درسی، درس، سوال، پیشرفت روی نقشه | -| `progress` | آمار، لیدربرد، streak، شخصیت‌های بازی | -| `ai_tutor` · `rag` | دستیارِ هوشِ مصنوعی: بازیابی از Qdrant + تولید با GPT | -| `subscription` · `payment` · `discount` | پلن، سفارش، تراکنش، کدِ تخفیف | -| `notifications` · `affiliate` · `support` · `campaigns` | امکاناتِ جانبی | -| `statuslog` | لاگِ عمومیِ تغییرِ وضعیت، مستقل از بقیه | -| `config` | تنظیمات، روت‌های اصلی، middleware | +| `accounts` | هویت، ورود با کدِ پیامکی، نشست، پروفایل | +| `content` | کتاب‌ها، برنامه‌ی درسی، درس، سوال، آزمونِ جزیره، بازخوردِ کاربر | +| `progress` | آمار، زنجیره‌ی روزانه، قلب، رتبه‌بندی، همراه‌ها، گزارشِ هفتگی | +| `ai_tutor` · `rag` | دستیار: گفتگو، بازیابی از Qdrant، تولید با GapGPT، ورودِ کتاب | +| `subscription` · `payment` · `discount` · `affiliate` | پلن و اشتراک، سفارش و تراکنش، کدِ تخفیف، برنامه‌ی معرفی | +| `notifications` · `support` · `campaigns` · `statuslog` | اعلان، پشتیبانی، کمپین، لاگِ تغییرِ وضعیت | +| `config` | تنظیمات، مسیرها، middleware، سلامت | -سه تصمیمِ معماری که غیرِبدیهی‌اند و دانستنشان لازم است: +سه قاعده که از خواندنِ یک فایل پیدا نیست: -- **احرازِ هویت، سیستمِ خودمان است نه جنگو.** توکنِ bearerِ امضاشده و stateless - با `django.core.signing` (بدونِ کتابخانه‌ی JWT)، پشتیبانی‌شده با مدلِ - `accounts.Session`. `accounts.User` عمداً `AUTH_USER_MODEL` **نیست** — یک - مدلِ بیزنسیِ مستقل است. هدرِ نامعتبر یعنی درخواستِ ناشناس، نه خطا؛ هر ویو - خودش تصمیم می‌گیرد که آیا ورود لازم است یا نه (`accounts/authentication.py`). -- **اپ‌ها با قرارداد از هم جدا شده‌اند، نه با ForeignKey.** ارجاعِ بینِ اپ‌ها با - `user_id` و `object_id` + `label` انجام می‌شود تا هر اپ بعداً قابلِ جدا شدن - باشد. **FK بینِ اپ‌ها اضافه نکنید.** -- **محدودیتِ نرخ (throttle) متمرکز است.** هر اندپوینتِ حساس یک - `throttle_scope` دارد و نرخش در `REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']` - داخلِ `config/settings.py` تعریف می‌شود. اندپوینتِ جدید یعنی **هر دو**. +- **احرازِ هویت سیستمِ خودِ پروژه است.** `accounts.User` عمداً `AUTH_USER_MODEL` نیست؛ توکن با `django.core.signing` + امضا می‌شود (`accounts/authentication.py`). هدرِ نامعتبر یعنی درخواستِ ناشناس، و هر ویو خودش تصمیم می‌گیرد ورود + لازم است یا نه. +- **اپ‌ها با قرارداد جدا شده‌اند، نه با ForeignKey.** ارجاعِ بینِ اپ‌ها با `user_id` یا `object_id` + `label` است تا + هر اپ بعداً جداشدنی بماند. +- **محدودیتِ نرخ متمرکز است.** هر اندپوینتِ حساس یک `throttle_scope` دارد و نرخش در `config/settings.py` است؛ IPِ + واقعی فقط از پروکسی‌های ArvanCloud پذیرفته می‌شود. -جزئیاتِ بیشتر: [`docs/architecture.md`](docs/architecture.md). +شرحِ کامل و دلیلِ هر تصمیم در [`docs/architecture.md`](docs/architecture.md) و +[`docs/technical-handbook.md`](docs/technical-handbook.md) آمده است. --- -## دیپلوی +## استقرار -کلِ انتشار یک خط است: +سرویس روی یک سرورِ مشترکِ شرکت اجرا می‌شود و مالِ این پروژه فقط پوشه‌ی `~/mathapp-v2` و سرویسِ +`zareian-mathapp` است. کلِ انتشار: ```bash -ssh -t administrator@78.38.35.249 \ - 'cd ~/mathapp-v2 && ./deploy.sh; sudo systemctl restart zareian-mathapp' +ssh -t administrator@78.38.35.249 'cd ~/mathapp-v2 && ./deploy.sh; sudo systemctl restart zareian-mathapp' ``` -`deploy.sh` کد را می‌گیرد، وابستگی‌ها را نصب می‌کند، مهاجرت می‌زند، محتوا را -seed می‌کند، فایل‌های استاتیک را جمع می‌کند و در پایان **ثابت می‌کند که سرویس -واقعاً ری‌استارت شده** — نه اینکه فقط فرض کند. ری‌استارت جداست چون `sudo` -داخلِ اسکریپت نمی‌تواند رمز بپرسد. - -پس از هر انتشار، از بیرون و بدونِ SSH تأیید کنید: +`deploy.sh` کد را با `origin/main` یکی می‌کند، وابستگی‌ها را نصب می‌کند، پیش از `migrate` از دیتابیس بکاپ می‌گیرد، +محتوا را seed و فایل‌های استاتیک را جمع می‌کند و سلامتِ سرویس را می‌سنجد. سپس از بیرون: ```bash -curl https://mathapi.tadrisino2.ir/api/health +curl https://mathapi.tadrisino2.ir/api/health # {"status": "ok"} ``` -`200` یعنی سالم، `503` یعنی چیزی واقعاً خراب است. هیچ سکرتی برنمی‌گرداند. - -رویه‌ی کامل — متغیرهای محیطی، ورودِ توسعه‌دهنده‌ها، بکاپ، عیب‌یابی: -[`docs/operations.md`](docs/operations.md). +هر تغییرِ دیتابیس لحظه‌ای به سرورِ بکاپِ جداگانه‌ای می‌رود. رویه‌ی کامل (متغیرهای محیطی، بکاپ و بازیابی، nginx، +عوض کردنِ دامنه، فروش در کافه‌بازار): [`docs/operations.md`](docs/operations.md). --- @@ -119,31 +120,20 @@ curl https://mathapi.tadrisino2.ir/api/health | سند | موضوع | |---|---| -| [`docs/operations.md`](docs/operations.md) | دیپلوی، متغیرهای محیطی، سلامت، بکاپ، ورودِ توسعه‌دهنده‌ها | -| [`docs/technical-handbook.md`](docs/technical-handbook.md) · [PDF](docs/technical-handbook.pdf) | **مستندِ فنیِ کامل و تحویلِ پروژه:** معماری، مدلِ سناریو و رابطه‌ای، API، Qdrant، گیمیفیکیشن، امنیت، استقرار، مقیاس | -| [`docs/security-before-production.md`](docs/security-before-production.md) | **پیش از کاربرِ واقعی:** هرچه برای دمو عمداً باز مانده، خطرش و راهِ بستنش | -| [`docs/content-authoring.md`](docs/content-authoring.md) | افزودن و ویرایشِ محتوا از پنلِ ادمین، ساختِ انبوهِ سوال | +| [`docs/technical-handbook.md`](docs/technical-handbook.md) · [PDF](docs/technical-handbook.pdf) | مستندِ فنیِ کامل برای تحویل و پیوستنِ توسعه‌دهنده‌ی تازه | | [`docs/architecture.md`](docs/architecture.md) | تصمیم‌های معماری و دلیلشان | -| [`docs/frontend/README.md`](docs/frontend/README.md) | راهنمای اتصالِ فرانت‌اند + کلاینتِ تایپ‌دار | -| [`docs/docker.md`](docs/docker.md) | استکِ لاگِ محلی (Loki + Alloy + Grafana) | -| [`docs/extraction-playbook.md`](docs/extraction-playbook.md) | استخراجِ محتوا از کتاب با مدلِ ویژن و هزینه‌هایش | +| [`docs/operations.md`](docs/operations.md) | استقرار، پیکربندی، سلامت، بکاپ و بازیابی، nginx، پرداخت | +| [`docs/security-before-production.md`](docs/security-before-production.md) | آنچه پیش از کاربرِ واقعی باید بسته شود، و حفاظ‌های موجود | +| [`docs/content-authoring.md`](docs/content-authoring.md) | ویرایشِ محتوا از پنلِ ادمین و ساختِ انبوهِ سوال | +| [`docs/extraction-playbook.md`](docs/extraction-playbook.md) | خواندنِ کتابِ درسی با مدلِ تصویری، هزینه و انتخابِ مدل | +| [`docs/ai-cost-and-guardrails.md`](docs/ai-cost-and-guardrails.md) · [`docs/model-prices.md`](docs/model-prices.md) | هزینه‌ی دستیار، حاشیه‌ی سودِ اشتراک‌ها، دفترِ قیمتِ مدل‌ها | +| [`tools/loadtest/README.md`](tools/loadtest/README.md) | آزمونِ بار | +| [`CLAUDE.md`](CLAUDE.md) | راهنمای agentهای کدنویس، به‌ویژه قواعدِ کار روی سرورِ مشترک | --- -## محدودیت‌های شناخته‌شده +## وضعیت -اینها عمداً نوشته شده‌اند تا کسی دوباره کشفشان نکند: - -- **`CACHE_URL` روی سرور خالی است، عمداً**، پس محدودیتِ نرخ بینِ workerها مشترک نیست. Redisِ روی آن ماشین - مالِ تیم‌های دیگر است و به آن وصل نمی‌شویم؛ Redisِ اختصاصیِ خودمان یک تصمیمِ باز پیش از لانچ است. - `/api/health` این را هشدار می‌دهد. -- **`KAVENEGAR_API_KEY` تنظیم نشده**، پس `POST /api/auth/otp/request` برای - شماره‌های واقعی `503` می‌دهد. راهِ ورودِ تیمِ توسعه در - [`docs/operations.md`](docs/operations.md) توضیح داده شده. -- **پوششِ محتوا یکدست نیست.** پایه‌های هشتم تا دوازدهم کاملند (هر نود چهار - سوال)؛ در پایه‌های سوم/ششم/هفتم ۳۹ نود از ۱۴۵ کمتر از چهار سوال دارند — - هیچ‌کدام خالی نیست، پس روی نقشه نودِ غیرقابلِ بازی وجود ندارد. -- **محتوای ویدیویی وجود ندارد**، هرچند `Lesson.kind` و `video_url` می‌توانند - بیانش کنند. -- **`DEV_MASTER_OTP` (کد `0000`) باید پشتِ `DEBUG` بماند.** روشن بودنش یعنی هر - کسی می‌تواند به‌جای هر کاربری وارد شود. `/api/health` هشدارش می‌دهد. +سرویس زنده است و با حساب‌های آزمونگر سنجیده می‌شود؛ هنوز به روی کاربرِ عمومی باز نشده. آنچه پیش از آن باید بسته +شود در [`docs/security-before-production.md`](docs/security-before-production.md) فهرست شده است. پرداختِ واقعی (زرین‌پال +و کافه‌بازار) تا انتهای مسیر هنوز آزموده نشده است. diff --git a/config/settings.py b/config/settings.py index 3302b9d..ca2cc21 100644 --- a/config/settings.py +++ b/config/settings.py @@ -687,8 +687,8 @@ SPECTACULAR_SETTINGS = { 'VERSION': '1.0.0', 'CONTACT': {'name': 'تیمِ بک‌اندِ تدریسینو'}, 'EXTERNAL_DOCS': { - 'description': 'راهنمای اتصالِ فرانت‌اند', - 'url': 'https://gitea.tadrisino2.ir/Tadrisino/MathApp/src/branch/main/docs/frontend/README.md', + 'description': 'فرانت‌اند: تنها کلاینتِ این API (src/api/)', + 'url': 'https://gitea.tadrisino2.ir/Tadrisino/MathApp_frontend', }, # ترتیبِ تگ‌ها در Swagger به همین ترتیب است، نه الفبایی — یعنی مسیرِ # واقعیِ یک کاربر: اول وارد شو، بعد محتوا بگیر، بعد پیشرفت و بقیه. diff --git a/data/textbooks/README.md b/data/textbooks/README.md index ab9d6de..a8be601 100644 --- a/data/textbooks/README.md +++ b/data/textbooks/README.md @@ -8,20 +8,18 @@ نسخه‌ی رسمی و به‌روزِ کتاب‌ها: (از خارج ایران در دسترس نیست؛ دانلود باید از داخل ایران انجام شود.) -## اولویت (به‌روزشده ۱۴۰۵/۰۶/۲۱) +## کتاب‌های ثبت‌شده -سینیور اولویت را عوض کرد: **از پایه ششم به بالا**، با تمرکز روی پایه‌های -ترانزیشنی ۶، ۷، ۹ و ۱۰. کتاب‌های ابتدایی (اول/دوم/چهارم/پنجم) که قبلاً -دانلود شده‌اند فعلاً در نوبت نیستند. +فهرستِ کامل در `rag/services/textbooks.py` است؛ همه روی Qdrant‌اند، هر کتاب در collectionِ خودش. -| پایه | وضعیت | +| کتاب | خوانده‌شده با | |---|---| -| سوم، ششم، هفتم | روی Qdrant هست (ساخته‌ی تیم دیگر) — PDF نداریم جز هفتم | -| هشتم، نهم، دهم | ingest شده؛ بازخوانیِ صفحه‌های شکل‌دار مانده | -| یازدهم، دوازدهم | در نوبتِ ingest (۱۴۰۵/۰۶/۲۸) | -| هندسه دهم/یازدهم/دوازدهم، آمار و احتمال یازدهم، گسسته دوازدهم | ثبت و ingest شد ۲۰۲۶-۱۰-۱۱ (`subject` = «هندسه»، «آمار و احتمال»، «گسسته»؛ هر کدام collectionِ خودش) | -| چهارم، پنجم | ingest شد ۲۰۲۶-۱۰-۰۳ (درخواستِ دکتر: «از سوم به بعد»)؛ پنجم چاپِ ۱۴۰۵ است | -| اول، دوم | PDF موجود است، خارج از اولویتِ فعلی | +| ریاضیِ سوم، ششم، هفتم | تیمِ دیگرِ شرکت (پیش از قاعده‌ی «شکل را در متن بنویس»؛ بیشترِ مسئله‌های وابسته به شکل این‌جاست) | +| ریاضیِ چهارم، پنجم، هشتم، نهم، دهم | ما، با `gpt-4.1-mini` | +| حسابانِ یازدهم و دوازدهم | ما | +| هندسه‌ی دهم، یازدهم، دوازدهم · آمار و احتمالِ یازدهم · گسسته‌ی دوازدهم | ما | + +اولِ و دومِ دبستان PDF دارند ولی ثبت نشده‌اند. ⚠️ کتابِ ریاضیِ یازدهم و دوازدهم چند رشته دارد (تجربی، ریاضی‌فیزیک، انسانی). نامِ فایل و `Textbook.grade` باید رشته را مشخص کند، وگرنه دو کتابِ متفاوت diff --git a/docs/architecture.md b/docs/architecture.md index 513dc70..b0d92cd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -21,7 +21,7 @@ (`POST /api/auth/logout`) refreshِ همان دستگاه را باطل می‌کند و `User.tokens_valid_after` را می‌گذارد؛ هر توکنِ access که پیش از آن لحظه صادر شده رد می‌شود (دقتِ ثانیه‌ای). دستگاه‌های دیگر با refreshِ خودشان بی‌صدا توکنِ تازه -می‌گیرند. پیش از ۲۰۲۶-۰۹-۲۵ خروج وجود نداشت. +می‌گیرند. **کدِ ورود ۶ رقمی است و هر کد بعد از ۵ حدسِ غلط باطل می‌شود** (`OTPCode.failed_attempts`، شمارش در دیتابیس، نه در کشِ per-worker). @@ -53,15 +53,15 @@ `X-Forwarded-For` قابلِ اعتماد است و **باید با تعدادِ واقعیِ پروکسی‌ها یکی باشد**، وگرنه جعلِ IP و دور زدنِ محدودیت ممکن می‌شود. -روی پروداکشن این عدد **۲** است (پیش‌فرضِ کد از ۲۰۲۶-۰۹-۲۴): ArvanCloud جلوی -سرور است و بعد nginxِ خودِ ماشین. تا آن روز ۱ بود و جنگو IPِ آروان را IPِ کاربر -می‌دید؛ یعنی همه‌ی کاربرانِ بی‌ورود یک سهمِ مشترک داشتند. `clientIp` در `/api/health` +روی پروداکشن این عدد **۲** است: ArvanCloud جلوی سرور است و بعد nginxِ خودِ ماشین. با عددِ کمتر، IPِ آروان +IPِ کاربر دیده می‌شود و همه‌ی کاربرانِ بی‌ورود یک سهمِ مشترک می‌گیرند. `clientIp` در `/api/health` (با کلیدِ داخلی) نشان می‌دهد سرور صدازننده را با چه IPای می‌شناسد — باید IPِ عمومیِ خودتان باشد. -⚠️ سرور روی پورتِ ۸۰ مستقیم هم (بدونِ آروان) جواب می‌دهد. کسی که IPِ سرور را -بداند می‌تواند از آن راه XFF جعل کند و محدودیتِ IP را دور بزند. ورودِ OTP جدا -به ازای هر شماره هم محدود است، پس این راه به حدسِ کد نمی‌رسد. بستنِ کاملش (پورتِ -۸۰ فقط برای رنج‌های آروان، یا `set_real_ip_from` در nginx) کارِ ادمینِ سرور است. +`X-Forwarded-For` فقط وقتی پذیرفته می‌شود که درخواست واقعاً از ArvanCloud آمده باشد +(`config.middleware.TrustedProxyMiddleware`، فهرستِ `TRUSTED_PROXY_NETWORKS`؛ آدرسِ TCP را nginx در `X-Real-IP` +می‌گذارد). وگرنه کسی که مستقیم به IPِ سرور بزند و در هر درخواست XFFِ تازه بفرستد، از همه‌ی سقف‌های IP-محور رد +می‌شود. لایه‌ی دوم در nginx است: هر درخواستی که از آروان نیامده ۴۰۳ می‌گیرد (`ops/nginx/zareian-arvan-only.conf`). +وقتی آروان رنجِ IPِ تازه بگیرد، هر دو فهرست با هم تازه می‌شوند (`docs/operations.md` «nginx»). ## پرداخت: هرگز مستقیم به درگاه @@ -78,7 +78,7 @@ PaymentRequest را می‌زند، `Order.gateway_authority` را ذخیره م **fail-closed** می‌شوند (`503` یا نامعتبر)، نه موفقیتِ ساختگی. همین قاعده برای پیامک هم برقرار است. -قاعده‌هایی که بازبینیِ ۲۰۲۶-۰۹-۲۵ اضافه کرد (هرکدام با تست در +قاعده‌های پرداخت (هرکدام با تست در `payment/tests_audit_fixes.py`): | قاعده | چون پیش‌تر… | @@ -110,8 +110,8 @@ PaymentRequest را می‌زند، `Order.gateway_authority` را ذخیره م جستجوی Qdrant محدود به همان درس/فصل → تولید با GPT. تکه‌های استفاده‌شده در `usedChunks` برمی‌گردند، پس پاسخ قابلِ ردیابی به منبع است. -- نیازمندِ `OPENAI_API_KEY` و `QDRANT_API_KEY`. بدونِ کلید، همان پاسخِ stubِ - قبلی برمی‌گردد (سازگاریِ عقب‌رو). +- نیازمندِ `OPENAI_API_KEY` و `QDRANT_API_KEY`. بی‌کلید و با `DEBUG=0` پاسخ ۵۰۳ است، نه جوابِ ساختگی + (`StubNeverReachesProductionTests`)؛ مسیرِ نمونه فقط با `DEBUG` یا `RAG_FORCE_MOCK` در دسترس است. - `"stream": true` پاسخ را به‌صورتِ SSE توکن‌به‌توکن می‌فرستد؛ قراردادِ JSON بدونِ این فلگ دست‌نخورده است. - `python manage.py rag_catalog` فهرستِ زنده‌ی collectionهای Qdrant را نشان @@ -122,7 +122,11 @@ PaymentRequest را می‌زند، `Order.gateway_authority` را ذخیره م کیفیتِ محتوایی که این لایه می‌خواند در [`content-authoring.md`](content-authoring.md) توضیح داده شده. -### شکلِ پاسخ (پرامپتِ `tutor-v3-stepwise-plain`، ۲۰۲۶-۰۹-۲۵) +### شکلِ پاسخ + +پرامپتِ فعلی `tutor-v12-age-tone` است (`rag/services/generation.py`، `PROMPT_VERSION`). جدولِ زیر قاعده‌هایی است که +از `tutor-v3` به بعد در آن مانده‌اند؛ فقط‌ریاضی‌بودن در `docs/ai-cost-and-guardrails.md` و لحن بر اساسِ سن (۱۸+ لحنِ +بزرگسال در هر پایه، کشِ جواب با لحن کلید می‌خورد) در `tone_for`. | چه | چرا | |---|---| @@ -145,7 +149,7 @@ temperature و با `OPENAI_CHAT_REASONING_EFFORT` صدا زده می‌شوند هزینه‌ی هر پاسخ از `usage.cost`ِ خودِ GapGPT خوانده می‌شود، و `--context-cache` بخش‌های بازیابی‌شده را ذخیره می‌کند تا چند اجرا (چند مدل یا چند نسخه‌ی پرامپت) دقیقاً یک context ببینند. -**انتخابِ فعلی: `gpt-4.1-mini` (۲۰۲۶-۰۹-۲۶).** دورِ اول ۱۴ مدل روی ۳۰ مسئله: پنج مدل +**انتخابِ فعلی: `gpt-4.1-mini`.** دورِ اول ۱۴ مدل روی ۳۰ مسئله: پنج مدل بی‌خطا بودند و 4.1-mini ارزان‌ترینشان بود. دورِ دوم 4o-mini در برابرِ 4.1-mini روی ۱۰۲ مسئله با سه نسخه‌ی پرامپت: ۹۵–۹۶ در برابرِ ۸۵–۹۰. نوسانِ 4o-mini با پرامپتِ یکسان تا ۵ مسئله بود، پس این فاصله واقعی است. خطاهای 4o-mini بیشتر حسابِ ساده بود (۸۴۵۱ − ۷۲۳۴ = ۱۳۱۷). هر پاسخ @@ -172,7 +176,7 @@ temperature و با `OPENAI_CHAT_REASONING_EFFORT` صدا زده می‌شوند `/api/health` آن را در `integrations.aiProvider` و `warnings` نشان می‌دهد. پیش‌تر `openai: true` فقط یعنی «کلید تنظیم شده». -### شکل در جواب (پرامپتِ `tutor-v10-shape-anchor`، ۲۰۲۶-۰۹-۲۹) +### شکل در جواب مدل HTML یا SVG نمی‌نویسد. در پایانِ جوابِ شکل‌دار یک بلوکِ کوتاهِ `{JSON}` می‌گذارد که فقط **توصیف** است («مثلثی با AB = 6، AC = 8 و زاویه‌ی قائمه در A»). فرانت (`src/diagram/`) از روی @@ -211,9 +215,8 @@ temperature و با `OPENAI_CHAT_REASONING_EFFORT` صدا زده می‌شوند آزمونش تمام شده `completed` می‌بیند، و مسیرِ قدیمیِ «همه‌ی درس‌ها تک‌تک» هم هنوز کار می‌کند. -**جزیره با درس‌ها هم تمام می‌شود، ولی فقط درس‌های سوال‌دار شمرده می‌شوند** -(۲۰۲۶-۰۹-۲۵). ۵۸۵ درس هنوز سوال ندارند و اپ برایشان دکمه‌ی شروع ندارد؛ با شرطِ -«همه‌ی درس‌ها» ۱۸۱ جزیره از ۲۶۹ هرگز تمام نمی‌شد. هر نود در `/api/home` +**جزیره با درس‌ها هم تمام می‌شود، ولی فقط درس‌های سوال‌دار شمرده می‌شوند**؛ با شرطِ «همه‌ی درس‌ها»، جزیره‌ای که +درسِ بی‌سوال دارد هرگز تمام نمی‌شد. هر نود در `/api/home` `nextLessonId` دارد (اولین درسِ سوال‌دارِ تمام‌نشده) و **عضوِ اولِ `lessonIds` همیشه همان است** — فرانت با کلیک روی جزیره `lessonIds[0]` را باز می‌کند و پیش‌تر هر بار درسِ اول را می‌گرفت. جزیره‌ای که یک بار تمام شد (`NodeProgress` یا @@ -222,7 +225,34 @@ temperature و با `OPENAI_CHAT_REASONING_EFFORT` صدا زده می‌شوند `progressPercent` در `/api/home` برای همان پایه‌ای است که نقشه‌اش نشان داده می‌شود: درس‌های سوال‌دارِ تمام‌شده به‌اضافه‌ی درس‌های جزیره‌های تمام‌شده، تقسیم -بر درس‌های سوال‌دارِ آن پایه. (قبلاً مخرج همه‌ی ۲٬۶۱۷ درسِ همه‌ی پایه‌ها بود.) +بر درس‌های سوال‌دارِ آن پایه. + +**آزمونِ هر جزیره ثابت است:** مبنای درهم‌ریزی فقط `nodeId:userId` است، پس دانش‌آموز هر بار همان سوال‌ها را +می‌بیند (تصمیمِ مالک؛ `NodeQuizStabilityTests`). + +**پایان فقط برای جزیره‌ی باز و آزمونِ جواب‌داده:** `quiz/complete` و `lessons/{id}/complete` جزیره‌ی قفل را ۴۰۳ +`node_locked` می‌دهند (همان قاعده‌ی `/api/home`، `content.views.node_is_unlocked`) و کاربری که همه‌ی سوال‌ها را +جواب نداده ۴۰۹ `answers_missing`. تعدادِ لازم `max(totalQuestions ِ کلاینت، min(4، سوال‌های جزیره))` است تا کلاینت +نتواند کمش کند. جزیره یا درسِ بی‌سوال، اگر باز باشد، بی‌ستاره و بی‌امتیاز رد می‌شود تا زنجیره را نبندد. + +## کتاب‌ها و دیوارِ پرداخت + +هر فصل (`Unit`) به یک کتاب (`Subject`: ریاضی، حسابان، هندسه، آمار و احتمال، گسسته) تعلق دارد. `/api/home?subject=` +نقشه‌ی همان کتاب را می‌دهد و کتابِ بی‌ربط به پایه ۴۰۰ است؛ `subjects` در پاسخ فقط کتاب‌های همان پایه است. + +**فصلِ اولِ هر کتاب رایگان است** (`content.views.unit_is_free`). بیرون از آن، بی‌اشتراکِ فعال، سوال‌های درس، آزمونِ +جزیره، ثبتِ جواب و پایانِ درس/جزیره ۴۰۲ `subscription_required` می‌دهند و `/api/home` برای هر فصل +`requiresSubscription` دارد (`content/tests_paywall.py`). + +## متنِ خروجی: ساده و بی‌اعراب + +تصمیمِ شرکت: هیچ اعراب و «ی»ِ اضافه‌ی نوشته‌شده («پایه‌ی سوم») به کاربر نمی‌رسد؛ تنوین می‌ماند. این **در خروجی** +انجام می‌شود، نه در داده: `config.renderers.PlainPersianJSONRenderer` روی هر پاسخِ JSON و `rag.services.mathtext.to_plain` +روی پاسخِ دستیار (استریم هم). دیتابیس، seed و ادمین متنِ اصلی را نگه می‌دارند، چون بازنویسیِ عنوان‌ها اتصالِ سوال به +درس (که با مسیرِ عنوان است) و کلیدهای `SeedRecord` را می‌شکست. تست: `config/tests_plain_persian.py`. + +هیچ LaTeX یا markdownی هم به دانش‌آموز نمی‌رسد: `content/plain_math.py` روی seed و ورودِ سوال، `to_plain` روی دستیار، +و `plainMath.ts` در فرانت به‌عنوانِ آخرین تور؛ این دو پیاده‌سازی باید هم‌رفتار بمانند. ## دستیار و سوالِ تمرینی @@ -242,10 +272,6 @@ temperature و با `OPENAI_CHAT_REASONING_EFFORT` صدا زده می‌شوند ## استریک و جدولِ رتبه‌بندی، زنده از فعالیت -تا ۲۰۲۶-۰۹-۲۴ هیچ‌جای اپ `StreakRecord` نمی‌نوشت و `GET /api/leaderboard` فقط -جدولی را می‌خواند که با اجرای دستیِ `rebuild_leaderboard` پر می‌شد؛ یعنی برای -کاربرِ واقعی «روز پیاپی» همیشه صفر بود و XPاش هیچ‌وقت در جدول دیده نمی‌شد. - - **استریک** (`progress/services.py`): پاسخ به سوال، پایانِ درس و پایانِ جزیره امروز را (به وقتِ تهران) روزِ فعال ثبت می‌کنند. نمایش با `effective_current_streak` است: اگر آخرین روزِ فعال نه امروز است نه دیروز، @@ -260,12 +286,11 @@ temperature و با `OPENAI_CHAT_REASONING_EFFORT` صدا زده می‌شوند قلب برمی‌گردد** (`HEART_REFILL_INTERVAL`) و **۲۴ ساعت بعد از اولین قلبی که از حالتِ پر کم شد، همه یک‌جا پر می‌شوند** (`HEART_FULL_REFILL_AFTER`، فیلدِ `full_refill_at`). `GET /api/user/hearts` هر دو زمان را می‌دهد: `nextRefillAt` -و `fullRefillAt`. تا ۲۰۲۶-۰۹-۲۴ هر قلب ۲۴ ساعت بود؛ تایمرِ قدیمیِ بلندتر از ۵ -ساعت در اولین خواندن کوتاه می‌شود. +و `fullRefillAt`. **با صفر قلب، بک‌اند جواب را رد می‌کند** (۴۰۳ با `code: "no_hearts"`، `nextRefillAt` و -`fullRefillAt`). `HEARTS_ENFORCED` از ۲۰۲۶-۱۰-۰۲ پیش‌فرض روشن است و فرانت برای این پاسخ صفحه‌ی -«قلب‌هات تموم شد» را با شمارشِ معکوس دارد (`src/components/NoHeartsScreen.tsx`). از همان روز: +`fullRefillAt`). `HEARTS_ENFORCED` پیش‌فرض روشن است و فرانت برای این پاسخ صفحه‌ی +«قلب‌هات تموم شد» را با شمارشِ معکوس دارد (`src/components/NoHeartsScreen.tsx`). سه جزئیات: - چکِ قلب داخلِ همان تراکنشی است که قلب را کم می‌کند. پیش‌تر بیرونش بود و دو جوابِ غلطِ هم‌زمان با یک قلبِ باقی‌مانده هر دو پذیرفته می‌شدند (با دو thread بازتولید شد). - پایانِ درس یا جزیره بی‌قلب و بی‌جواب دادنِ سوال‌هایش ۴۰۳ می‌گیرد؛ پیش‌تر `quiz/complete` اجازه @@ -283,16 +308,16 @@ temperature و با `OPENAI_CHAT_REASONING_EFFORT` صدا زده می‌شوند ## دیتابیس -هنوز SQLite است (`OPTIONS.timeout=20` تا قفل‌های هم‌زمانیِ نوشتن را تحمل کند). -مهاجرت به Postgres انجام نشده. +روی سرور **PostgreSQL 14** است (cluster جدای `zareian`، پورت ۵۴۴۰، فقط localhost) و بی `DB_NAME` در `.env` برنامه +SQLite را برمی‌دارد (توسعه‌ی محلی و تست‌ها). کلِ تست‌ها روی هر دو سبزند. قفل‌های ردیفی (`select_for_update`) در +پرداخت، سقفِ کدِ تخفیف، قلب و سهمیه‌ی دستیار روی Postgres قفلِ واقعی‌اند؛ روی SQLite همان تراکنش‌ها با +`transaction_mode: IMMEDIATE` سریالی می‌شوند، چون SQLite `FOR UPDATE` را بی‌صدا نادیده می‌گیرد. -**`transaction_mode: IMMEDIATE`** (۲۰۲۶-۰۹-۲۵): هر `atomic()` قفلِ نوشتن را از -اول می‌گیرد. SQLite از `SELECT … FOR UPDATE` پشتیبانی نمی‌کند و جنگو -`select_for_update` را رویش بی‌صدا نادیده می‌گیرد؛ یعنی تا آن روز همه‌ی -«قفلِ ردیف»ها در پرداخت، سقفِ کدِ تخفیف، قلب و سهمیه‌ی دستیار عملاً قفل نبودند. -حالا کلِ تراکنش‌ها سریالی‌اند (درشت ولی درست). فراخوانی‌های شبکه‌ای (AI، -زرین‌پال) بیرون از `atomic()` هستند و باید بمانند. بعد از Postgres این گزینه -برداشته می‌شود. +**اتصال‌ها از poolِ هر worker می‌آیند** (`OPTIONS.pool`، `DB_POOL_MAX_SIZE`): Postgresِ سرور `max_connections=60` +دارد و الگوی «یک اتصال برای هر نخ» (۴ worker × ۳۲ نخ) آن را پر می‌کرد. poolِ پر ۵۰۳ با `Retry-After` می‌دهد، نه ۵۰۰ +(`config/db.py`)، و `/api/health` → `dbPool` بودجه‌ی `workers × pool + 6 ≤ max_connections` را می‌سنجد. فراخوانی‌های +شبکه‌ای (AI، زرین‌پال، پیامک) بیرون از `atomic()` هستند و دستیار و پیامک پیش از انتظارِ شبکه اتصال را پس می‌دهند +(`release_db_connection`). تست‌ها روی فایلِ `.test-db.sqlite3` اجرا می‌شوند، نه دیتابیسِ حافظه‌ای: آن یکی shared-cache است و قفلش جدول‌به‌جدول و بی‌انتظار است، رفتاری که روی فایلِ واقعی @@ -302,14 +327,12 @@ shared-cache است و قفلش جدول‌به‌جدول و بی‌انتظا `Grade.number` هم هست. پایه‌ی تازه id را از شماره‌اش می‌گیرد، نه از شمارنده‌ی دیتابیس؛ شماره اگر خالی بماند از عنوان («پایه چهارم») خوانده می‌شود. پایه‌ی بی‌شماره ([DEMO]/[SEED]) idِ بالای ۱۰۰ می‌گیرد. همه‌جا — API، ادمین، فیلترِ -سوال‌ها — پایه‌ها به‌ترتیبِ شماره‌اند. تا migration‌ِ `content.0008` id ترتیبِ -واردشدنِ کتاب‌ها بود (نهم=۱، هشتم=۵). +سوال‌ها — پایه‌ها به‌ترتیبِ شماره‌اند. ⚠️ **`.iterator()` را دورِ حلقه‌ای که می‌نویسد یا I/O بیرونی می‌زند نگذارید.** -کرسرِ خواندن در تمامِ طولِ حلقه باز می‌ماند و روی SQLite جدول را برای هر -نویسنده‌ی هم‌زمان قفل می‌کند — قفلی که تا پایانِ حلقه رها نمی‌شود و هیچ -retryای نجاتش نمی‌دهد. این یک بارْ ماه‌ها به‌شکلِ یک تستِ «flaky» ظاهر شد؛ -باگِ واقعی بود، نه تصادفِ زمان‌بندی. به‌جایش نتیجه را با `list(...)` مادی کنید. +کرسرِ خواندن در تمامِ طولِ حلقه باز می‌ماند و قفل و اتصال را تا پایانِ حلقه نگه می‌دارد؛ روی SQLite هیچ retryای +نجاتش نمی‌دهد. این یک بارْ ماه‌ها به‌شکلِ یک تستِ «flaky» ظاهر شد؛ باگِ واقعی بود، نه تصادفِ زمان‌بندی. به‌جایش +نتیجه را با `list(...)` مادی کنید. ## لاگِ ساختاریافته diff --git a/docs/content-authoring.md b/docs/content-authoring.md index 158ac86..5116fb4 100644 --- a/docs/content-authoring.md +++ b/docs/content-authoring.md @@ -10,13 +10,17 @@ | فایل | محتوا | |---|---| -| `curriculum.json` | ۸ پایه، ۵۸ فصل، ۲۶۳ نود، ۲٬۶۱۷ درس | -| `questions.json` | ۲٬۵۴۵ سوالِ چندگزینه‌ای | +| `curriculum.json` | ۱۰ پایه (سوم تا دوازدهم)، ۵ کتاب، ۸۹ فصل، ۱٬۱۴۵ جزیره، ۵٬۴۰۶ درس؛ با `subject`، متنِ درس، مثال، قدم‌ها و شکلِ درس (`figures`) | +| `questions.json` | ۱۸٬۵۲۲ سوالِ چهارگزینه‌ای، بعضی با شکل (`figure`) | + +هر فصل باید `subject` داشته باشد (ریاضی، حسابان، هندسه، آمار و احتمال، گسسته)؛ فایلِ بی‌آن رد می‌شود. شکل‌ها +(`Lesson.figures`، `Question.figure`) با `rag.services.diagram.validate` در ورود اعتبارسنجی می‌شوند و شکلِ نامعتبر کلِ +فایل را رد می‌کند. هیچ LaTeX یا markdownی وارد نمی‌شود: `content/plain_math.py` متن را در ورود ساده می‌کند. `deploy.sh` در هر انتشار `seed_content` را اجرا می‌کند، پس یک سرورِ تازه خودش را با یک فرمان پر می‌کند — **بدونِ Qdrant، بدونِ اعتبارِ API، بدونِ نشستِ SSH.** -> **کد و محتوا دو مسیرِ جدا دارند.** `db.sqlite3` در gitignore است. سوالی که +> **کد و محتوا دو مسیرِ جدا دارند.** دیتابیس در گیت نیست. سوالی که > فقط در دیتابیسِ محلیِ شماست به سرور نمی‌رسد مگر از راهِ > `seed_content --export --only questions` و کامیتِ `content/seed/questions.json`. > پوش کردنِ کد به‌تنهایی محتوا را نمی‌برد. @@ -38,6 +42,10 @@ دیتابیسِ بی‌محتوا (سرورِ تازه) دفتر را نادیده می‌گیرد و همه‌چیز را از فایل می‌سازد. نتیجه‌اش این است که برای برگرداندنِ چیزی که در ادمین پاک شده، همان‌جا دوباره بسازیدش (یا ردیفِ `SeedRecord`ش را پاک کنید)؛ دیپلوی دیگر آن را برنمی‌گرداند. +- **مثال، قدم‌ها و شکلِ درس فقط در خانه‌ی خالی نشانده می‌شوند.** برای عوض کردنِ شکلِ درسی که روی سرور هست، `figures` + آن درس را در ادمین خالی کنید و دوباره دیپلو کنید. شناسه‌ی درس روی سرور با لپ‌تاپ فرق دارد. +- **جزیره‌های تمرین («تمرین: …») استثنا هستند:** گزینه‌های سوالِ موجودشان در جا (با همان شناسه) با فایل یکی می‌شود و + سوالی که از فایل حذف شده و هنوز جوابی ندارد پاک می‌شود (`seed_sync._refresh_practice`). سوالِ دستیِ ادمین هرگز. `seed_content --export` فایل‌ها را از دیتابیسِ محلی بازنویسی می‌کند. اگر دیتابیس سوالی نداشته باشد از بازنویسیِ یک `questions.json` غیرخالی خودداری @@ -114,6 +122,12 @@ python manage.py generate_questions_from_chunks --subject ریاضی --grade ن `solution_verdict='agrees'` تضمینِ کامل نیست، فقط فیلترِ اول: چند بار خطای حسابِ خودِ کتاب در همین مرحله گرفته شده است. +## بازخوردِ دانش‌آموز + +زیرِ هر سوالِ آزمون و هر درس سه دکمه است: «خوب بود»، «خوب نبود» و «گزارشِ ایراد». در ادمین، فهرستِ سوال‌ها و درس‌ها +ستونِ 👍/👎 دارد و «گزارش‌های سوال» و «گزارش‌های درس» صفِ بررسی‌اند. این‌ها نخستین جایی‌اند که سوالِ غلط یا مبهم پیدا +می‌شود. + ## داده‌ی ساختگی برای توسعه `seed_test_data` یک دیتاستِ کوچک و idempotent می‌سازد که همه‌ی اپ‌ها را پوشش diff --git a/docs/frontend/README.md b/docs/frontend/README.md deleted file mode 100644 index 92aec90..0000000 --- a/docs/frontend/README.md +++ /dev/null @@ -1,146 +0,0 @@ -# راهنمای اتصالِ فرانت‌اند به این بک‌اند - -این پوشه برای تیمِ فرانت (`MathApp_frontend`) است. `api.ts` یک کلاینتِ -تایپ‌دار است که از روی OpenAPI واقعیِ همین سرور نوشته شده — نامِ فیلدها حدس -نیست. - -```bash -cp docs/frontend/api.ts <پروژه‌ی-فرانت>/src/lib/api.ts -``` - -```ts -import { createClient } from './lib/api'; -export const api = createClient({ baseUrl: import.meta.env.VITE_API_URL ?? '/api' }); -``` - -## نگاشتِ صفحاتِ فعلیِ فرانت به API - -فرانت الان (`src/app/App.tsx`) خروجیِ Figma Make است و هیچ فراخوانیِ APIای -ندارد؛ همه‌ی داده‌ها هاردکد است. این جدول می‌گوید هر صفحه به چه چیزی وصل شود. - -| صفحه‌ی فرانت | فراخوانی | نکته | -|---|---|---| -| `SplashScreen` | `api.auth.isSignedIn()` | «قبلاً حساب دارم» → صفحه‌ی ورود | -| — (**وجود ندارد، باید ساخته شود**) | `auth.requestOtp` → `auth.login` | پایین ببینید | -| `OnboardingScreen` | — | ایستا | -| `ProfileSetupScreen` | `profile.update({name})` + `profile.setGrade(id)` | `GRADES` هاردکد را با `api.grades()` عوض کنید | -| `HomeScreen` | `api.home()` + `api.summary()` | بدونِ آرگومان، پایه‌ی خودِ کاربر ملاک است | -| `SkillMapScreen` | `api.home()` → `units[].nodes[]` | `status` = `locked / unlocked / in_progress / completed` | -| `LessonScreen` | `lesson.intro(id)` + `lesson.questions(id)` | پایین ببینید | -| `QuestionScreen` | `lesson.answer(qid, optId)` → `lesson.complete(id)` | `mascotMood` حالتِ رُبو را می‌دهد | -| `AskAIScreen` | `ai.askStream({userQuestion, lessonId}, onDelta)` | فقط `lessonId` کافی‌ست | -| `SubscriptionScreen` | `subscription.plans()` + `subscription.purchase(planId)` | قیمت‌های هاردکد را بردارید | - -## چهار چیزی که الان با فرانت جور نیست - -**۱. صفحه‌ی ورود وجود ندارد.** فرانت فقط اسم و پایه می‌پرسد، ولی احرازِ هویتِ -ما OTP با شماره‌ی موبایل است. دو راه: - -- **مهمان** (سریع‌ترین): `api.auth.guest(deviceId)` — بدونِ شماره، کاربر - بلافاصله وارد می‌شود و بعداً می‌تواند شماره‌اش را وصل کند. -- **کامل**: یک صفحه‌ی «شماره + کد» بین Splash و ProfileSetup. - -در حالتِ توسعه (`DJANGO_DEBUG=1`) پاسخِ `requestOtp` فیلدِ `devOtp` دارد، پس -برای تست لازم نیست پیامک واقعی برسد. - -**برای تستِ مسیرِ کامل روی سرورِ واقعی، شماره‌ی تست بگیرید.** روی -`mathapi.tadrisino2.ir` سرویسِ پیامک هنوز تنظیم نشده، پس `requestOtp` برای یک -شماره‌ی معمولی خطای ۵۰۳ می‌دهد. بک‌اند چند شماره‌ی تست با کدِ ثابت دارد -(`TEST_LOGIN_PHONES` در `.env` سرور): برای آن شماره‌ها پیامکی ارسال نمی‌شود و -همان کد همیشه جواب می‌دهد، دقیقاً مثل مسیرِ واقعی. یک جفت «شماره + کد» برای -خودتان بخواهید — ساختنش کارِ یک خط ویرایشِ `.env` و یک ری‌استارت است. - -> کدِ شماره‌ی تست در پاسخِ API برنمی‌گردد. اگر `devOtp` خالی آمد، یعنی همین -> مسیر فعال است، نه اینکه چیزی خراب باشد. - -**یک حسابِ تستِ آماده هم روی پروداکشن هست** که از قبل داده دارد، تا برای دیدنِ -صفحه‌های اعلان و لیدربرد مجبور نباشید منتظرِ تولیدِ داده‌ی واقعی بمانید: - -```bash -curl -X POST https://mathapi.tadrisino2.ir/api/auth/session \ - -H 'Content-Type: application/json' \ - -d '{"type":"guest","deviceId":"frontend-test-device"}' -``` - -- کاربرِ شماره‌ی ۶، با نامِ «حساب تست فرانت». هیچ پیامکی و هیچ توکنی که دست - به دست شود در کار نیست — همان `deviceId` کافی‌ست. -- `GET /api/notifications` روی این حساب چند اعلانِ واقعی دارد. **پاسخ در کلیدِ - `items` است**، نه `results`. -- `GET /api/leaderboard?period=weekly` و `?period=all_time` هر کدام ۶ ردیف - می‌دهند، با **ترتیبِ عمداً متفاوت** بینِ دو بازه، تا بشود دید پارامترِ - `period` واقعاً اثر می‌کند. -- این داده دستی روی دیتابیسِ پروداکشن ساخته شده و در گیت نیست؛ پیش از باز - شدنِ سرویس روی کاربرانِ واقعی باید پاک شود (`docs/operations.md`). - -⚠️ این حساب **مشترک** است. هر کسی که همین `deviceId` را بفرستد وارد همان حساب -می‌شود، پس رویش چیزی که مالِ خودتان باشد نگذارید. برای کارِ روزمره یک -`deviceId` مخصوصِ خودتان بسازید — هر رشته‌ای قبول است. - -**۲. فهرستِ پایه‌ها از `api.grades()` می‌آید** (انجام شده). ترتیب همان ترتیبِ -کلاس‌هاست و `id` هر پایه برابرِ شماره‌اش است: `gradeId: 8` یعنی پایه‌ی هشتم. -فیلدِ `number` هم همین عدد را صریح می‌دهد. پیش از ۲۰۲۶-۰۹-۲۳ idها ترتیبِ -واردشدنِ کتاب‌ها بودند (هشتم=۵)؛ اپ چون همیشه `gradeId` را از سرور می‌گیرد، -تغییری لازم نداشت. - -**۳. صفحه‌ی درس، ویدیو دارد.** `LessonScreen` فهرستی از -`{type: "video" | "quiz"}` نشان می‌دهد. بک‌اند هنوز مفهومِ ویدیو ندارد — -`lessons/{id}/intro` فقط متن و حالتِ ماسکوت می‌دهد. فعلاً فقط `quiz` را نشان -دهید؛ اگر ویدیو واقعاً لازم است بگویید تا در مدل اضافه کنیم. - -**۴. «سطح» در آمار.** `HomeScreen` سه عدد نشان می‌دهد: روز پیاپی، امتیاز، سطح. -دوتای اول را داریم (`streak.current`، `xp`)؛ «سطح» را نداریم. یا از روی `xp` -سمتِ فرانت حساب کنید، یا بگویید تا در `/api/user/summary` اضافه کنیم. - -## چیزهایی که سمتِ بک‌اند برایتان درست شد - -- **CORS** اضافه شد. origin خود را در `CORS_ALLOWED_ORIGINS` بدهید (در حالتِ - توسعه پورت‌های ۳۰۰۰ و ۵۱۷۳ پیش‌فرض بازند). -- **`PATCH /api/user/profile`** اضافه شد — قبلاً پروفایل فقط خواندنی بود و - هیچ راهی برای ثبتِ اسم نبود. -- **`GET /api/home` بدونِ پارامتر** حالا پایه‌ی ذخیره‌شده‌ی کاربر را می‌خواند. - قبلاً اگر `?grade=` نمی‌فرستادید نقشه خالی برمی‌گشت. -- **`POST /api/ai/ask` با فقط `lessonId`** حالا خودش می‌فهمد آن درس مالِ کدام - پایه و فصلِ کتاب است. قبلاً باید `subject` + `grade` + `lessonNumber` را - خودتان می‌فرستادید وگرنه پاسخ بدونِ استناد به کتاب تولید می‌شد. - -## چند قاعده که وقت‌تان را می‌خرد - -**`Idempotency-Key` بفرستید** روی هر چیزی که پول/امتیاز/قلب جابه‌جا می‌کند -(`answer`, `complete`, `purchase`). یک UUID که تا موفق شدنِ درخواست ثابت -بماند. بدونِ آن، قطعیِ شبکه + دکمه‌ی دوباره = دوبار کم شدنِ قلب. - -**۴۰۱ یعنی توکن منقضی شده، نه اینکه کاربر بیرون است.** کلاینت خودش یک‌بار -`refresh` می‌زند و درخواست را تکرار می‌کند؛ فقط اگر آن هم شکست خورد کاربر را -به صفحه‌ی ورود ببرید. - -**۴۲۹ و ۵۰۳ را جدا نشان دهید.** ۴۲۹ یعنی سقفِ روزانه‌ی سوال از دستیار پر شده -(الان ۱۵ تا) یا throttle؛ پیامش «فردا برگرد» است. ۵۰۳ یعنی سرویسِ AI موقتاً -قطع است؛ پیامش «چند لحظه دیگر دوباره». `ApiError.isRateLimited` و -`.isProviderDown` همین را می‌دهند. - -**پرداخت را در مرورگر باز کنید.** `purchase` یک `paymentUrl` می‌دهد که به -سرورِ خودمان اشاره می‌کند، نه به زرین‌پال. اپ نباید مستقیم با درگاه حرف بزند. - -**امتیاز موقعِ اتمامِ درس در مجموع می‌نشیند، نه سرِ هر سوال.** پاسخِ -`answer` فیلدِ `xpEarned` دارد ولی آن ارزشِ همان سوال است؛ `user/summary` -تا وقتی `complete` صدا نخورد تکان نمی‌خورد. در UI می‌توانید خوش‌بینانه -جمع بزنید، ولی عددِ معتبر بعد از `complete` می‌آید. - -**`complete` دو فیلدِ اجباری دارد:** `correctCount` و `totalQuestions`. -سرور `correctCount` را با تعدادِ واقعیِ سوال‌های آن درس سقف می‌زند، پس -عددِ متورم امتیاز نمی‌سازد. - -**`usedChunks` خالی یعنی پاسخ به کتاب مستند نیست.** اگر خواستید، در UI -تفکیکش کنید («بر اساسِ کتابِ درسی» در برابر «پاسخِ عمومی»). - -## اجرای محلی - -```bash -# بک‌اند -DJANGO_DEBUG=1 python manage.py runserver # 127.0.0.1:8000 - -# فرانت -VITE_API_URL=http://127.0.0.1:8000/api npm run dev -``` - -مستنداتِ زنده‌ی همه‌ی endpointها: `http://127.0.0.1:8000/api/docs/` diff --git a/docs/frontend/api.ts b/docs/frontend/api.ts deleted file mode 100644 index 9dea420..0000000 --- a/docs/frontend/api.ts +++ /dev/null @@ -1,446 +0,0 @@ -/** - * کلاینتِ API تدریسینو — برای فرانتِ React/TypeScript (MathApp_frontend). - * - * از روی OpenAPI واقعیِ همین بک‌اند نوشته شده، نه از روی حدس. نامِ فیلدها - * دقیقاً همان چیزی‌ست که سرور برمی‌گرداند (camelCase). - * - * نکته‌های مهمِ اتصال: - * - احرازِ هویت توکنِ Bearer است، نه کوکی. پس `credentials: 'include'` لازم - * نیست و CORS سمتِ سرور هم عمداً بدونِ credentials تنظیم شده. - * - توکنِ دسترسی امضاشده و کوتاه‌عمر است؛ با ۴۰۱ باید `refresh` صدا زد. - * این کلاینت خودش یک‌بار refresh می‌کند و درخواست را تکرار. - * - برای پرسش از دستیار کافی‌ست `lessonId` بدهید؛ سرور خودش می‌فهمد آن درس - * مالِ کدام پایه و فصل است و پاسخ را به کتاب مستند می‌کند. - */ - -export type Json = Record; - -export interface Tokens { - token: string; - refreshToken: string; -} - -/* ─── انواعِ پاسخ ───────────────────────────────────────────────────────── */ - -export type NodeStatus = 'locked' | 'unlocked' | 'in_progress' | 'completed'; - -export interface HomeNode { - id: string; - title: string; - status: NodeStatus; - lessonIds: number[]; -} -export interface HomeUnit { - id: string; - title: string; - nodes: HomeNode[]; -} -export interface HomeResponse { - units: HomeUnit[]; - stats: { progressPercent: number; xp: number; coins: number }; -} - -export interface Grade { - /** برابرِ شماره‌ی پایه: ۸ یعنی هشتم. */ - id: number; - /** شماره‌ی پایه (۱ تا ۱۲)؛ برای پایه‌های نمونه null. */ - number: number | null; - title: string; - icon: string; - color: string; - lessonCount: number; -} - -export interface UserSummary { - userId: string; - name: string; - avatar: string; - gradeId: number | null; - xp: number; - coins: number; - streak: { current: number; longest: number }; - subscription: string; -} - -export interface UserProfile { - userId: string; - phone: string | null; - name: string; - avatar: string; - isGuest: boolean; - gradeId: number | null; - birthYear: number | null; - parentPhone: string; - bio: string; -} - -export interface LessonIntro { - lessonId: string; - title: string; - introText: string; - /** برای انتخابِ حالتِ ماسکوت در صفحه‌ی درس */ - mascotAnimation: string; - /** - * صفر یعنی متنِ درس هست ولی آزمونش هنوز ساخته نشده — «به‌زودی» نشان دهید، - * نه کویزِ خالی. `complete` هم `totalQuestions: 0` را نمی‌پذیرد. - */ - questionCount: number; -} - -export interface QuestionOption { - id: string; - value: string; -} -export interface Question { - id: string; - type: 'multiple_choice' | 'count_objects'; - prompt: string; - options: QuestionOption[]; -} - -export interface AnswerResult { - correct: boolean; - correctOptionId: string; - xpEarned: number; - heartsRemaining: number; - /** حالتِ ماسکوت: درست → happy، غلط → encourage */ - mascotMood: string; -} - -export interface LessonComplete { - stars: number; - xpEarned: number; - coinsEarned: number; - stats: { progressPercent: number; xp: number; coins: number }; -} - -export interface Hearts { - currentHearts: number; - maxHearts: number; - nextRefillAt: string | null; - isUnlimited: boolean; -} - -export interface AskResponse { - conversationId: string; - messageId: string; - answer: string; - suggestedFollowUps: string[]; - remainingQuota: number; - /** تکه‌های کتاب که پاسخ رویشان بنا شده؛ خالی یعنی پاسخِ عمومی */ - usedChunks: { title: string; summary: string }[]; -} - -export interface Plan { - id: string; - title: string; - price: number; - durationDays: number; - features: string[]; -} - -/* ─── خطا ───────────────────────────────────────────────────────────────── */ - -export class ApiError extends Error { - constructor( - readonly status: number, - readonly detail: string, - readonly body: Json, - ) { - super(detail); - this.name = 'ApiError'; - } - - /** ۴۲۹: throttle یا اتمامِ سهمیه‌ی روزانه‌ی دستیار */ - get isRateLimited() { - return this.status === 429; - } - /** ۵۰۳: سرویسِ هوش مصنوعی/پیامک موقتاً در دسترس نیست */ - get isProviderDown() { - return this.status === 503; - } -} - -/* ─── کلاینت ────────────────────────────────────────────────────────────── */ - -export interface ClientOptions { - baseUrl?: string; - /** خواندن/نوشتنِ توکن؛ پیش‌فرض localStorage */ - storage?: { - get(): Tokens | null; - set(t: Tokens | null): void; - }; -} - -const STORAGE_KEY = 'tadrisino.tokens'; - -function defaultStorage() { - return { - get(): Tokens | null { - try { - const raw = localStorage.getItem(STORAGE_KEY); - return raw ? (JSON.parse(raw) as Tokens) : null; - } catch { - return null; - } - }, - set(t: Tokens | null) { - try { - if (t) localStorage.setItem(STORAGE_KEY, JSON.stringify(t)); - else localStorage.removeItem(STORAGE_KEY); - } catch { - /* حالتِ خصوصیِ مرورگر — نادیده */ - } - }, - }; -} - -export function createClient(options: ClientOptions = {}) { - const baseUrl = (options.baseUrl ?? '/api').replace(/\/$/, ''); - const storage = options.storage ?? defaultStorage(); - - async function raw( - method: string, - path: string, - body?: unknown, - extraHeaders: Record = {}, - retryOn401 = true, - ): Promise { - const tokens = storage.get(); - const headers: Record = { ...extraHeaders }; - if (body !== undefined) headers['Content-Type'] = 'application/json'; - if (tokens?.token) headers['Authorization'] = `Bearer ${tokens.token}`; - - const response = await fetch(`${baseUrl}${path}`, { - method, - headers, - body: body === undefined ? undefined : JSON.stringify(body), - }); - - if (response.status === 401 && retryOn401 && tokens?.refreshToken) { - const refreshed = await refresh(tokens.refreshToken).catch(() => null); - if (refreshed) return raw(method, path, body, extraHeaders, false); - storage.set(null); - } - - const text = await response.text(); - const parsed: Json = text ? (JSON.parse(text) as Json) : {}; - if (!response.ok) { - const detail = - (parsed.detail as string) ?? - (parsed.error as string) ?? - `خطای ${response.status}`; - throw new ApiError(response.status, detail, parsed); - } - return parsed as T; - } - - async function refresh(refreshToken: string) { - const response = await fetch(`${baseUrl}/auth/refresh`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ refreshToken }), - }); - if (!response.ok) return null; - const data = (await response.json()) as Tokens; - storage.set({ token: data.token, refreshToken: data.refreshToken }); - return data; - } - - return { - /* ── ورود (صفحه‌ای که فعلاً در فرانت نیست و باید ساخته شود) ────────── */ - auth: { - /** مهمان: بدونِ شماره، فقط با شناسه‌ی دستگاه. برای «شروع سریع». */ - async guest(deviceId: string) { - const data = await raw<{ userId: string; token: string; isNewUser: boolean; isGuest: boolean }>( - 'POST', '/auth/session', { type: 'guest', deviceId }, - ); - storage.set({ token: data.token, refreshToken: '' }); - return data; - }, - /** گامِ ۱: ارسالِ پیامکِ کد. در حالتِ توسعه `devOtp` هم برمی‌گردد. */ - requestOtp(phone: string) { - return raw<{ success: boolean; devOtp?: string; note?: string }>( - 'POST', '/auth/otp/request', { phone }, - ); - }, - /** گامِ ۲: تأییدِ کد و گرفتنِ توکن. نامِ فیلد در سرور `otp` است. */ - async login(phone: string, otp: string) { - const data = await raw<{ userId: string } & Tokens>( - 'POST', '/auth/login', { phone, otp }, - ); - storage.set({ token: data.token, refreshToken: data.refreshToken }); - return data; - }, - logout() { - storage.set(null); - }, - isSignedIn() { - return Boolean(storage.get()?.token); - }, - }, - - /* ── صفحه‌ی «بیا باهم آشنا بشیم» ──────────────────────────────────── */ - profile: { - get: () => raw('GET', '/user/profile'), - /** اسم/آواتار/… . فقط فیلدهایی که می‌فرستید عوض می‌شوند. */ - update: (patch: Partial>) => - raw('PATCH', '/user/profile', patch), - setGrade: (gradeId: number) => - raw<{ success: boolean; gradeId: number }>('POST', '/user/grade', { gradeId }), - }, - - /** فهرستِ پایه‌ها را از سرور بگیرید، هاردکد نکنید — پایه‌ها اضافه می‌شوند. */ - grades: () => raw<{ grades: Grade[] }>('GET', '/grades').then((r) => r.grades), - - /* ── صفحه‌ی خانه و نقشه‌ی مهارت ────────────────────────────────────── */ - /** بدونِ gradeId، پایه‌ی ذخیره‌شده‌ی خودِ کاربر ملاک است. */ - home: (gradeId?: number) => - raw('GET', gradeId ? `/home?gradeId=${gradeId}` : '/home'), - summary: () => raw('GET', '/user/summary'), - hearts: () => raw('GET', '/user/hearts'), - streakCalendar: () => - raw<{ currentStreak: number; longestStreak: number; activeDays: string[] }>( - 'GET', '/user/streak/calendar', - ), - leaderboard: (period: 'weekly' | 'monthly' = 'weekly') => - raw<{ period: string; entries: unknown[] }>('GET', `/leaderboard?period=${period}`), - - /* ── صفحه‌ی درس و سوال ─────────────────────────────────────────────── */ - lesson: { - intro: (lessonId: number) => raw('GET', `/lessons/${lessonId}/intro`), - questions: (lessonId: number) => - raw<{ questions: Question[] }>('GET', `/lessons/${lessonId}/questions`).then((r) => r.questions), - /** - * `idempotencyKey` را بدهید تا اگر شبکه قطع شد و کاربر دوباره زد، - * امتیاز/قلب دوبار حساب نشود. - */ - answer: (questionId: number, selectedOptionId: number, idempotencyKey?: string) => - raw( - 'POST', `/questions/${questionId}/answer`, { selectedOptionId }, - idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}, - ), - /** - * پایانِ درس. `correctCount` را سرور با تعدادِ واقعیِ سوال‌ها سقف می‌زند. - * مجموعِ XPِ کاربر **اینجا** به‌روز می‌شود، نه سرِ هر پاسخ. - */ - complete: ( - lessonId: number, - result: { correctCount: number; totalQuestions: number }, - idempotencyKey?: string, - ) => - raw( - 'POST', `/lessons/${lessonId}/complete`, result, - idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}, - ), - }, - - /* ── صفحه‌ی «از رُبو بپرس» ─────────────────────────────────────────── */ - ai: { - /** - * `lessonId` را بدهید تا پاسخ به کتابِ همان درس مستند شود؛ لازم نیست - * subject/grade/chapter را خودتان حساب کنید. - * `conversationId` را از پاسخِ قبلی پاس بدهید تا گفتگو ادامه پیدا کند. - */ - ask: (input: { - userQuestion: string; - lessonId?: number | string; - conversationId?: string; - questionId?: number | string; - idempotencyKey?: string; - }) => { - const { idempotencyKey, ...body } = input; - return raw( - 'POST', '/ai/ask', body, - idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}, - ); - }, - - /** - * همان ask ولی توکن‌به‌توکن (SSE). اولین کلمه ~۳.۵ ثانیه به‌جای ~۱۰. - * برای حبابِ چتِ «در حالِ تایپ» همین را استفاده کنید. - */ - async askStream( - input: { userQuestion: string; lessonId?: number | string; conversationId?: string }, - onDelta: (text: string) => void, - ): Promise<{ conversationId?: string; messageId?: string }> { - const tokens = storage.get(); - const response = await fetch(`${baseUrl}/ai/ask`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(tokens?.token ? { Authorization: `Bearer ${tokens.token}` } : {}), - }, - body: JSON.stringify({ ...input, stream: true }), - }); - if (!response.ok || !response.body) { - throw new ApiError(response.status, 'استریم شروع نشد', {}); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - let done: { conversationId?: string; messageId?: string } = {}; - - for (;;) { - const { value, done: finished } = await reader.read(); - if (finished) break; - buffer += decoder.decode(value, { stream: true }); - - // رویدادها با یک خطِ خالی از هم جدا می‌شوند. - const parts = buffer.split('\n\n'); - buffer = parts.pop() ?? ''; - for (const part of parts) { - const event = /^event: (.+)$/m.exec(part)?.[1]; - const dataLine = /^data: (.+)$/m.exec(part)?.[1]; - if (!event || !dataLine) continue; - const payload = JSON.parse(dataLine) as Json; - if (event === 'chunk') onDelta(String(payload.delta ?? '')); - else if (event === 'done') done = payload as typeof done; - else if (event === 'error') { - throw new ApiError(503, String(payload.detail ?? 'خطای دستیار'), payload); - } - } - } - return done; - }, - - /** 👍/👎 روی یک پاسخ. `reason` اختیاری ولی خیلی مفید است. */ - feedback: (messageId: string | number, isHelpful: boolean, reason?: string) => - raw<{ success: boolean }>('POST', `/ai/messages/${messageId}/feedback`, { - isHelpful, - ...(reason ? { reason } : {}), - }), - - history: (conversationId: string | number) => - raw<{ conversationId: string; lessonId: string | null; messages: unknown[] }>( - 'GET', `/ai/conversations/${conversationId}/messages`, - ), - }, - - /* ── صفحه‌ی اشتراک ─────────────────────────────────────────────────── */ - subscription: { - plans: () => raw<{ plans: Plan[] }>('GET', '/subscription/plans').then((r) => r.plans), - status: () => - raw<{ active: boolean; planId: string | null; expiresAt: string | null }>( - 'GET', '/subscription/status', - ), - /** - * `paymentUrl` را در مرورگر باز کنید. آن آدرس خودش سراغِ زرین‌پال - * می‌رود؛ اپ نباید مستقیم با درگاه حرف بزند. - */ - purchase: (planId: string, idempotencyKey?: string) => - raw<{ orderId: string; paymentUrl: string }>( - 'POST', '/subscription/purchase', { planId }, - idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}, - ), - }, - - notifications: { - list: () => raw<{ items: unknown[]; unreadCount: number }>('GET', '/notifications'), - markRead: (id: number) => raw('POST', `/notifications/${id}/read`), - }, - }; -} - -export type TadrisinoClient = ReturnType; diff --git a/docs/images/technical-handbook/architecture.png b/docs/images/technical-handbook/architecture.png index 4f97f8d..65619d4 100644 Binary files a/docs/images/technical-handbook/architecture.png and b/docs/images/technical-handbook/architecture.png differ diff --git a/docs/images/technical-handbook/relational-model-er.png b/docs/images/technical-handbook/relational-model-er.png index 96af1a8..a670a85 100644 Binary files a/docs/images/technical-handbook/relational-model-er.png and b/docs/images/technical-handbook/relational-model-er.png differ diff --git a/docs/images/technical-handbook/scenario-ask-tutor.png b/docs/images/technical-handbook/scenario-ask-tutor.png index 727a08e..2fdee60 100644 Binary files a/docs/images/technical-handbook/scenario-ask-tutor.png and b/docs/images/technical-handbook/scenario-ask-tutor.png differ diff --git a/docs/images/technical-handbook/scenario-island-quiz.png b/docs/images/technical-handbook/scenario-island-quiz.png index 2d4e3a3..aa3be6d 100644 Binary files a/docs/images/technical-handbook/scenario-island-quiz.png and b/docs/images/technical-handbook/scenario-island-quiz.png differ diff --git a/docs/images/technical-handbook/scenario-login.png b/docs/images/technical-handbook/scenario-login.png index 3b4ba72..552267e 100644 Binary files a/docs/images/technical-handbook/scenario-login.png and b/docs/images/technical-handbook/scenario-login.png differ diff --git a/docs/images/technical-handbook/scenario-subscription-payment.png b/docs/images/technical-handbook/scenario-subscription-payment.png index 84be702..44fe732 100644 Binary files a/docs/images/technical-handbook/scenario-subscription-payment.png and b/docs/images/technical-handbook/scenario-subscription-payment.png differ diff --git a/docs/images/technical-handbook/use-case-model.png b/docs/images/technical-handbook/use-case-model.png index bb18f04..4202077 100644 Binary files a/docs/images/technical-handbook/use-case-model.png and b/docs/images/technical-handbook/use-case-model.png differ diff --git a/docs/operations.md b/docs/operations.md index f472921..33a33d2 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -169,35 +169,6 @@ Postgres 5440 (localhost) ◄── تونلِ SSH ─── zareian-pg-tunnel - **کلِ ۲۴۹ رفت:** همان بازیابی تا انتهای WAL روی ماشینِ تازه، و سفارش‌های چند ثانیه‌ی آخر را با پنلِ زرین‌پال/بازار تطبیق دهید. -بکاپِ دستیِ SQLite (توسعه‌ی محلی): - - -```bash -cd /home/administrator/mathapp-v2 -mkdir -p .deploy-backups -venv/bin/python - <<'PY' -import sqlite3, datetime -dst = ".deploy-backups/db-%s.sqlite3" % datetime.datetime.now().strftime("%Y%m%d-%H%M%S") -src, out = sqlite3.connect("db.sqlite3"), sqlite3.connect(dst) -with out: - src.backup(out) # نسخه‌ی سازگار حتی اگر هم‌زمان کسی بنویسد -out.close(); src.close() -print("backup:", dst, sqlite3.connect(dst).execute("PRAGMA integrity_check").fetchone()[0]) -PY -``` - -`sqlite3` به‌عنوانِ فرمانِ خط فرمان روی آن سرور نصب **نیست** — از پایتونِ خودِ -پروژه استفاده کنید، نه `cp`؛ کپیِ ساده وسطِ یک نوشتن، فایلِ نیمه‌کاره می‌دهد. - -بازیابی: سرویس را بخوابانید، فایل را برگردانید، دوباره بالا بیاورید. - -```bash -sudo systemctl stop zareian-mathapp -cp .deploy-backups/db-.sqlite3 db.sqlite3 -sudo systemctl start zareian-mathapp -curl https://mathapi.tadrisino2.ir/api/health -``` - ## متغیرهای محیطی `.env` خودکار خوانده می‌شود (`python-dotenv`) و مقادیرش **با پیش‌فرض‌های کد @@ -258,13 +229,12 @@ pendingِ همان کاربر را دوباره با زرین‌پال می‌س ## عوض کردنِ دامنه -آخرین جابه‌جایی: ۲۰۲۶-۰۹-۲۶، API از `api.math.tadrisino2.ir` به `mathapi.tadrisino2.ir` -(رکوردِ DNSِ آدرسِ قبلی حذف شده). فرانت همان `math.tadrisino2.ir` ماند. +دامنه‌های فعلی: API روی `mathapi.tadrisino2.ir` و فرانت روی `math.tadrisino2.ir`. دامنه در کد فقط یک‌جا آمده: پیش‌فرضِ `PUBLIC_BASE_URL` و `FRONTEND_URL` در `config/settings.py`. `ALLOWED_HOSTS`، `CSRF_TRUSTED_ORIGINS`، `CORS_ALLOWED_ORIGINS`، callbackِ زرین‌پال، برگشت از پرداخت، `BAZAAR_REDIRECT_URI` و آدرسِ سلامت در -`deploy.sh` همه از همین دو ساخته می‌شوند (۲۰۲۶-۰۹-۲۶). فرانت هم دامنه را در کد +`deploy.sh` همه از همین دو ساخته می‌شوند (`config/tests_domains.py`). فرانت هم دامنه را در کد ندارد (README همان ریپو). ترتیبِ کار، از بیرون به داخل: @@ -291,7 +261,7 @@ callbackِ زرین‌پال، برگشت از پرداخت، `BAZAAR_REDIRECT_U ## لغو و بازپرداختِ سفارش (از پنلِ ادمین) -از ۲۰۲۶-۱۰-۰۲. همه از `/admin/payment/order/` انجام می‌شود و SSH لازم نیست: +همه از `/admin/payment/order/` انجام می‌شود و SSH لازم نیست: 1. سفارش را با شماره‌اش یا شناسه‌ی کاربر (`user_id`) پیدا کنید. شماره‌ی فاکتورِ `TD-000123` یعنی سفارشِ ۱۲۳. 2. تیکش را بزنید و از فهرستِ «Action» یکی را انتخاب کنید: @@ -362,33 +332,32 @@ callbackِ زرین‌پال، برگشت از پرداخت، `BAZAAR_REDIRECT_U ## سرور -`mathapi.tadrisino2.ir` روی یک **ماشینِ مشترکِ شرکت** اجرا می‌شود -(`administrator@78.38.35.249`)، نه سروری که مالِ این پروژه باشد. روی همان ماشین -Gitea، سرویسِ ایمیل، DNS، چند دیتابیس و اپ‌های تیم‌های دیگر در حالِ کارند. +`mathapi.tadrisino2.ir` روی یک **ماشینِ مشترکِ شرکت** اجرا می‌شود (`administrator@78.38.35.249`)، نه سروری که مالِ این +پروژه باشد. روی همان ماشین Gitea، ایمیل، DNS، چند دیتابیس و اپ‌های تیم‌های دیگر در حالِ کارند، و حسابِ ما `sudo`ِ +کامل دارد؛ تنها مانع میانِ یک فرمانِ بی‌دقت و داده‌ی کلِ شرکت، خودِ اجراکننده است. سرورِ بکاپ (`78.38.35.247`) هم +مشترک است. -مالِ ما **دقیقاً یک دایرکتوری** است: `/home/administrator/mathapp-v2`، که با -`gunicorn-mathapp.service` سرو می‌شود. همان یونیت با نامِ `zareian-mathapp` هم -پاسخ می‌دهد و **همیشه همین نام را بنویسید**: +مالِ ما روی ۲۴۹: پوشه‌های `~/mathapp-v2` (بک‌اند) و `~/mathapp_frontend_new` (فرانت)، سرویسِ `gunicorn-mathapp` با +نامِ دومِ **`zareian-mathapp`** (همیشه همین نام را بنویسید)، `zareian-redis`، clusterِ Postgresِ `14/zareian`، تایمرِ +`zareian-applog-archive`، و دو سایت و دو snippetِ nginx (بخشِ بعد). روی ۲۴۷ فقط `~/zareian-pgbackup/` و یونیت‌های +`zareian-*`. ```bash sudo systemctl restart zareian-mathapp # مالِ ما sudo systemctl restart gunicorn # مالِ ما نیست — اپِ تیمِ دیگر ``` -قواعدی که قابلِ مذاکره نیستند: +قواعدی که قابلِ مذاکره نیستند (نسخه‌ی کامل، با فهرستِ آنچه مالِ ما نیست و رویه‌ی دیپلو، در `CLAUDE.md` «Working on +the servers»): -1. **هیچ فرمانِ مخربی بیرون از `~/mathapp-v2` اجرا نشود.** مسیر را کامل - بنویسید، به دایرکتوریِ جاری تکیه نکنید. -2. **`git clean` آنجا هرگز اجرا نشود.** `db.sqlite3`، `.env` و - `.django_secret_key` هیچ‌کدام در گیت نیستند و همان‌جا زندگی می‌کنند؛ - `reset --hard` کاری با آن‌ها ندارد ولی `clean -fd` دیتابیس، پیکربندی و - همه‌ی نشست‌های فعال را نابود می‌کند. -3. **پروژه هرگز پاک و «از نو clone» نشود** — به همان دلیل. برای برگرداندنِ - درخت به حالتِ سالم از `git reset --hard origin/main` استفاده کنید. -4. **`.env` فقط از راهِ Django خوانده شود.** `cat .env` نه، و مقادیرش در هیچ - چتی paste نشود. -5. **`df -h /` را قبل از هر کارِ پرنوشت چک کنید.** دیسک مدتی‌ست حدودِ ۹۴٪ پر - است؛ پر شدنش سرویسِ ایمیل و Gitea را هم با ما پایین می‌کشد. +1. **اول فقط نگاه کنید**، بعد تغییر بدهید؛ هیچ فرمانِ مخربی بیرون از پوشه‌های ما، و مسیرها را کامل بنویسید. +2. **`git clean` هرگز، و پروژه هرگز پاک و «از نو clone» نشود.** `.env`، `.django_secret_key`، لاگ‌ها و بکاپ‌ها در گیت + نیستند و همان‌جا زندگی می‌کنند؛ راهِ برگشت `git reset --hard origin/main` است. +3. **فقط `zareian-mathapp` ری‌استارت شود**؛ nginx مشترک است: `nginx -t` و بعد `reload`، هرگز `restart`. +4. **پیش از ویرایشِ هر چیز زیرِ `/etc` یا `.env` بکاپِ تاریخ‌دار** در `~/mathapp-v2/.deploy-backups/`. +5. **`.env` فقط از راهِ Django خوانده شود**؛ `cat .env` نه، و مقادیرش در هیچ چتی paste نشود. +6. **`df -h /` پیش از هر کارِ پرنوشت.** پر شدنِ دیسک ایمیل و Gitea را هم پایین می‌کشد. +7. **از منابعِ تیم‌های دیگر استفاده نشود** (Redis، Postgres، صف، سایتِ nginx)؛ نیاز به آن‌ها درخواست از ادمینِ سرور است. ## nginx (دو سایتِ ما) @@ -426,6 +395,5 @@ sudo systemctl restart gunicorn # مالِ ما نیست — اپِ --- > **پیش از آمدنِ کاربرِ واقعی** فهرستِ [`security-before-production.md`](security-before-production.md) -> را کامل ببندید: کلیدِ امضا، حساب‌های دمو، جزئیاتِ عمومیِ `/api/health`، مستنداتِ عمومیِ API، -> جدولِ رتبه‌بندیِ عمومی، پنلِ ادمین، پورتِ ۸۰ و دسترسیِ سرور. +> را کامل ببندید (پنلِ ادمینِ عمومی، کدِ ورودِ بی‌هش، سقفِ پیامک، دسترسیِ مشترکِ سرور، …). diff --git a/docs/security-before-production.md b/docs/security-before-production.md index 40fa162..2adb2f5 100644 --- a/docs/security-before-production.md +++ b/docs/security-before-production.md @@ -2,8 +2,7 @@ **آخرین بازبینی با کد و سرورِ زنده: ۲۰۲۶-۱۰-۱۳ (۲۱ مهر ۱۴۰۵): حساب‌های دمو، nginx (فقط آروان، CSP)، جعلِ IP، سقفِ پیامک.** -این پروژه تا این تاریخ در حالتِ **دمو و ارائه‌ی کارآموزی** است، نه محصولِ نهایی. چند چیز -عمداً باز گذاشته شده تا توسعه‌دهنده‌ها، فرانت‌کار و ارائه کار کنند. هر کدام برای یک سرورِ +سرویس زنده است ولی هنوز به روی کاربرِ عمومی باز نشده. چند چیز عمداً یا به‌خاطرِ تصمیمِ بازِ طراحی باز مانده است. هر کدام برای یک سرورِ عمومی با کاربرِ واقعی (کودک، پرداختِ واقعی) **خطر است** و باید پیش از آمدنِ اولین کاربرِ واقعی بسته شود. @@ -53,15 +52,13 @@ ## ۱۲. هزینه‌ی دستیارِ AI -- **چیست:** هر کاربر روزی ۲ سؤالِ رایگان دارد (`AI_DAILY_QUOTA_FREE`) - و بعد فقط با اعتبارِ خریده‌شده؛ ۲۰ در ساعت. هر شماره‌ی تازه ۲ سؤالِ رایگان می‌گیرد و ساختنِ شماره با سقفِ پیامک - (بند ۲۴) محدود است. سقفِ - روزانه‌ی کلِ سامانه (`AI_GLOBAL_DAILY_MAX`، ۳۰۰۰) خرجِ کل را محدود می‌کند. طولِ سؤال حداکثر ۲۰۰۰ نویسه است. -- **خطر:** با چند IP، اعتبارِ GapGPT در چند ساعت تمام می‌شود (اعتبار یک بار در ۲۰۲۶-۰۹-۲۵ - به صفر رسید، البته به دلیلِ دیگر). -- **رفع:** سقفِ سراسریِ روزانه برای کلِ اپ؛ دستیار فقط برای کاربرِ واردشده با شماره یا - با سقفِ بسیار کمتر برای مهمان؛ هشدار وقتی `aiProvider.state` در `/api/health` برابرِ - `quota_exhausted` شد. +- **چیست:** هر کاربرِ بی‌اشتراک روزی ۲ سؤالِ رایگان دارد (`AI_DAILY_QUOTA_FREE`) و مشترک ۴؛ ۲۰ در ساعت. هر شماره‌ی تازه ۲ + سؤالِ رایگان می‌گیرد و ساختنِ شماره با سقفِ پیامک (بند ۲۴) محدود است. سقفِ روزانه‌ی کلِ سامانه (`AI_GLOBAL_DAILY_MAX`، + ۳۰۰۰) بیشترین خرجِ روزانه را محدود می‌کند. طولِ سؤال حداکثر ۲۰۰۰ نویسه است. +- **خطر:** با چند IP و چند شماره، تا سقفِ سراسری خرج می‌شود (~۲ میلیون تومان در روز) و سقفِ سراسری برای کاربرانِ + واقعی هم پر می‌شود. +- **رفع:** هشدارِ فعال (نه فقط در `/api/health`) وقتی `aiProvider.state` برابرِ `quota_exhausted` شد یا سقفِ سراسری از + نیمه گذشت؛ سهمیه‌ی کمتر برای حسابِ تازه‌ساخته. ## ۱۵. دسترسیِ سرور @@ -102,7 +99,7 @@ ## ۲۷. کلیدِ جواب با حسابِ دوم، امتیازِ دوباره - **چیست:** جوابِ غلط `correctOptionId` را برمی‌گرداند و برای همه یکی است؛ پایانِ درس و پایانِ - جزیره هر کدام جدا امتیاز می‌دهند. یک مهمانِ دورریختنی کلید را درمی‌آورد و حسابِ اصلی همه را درست + جزیره هر کدام جدا امتیاز می‌دهند. یک حسابِ دومِ دورریختنی کلید را درمی‌آورد و حسابِ اصلی همه را درست می‌زند (۴ سوال، ۸۰ امتیاز). رتبه‌بندی و آمارِ درصدِ غلط آلوده می‌شوند. - **رفع (تصمیمِ محصولی):** امتیازِ هر سوال یک بار؛ گزینه‌ی درست فقط بعد از پایانِ آزمون. @@ -110,8 +107,8 @@ - **چیست:** استفاده‌ی دوباره از یک توکنِ تمدیدِ باطل‌شده فقط ۴۰۱ می‌گیرد؛ اگر دزد اول تمدید کند، صاحبِ حساب بیرون می‌افتد و دزد می‌ماند. «خروج از همه‌ی دستگاه‌ها» هم نیست. -- **چرا باز مانده:** باطل کردنِ کلِ زنجیره با استفاده‌ی دوباره، حساب‌های دمو را که در ارائه چند - نفر هم‌زمان با آن‌ها کار می‌کنند بیرون می‌اندازد. +- **چرا باز مانده:** در دورانِ ارائه حساب‌های دمو را چند نفر هم‌زمان به کار می‌بردند و باطل کردنِ زنجیره بیرونشان + می‌انداخت. آن حساب‌ها پاک شده‌اند؛ مانعی نمانده، فقط نوشته نشده است. - **رفع:** شناسه‌ی خانواده روی `Session`؛ با استفاده‌ی دوباره کلِ خانواده باطل شود؛ گزینه‌ی خروج از همه‌جا. ## ۲۹. پیشنهادِ ثبت‌نام بعد از حذفِ حساب @@ -157,10 +154,10 @@ | شماره‌ی موبایل نرمال می‌شود؛ cooldown، سقفِ روزانه‌ی هر شماره و سقفِ کلِ پیامک در دیتابیس شمرده می‌شوند | `accounts/tests_otp_limits.py` | | OTPِ ۶ رقمی با ۵ حدس برای هر کد؛ خروج از حساب توکن‌های قبلی را باطل می‌کند | `accounts/tests_audit_fixes.py` | | قلبِ صفر جوابِ سوال را می‌بندد؛ چکش داخلِ تراکنش است (بی‌جواب‌ماندن و جزیره‌ی قفل جداگانه در ردیفِ بعدی بسته شده‌اند) | `content/tests_hearts_gate.py` | -| `select_for_update` مؤثر با `transaction_mode: IMMEDIATE`؛ مقایسه‌ی زمان‌ثابتِ `X-Internal-Key` | `payment/tests_audit_fixes.py` | -| `deploy.sh` پیش از `migrate` بکاپِ `db-before-deploy-*` می‌گیرد، `integrity_check` می‌کند و اگر نشد migrate را اجرا نمی‌کند | مرحله‌ی «بکاپِ دیتابیس»: ۷ بار روی لپ‌تاپ و یک بار روی سرور (۶ مگابایت، `integrity_check: ok`) | +| `select_for_update` مؤثر (قفلِ ردیفِ Postgres؛ روی SQLiteِ محلی `transaction_mode: IMMEDIATE`)؛ مقایسه‌ی زمان‌ثابتِ `X-Internal-Key` | `payment/tests_audit_fixes.py` | +| `deploy.sh` پیش از `migrate` بکاپِ `db-before-deploy-*` می‌گیرد (`pg_dump -Fc` روی Postgres) و اگر نشد migrate را اجرا نمی‌کند | مرحله‌ی «بکاپِ دیتابیس» در خروجیِ هر دیپلو | | دیتابیس Postgresِ اختصاصی است و هر تغییرش لحظه‌ای روی سرورِ دیگری (۲۴۷) کپی می‌شود، با نسخه‌ی کاملِ روزانه و بازیابی تا هر لحظه از ۷ روزِ اخیر؛ قطعیِ ۲۴۷ اپ را نمی‌خواباند و بیش از ۱ گیگ روی دیسکِ ۲۴۹ جمع نمی‌شود (`docs/operations.md` «بکاپ و بازیابی») | `config/tests_offsite_backup.py` برای هشدارِ `/api/health`؛ روی سرور: بازیابی روی ۲۴۷ با شمارش‌های برابرِ ۲۴۹ و یک ردیفِ آزمایشی که ۲ ثانیه بعد آن‌جا بود | -| دیتابیس و بکاپ‌ها فقط برای صاحبِ سرویس خواندنی‌اند (مرحله‌ی «مجوزِ فایل‌های داده» در `deploy.sh`) | با `sudo -u nobody head -c 15 ~/mathapp-v2/db.sqlite3` روی سرور باید خطای دسترسی بدهد | +| `.env`، کلیدِ امضا، لاگ‌ها و بکاپ‌ها فقط برای صاحبِ سرویس خواندنی‌اند (مرحله‌ی «مجوزِ فایل‌های داده» در `deploy.sh`) | روی سرور `sudo -u nobody ls ~/mathapp-v2/.deploy-backups` باید خطای دسترسی بدهد | | پانزده مسیرِ خواندنی و نوشتنیِ کم‌خطر سقفِ نرخ دارند (بانکِ سوال و intro بی‌ورود، `progress/nodes`، `user/hearts`، اعلان، تیکت، پلن‌ها، وضعیتِ اشتراک، سفارش، پیشنهادها، affiliate، تاریخچه و فیدبکِ دستیار، خروج). با نرخِ ۳ در دقیقه درخواستِ چهارم ۴۲۹ می‌گیرد | `content/tests_read_throttles.py` | | ساختنِ دستیِ `OTPCode` و `Session` در پنل فقط برای ابرکاربر (کارمندِ عادی ۴۰۳) | `accounts/tests_security_review.py` (`StaffCannotMintLoginsTests`) | | پایانِ درس و پایانِ آزمونِ جزیره جوابِ ثبت‌شده به همه‌ی سوال‌ها می‌خواهند (۴۰۹ `answers_missing`)، روی جزیره‌ی قفل ۴۰۳ `node_locked` می‌دهند (همان قاعده‌ی نقشه)، جزیره یا درسِ بی‌سوال چیزی برای جواب‌دادن ندارد و اگر باز است بی‌امتیاز رد می‌شود (قفل بودنش هنوز چک می‌شود)، و تعدادِ سوالِ آزمون را کلاینت نمی‌تواند کمتر از آزمونِ استانداردِ ۴ تایی بگوید (ستاره‌ی دروغین) | `content/tests_finish_requires_answers.py`، `content/tests_node_completion.py` | diff --git a/docs/technical-handbook.md b/docs/technical-handbook.md index 0d3da4a..430f223 100644 --- a/docs/technical-handbook.md +++ b/docs/technical-handbook.md @@ -4,10 +4,10 @@ | | | |---|---| -| تاریخ | ۱۰ مهر ۱۴۰۵ (۲ اکتبر ۲۰۲۶) | +| تاریخ | ۲۲ مهر ۱۴۰۵ (۱۴ اکتبر ۲۰۲۶) | | نویسنده | علیرضا زارعیان — بک‌اند و فرانت‌اند | | مخاطب | توسعه‌دهنده‌ی تازه‌وارد، مدیرِ فنی، و هر کسی که باید درباره‌ی ادامه‌ی پروژه تصمیم بگیرد | -| کدِ مبنا | بک‌اند `8a4753b` (مخزنِ `Tadrisino/MathApp` ) · فرانت `33f5f8d` (مخزنِ `Tadrisino/MathApp_frontend` ) | +| کدِ مبنا | شاخه‌ی `main` ِ هر دو مخزن در همین تاریخ: `Tadrisino/MathApp` (بک‌اند) و `Tadrisino/MathApp_frontend` (فرانت و پوسته‌ی اندروید) | | نشانی‌ها | اپ: `math.tadrisino2.ir` · API: `mathapi.tadrisino2.ir` · مستنداتِ زنده‌ی API: `/api/docs/` | > این فایل نسخه‌ی Markdownِ «مستند-فنی-تدریسینو» است. نسخه‌ی PDF با همان محتوا و صفحه‌آراییِ کامل کنارِ همین فایل است: `technical-handbook.pdf`. @@ -18,12 +18,13 @@ | شاخص | مقدار | |---|---| -| سوم، ششم، هفتم، هشتم تا دوازدهم | **۸ پایه** | -| سؤالِ چهارگزینه‌ای از مسئله‌های کتاب | **۲٬۵۴۵** | -| اندپوینتِ API، مستند در Swagger | **۵۹** | -| تستِ خودکار، همه سبز | **۷۷۹** | +| پایه‌های سوم تا دوازدهم | **۱۰ پایه** | +| کتاب (ریاضی، حسابان، هندسه، آمار و احتمال، گسسته) | **۱۵ کتاب در ۵ درس** | +| سؤالِ چهارگزینه‌ای | **۱۸٬۵۲۲** | +| عملیاتِ API، مستند در Swagger | **۶۱** | +| تستِ خودکار، همه سبز | **۹۷۳** | | اپِ جنگو، هرکدام یک مسئولیت | **۱۴** | -| تکه‌ی کتاب در Qdrant (۱۱ collection) | **۳٬۵۱۷** | +| تکه‌ی کتاب در Qdrant (۱۸ collection) | **۵٬۸۱۸** | | همراهِ گیمیفیکیشن، هرکدام ۶ حالتِ چهره | **۴** | | بک‌اند و فرانت؛ یک قراردادِ API | **۲ مخزن** | @@ -31,7 +32,6 @@ | کاربر | چه می‌کند | کجای کد | |---|---|---| -| دانش‌آموزِ مهمان | بدونِ شماره وارد می‌شود، پایه‌اش را انتخاب می‌کند و بازی می‌کند. اگر بعداً با شماره ثبت‌نام کند، همان حساب می‌ماند و امتیازش گم نمی‌شود. | `accounts/views.py` | | دانش‌آموز | از هر سنی (بزرگسالی که فقط یک مبحث را می‌خواهد هم پایه‌ی همان مبحث را انتخاب می‌کند). با شماره‌ی موبایل و کدِ پیامکی وارد می‌شود؛ جزیره‌ها را بازی می‌کند، از دستیار می‌پرسد، امتیاز و سکه و زنجیره‌ی روزانه جمع می‌کند، همراه انتخاب می‌کند، اشتراک می‌خرد، تیکتِ پشتیبانی می‌فرستد. | `content/` `progress/` `ai_tutor/` | | والد | حسابِ جدا ندارد: «گزارشِ والدین» را در حسابِ خودِ دانش‌آموز می‌بیند — زمانِ تمرین، سوال‌ها و مقایسه با هفته‌ی پیش، با دو نمودار. | `progress/report.py` | | ادمین (کارمندِ شرکت) | از پنلِ `/admin/` سؤال، درس، پلن، همراه و تیکت را بدونِ برنامه‌نویس ویرایش می‌کند؛ سؤال‌ها را فله‌ای وارد و خارج می‌کند. | `*/admin.py` | @@ -43,6 +43,7 @@ | مرداد ۱۴۰۵ | طراحیِ معماری و مدل‌های داده؛ هویتِ کاربر؛ جداسازیِ اپ‌ها. بازبینیِ امنیتیِ اول و بستنِ حفره‌ها، محدودیتِ نرخ در کلِ سیستم. | | شهریور | دستیار از پاسخِ ساختگی به RAGِ واقعی روی کتاب رسید؛ اتصالِ زرین‌پال؛ گیمیفیکیشن؛ خواندنِ پنج کتابِ ریاضی و ساختِ هزاران سؤالِ تأییدشده؛ حسابانِ ۱۱ و ۱۲. | | ۲ تا ۷ مهر | فرانت به ما سپرده شد و از نو ساخته شد؛ هر دو روی دامنه‌ی تازه زنده شدند؛ پیامکِ واقعی؛ شکل در جوابِ دستیار؛ همراهِ چهارم؛ بازبینیِ نهایی روی سایتِ زنده. | +| ۸ تا ۲۲ مهر | ورودِ مهمان حذف شد؛ کلیدِ امضای تازه؛ PostgreSQL و Redisِ اختصاصی و کپیِ زنده‌ی دیتابیس روی سرورِ دوم؛ دیوارِ پرداخت و اشتراکِ یک/سه/شش‌ماهه از زرین‌پال و کافه‌بازار؛ پایه‌های چهارم و پنجم و پنج کتابِ تازه (هندسه، آمار، گسسته)؛ جزیره‌های تمرین و ۱۸ هزار سؤال؛ سخت‌کردنِ سرور و آزمونِ بارِ هزار نفره. | ### مدلِ سناریو (Use Case) @@ -50,11 +51,11 @@ ![مدل سناریو (Use Case)](images/technical-handbook/use-case-model.png) -*شکلِ ۱-۱ — مدلِ سناریو: سه نقشِ کاربری (والد با حسابِ خودِ دانش‌آموز)، یازده کارِ اصلی، و سه سرویسِ بیرونی.* +*شکلِ ۱-۱ — مدلِ سناریو: نقش‌های کاربری (والد با حسابِ خودِ دانش‌آموز)، کارهای اصلی، و سرویس‌های بیرونی.* ### واحدهای محتوا (این واژه‌ها در کلِ سند تکرار می‌شوند) -پایه Grade ۸ ← فصل Unit ۵۸ ← جزیره Node ۲۶۳ ← درس Lesson ۲٬۶۱۷ ← سؤال Question ۲٬۵۴۵ ← گزینه Choice +پایه Grade ۱۰ ← کتاب Subject ۵ ← فصل Unit ۸۹ ← جزیره Node ۱٬۱۴۵ ← درس Lesson ۵٬۴۰۶ ← سؤال Question ۱۸٬۵۲۲ ← گزینه Choice **«درس» در این پروژه یعنی یک «تیپ‌مسئله»ی کتاب**، نه یک جلسه‌ی کلاس. برای همین درس خیلی ریز است و اپ در سطحِ **جزیره** (Node) بازی می‌شود: آزمونِ هر جزیره از درس‌هایش سؤال برمی‌دارد. @@ -72,15 +73,15 @@ | لایه | انتخاب | چرا | چرا نه جایگزین | هزینه / ریسک | |---|---|---|---|---| -| بک‌اند | **Django 5.2** + **Django REST Framework** | ORM و migration و **پنلِ ادمینِ آماده** در یک بسته؛ پنلِ ادمین یعنی محتوا بدونِ برنامه‌نویس ویرایش می‌شود. DRF احرازِ هویت، مجوز، محدودیتِ نرخ و سریال‌سازی را استاندارد می‌دهد. پایتون همان زبانِ کتابخانه‌های هوشِ مصنوعی است ( `openai`، `qdrant-client`، `pymupdf` ). | FastAPI سریع‌تر و async است ولی ادمین، ORM و migration را باید جدا سرهم کرد. Node.js زبانِ بک‌اند را از زبانِ لایه‌ی AI جدا می‌کرد. | جنگو پیش‌فرض همگام (sync) است؛ جوابِ طولانیِ دستیار یک نخ را نگه می‌دارد. با ۲۴ نخِ هم‌زمان حل شد (بخشِ ۱۴). | +| بک‌اند | **Django 5.2** + **Django REST Framework** | ORM و migration و **پنلِ ادمینِ آماده** در یک بسته؛ پنلِ ادمین یعنی محتوا بدونِ برنامه‌نویس ویرایش می‌شود. DRF احرازِ هویت، مجوز، محدودیتِ نرخ و سریال‌سازی را استاندارد می‌دهد. پایتون همان زبانِ کتابخانه‌های هوشِ مصنوعی است ( `openai`، `qdrant-client`، `pymupdf` ). | FastAPI سریع‌تر و async است ولی ادمین، ORM و migration را باید جدا سرهم کرد. Node.js زبانِ بک‌اند را از زبانِ لایه‌ی AI جدا می‌کرد. | جنگو پیش‌فرض همگام (sync) است؛ جوابِ طولانیِ دستیار یک نخ را نگه می‌دارد. با ۴ worker × ۳۲ نخ و سهمِ جدای دستیار حل شد (بخشِ ۱۴). | | قراردادِ API | **OpenAPI** با drf-spectacular | اسکیما از خودِ کد ساخته می‌شود، پس هیچ‌وقت از کد عقب نمی‌ماند. Swagger در `/api/docs/` قراردادِ مشترکِ دو مخزن است. | سندِ دستی یا «در ذهنِ کسی» — که دقیقاً باعثِ ناهمخوانیِ اولیه‌ی فرانت و بک‌اند شد. | — | -| فرانت | **React 18** + **TypeScript** (strict) + **Vite** + **Tailwind** | فرانتِ اولیه را توسعه‌دهنده‌ی قبلی با React ساخته بود؛ در بازسازی نگه داشته شد چون بزرگ‌ترین اکوسیستم را دارد و نیروی جایگزینش راحت پیدا می‌شود. TypeScript ناهمخوانیِ شکلِ داده با API را در زمانِ build می‌گیرد. Vite buildِ سریع و Tailwind رنگ و فاصله‌ی یکدست. وبِ موبایل‌محور یعنی بدونِ نصب و بدونِ فروشگاه روی هر گوشی باز می‌شود. | React Native / Flutter نیازِ انتشار در فروشگاه و build جدا داشت؛ نسخه‌ی اندروید (کافه‌بازار) بعداً می‌تواند اضافه شود. | بدونِ کتابخانه‌ی router و state؛ کمتر وابستگی، ولی الگوی داخلی را باید خواند (بخشِ ۴). | -| دیتابیس | **SQLite** (فعلاً) | روی سرورِ مشترکِ شرکت صفر نگهداری دارد: یک فایل، بکاپ با یک دستور. برای حجمِ فعلی کافی است. کد از ORM استفاده می‌کند و به SQLite گره نخورده. | Postgres روی همان سرور موجود است؛ مهاجرت به هماهنگی با ادمینِ سرور نیاز دارد و به مرحله‌ی بعد موکول شده است. | **فقط یک نویسنده در لحظه.** اولین گلوگاهِ مقیاس است (بخشِ ۱۴). | +| فرانت | **React 18** + **TypeScript** (strict) + **Vite** + **Tailwind** | فرانتِ اولیه را توسعه‌دهنده‌ی قبلی با React ساخته بود؛ در بازسازی نگه داشته شد چون بزرگ‌ترین اکوسیستم را دارد و نیروی جایگزینش راحت پیدا می‌شود. TypeScript ناهمخوانیِ شکلِ داده با API را در زمانِ build می‌گیرد. Vite buildِ سریع و Tailwind رنگ و فاصله‌ی یکدست. وبِ موبایل‌محور یعنی بدونِ نصب و بدونِ فروشگاه روی هر گوشی باز می‌شود. | React Native / Flutter نیازِ انتشار در فروشگاه و build جدا داشت. نسخه‌ی اندروید یک پوسته‌ی WebView روی همین سایت است که فقط پرداختِ کافه‌بازار را بومی انجام می‌دهد ( `android/` در مخزنِ فرانت). | بدونِ کتابخانه‌ی router و state؛ کمتر وابستگی، ولی الگوی داخلی را باید خواند (بخشِ ۴). | +| دیتابیس و کش | **PostgreSQL 14** (clusterِ اختصاصی) · **Redis** (اختصاصی) | قفلِ ردیفیِ واقعی برای پرداخت، قلب و سهمیه؛ چند نویسنده‌ی هم‌زمان؛ کپیِ زنده‌ی لحظه‌ای به سرورِ دوم با ابزارِ خودِ Postgres. Redis سقف‌های نرخ و کش را بینِ workerها مشترک می‌کند. توسعه‌ی محلی و تست‌ها روی SQLite می‌مانند. | Postgres و Redisِ موجودِ ماشین مالِ تیم‌های دیگرند؛ برای این‌که به داده‌ی دیگران دست نزنیم، نمونه‌ی جدای خودمان را داریم. | دو سرویسِ بیشتر برای نگهداری؛ اتصال‌ها از poolِ هر worker می‌آیند چون `max_connections` ِ سرور ۶۰ است. | | جست‌وجوی کتاب | **Qdrant** (بانکِ برداری) | تیمِ دیگرِ شرکت کتاب‌های سوم/ششم/هفتم را از قبل در یک Qdrantِ مشترک گذاشته بود؛ همان را ادامه دادیم تا داده دوباره ساخته نشود. فیلتر روی فیلدِ «فصل» دارد. | pgvector به Postgres نیاز داشت؛ FAISS سرور ندارد و بینِ دو تیم اشتراک‌پذیر نیست. | سرویسِ مشترک با تیمِ دیگر؛ پاک‌کردن باید با احتیاط باشد (بخشِ ۹). | -| مدلِ زبانی | **GapGPT** ← `gpt-4.1-mini` | GapGPT یک واسطِ سازگار با OpenAI است که از داخلِ ایران جواب می‌دهد. مدل با سنجش انتخاب شد، نه با جدولِ قیمت: روی ۱۰۲ مسئله‌ی تصحیح‌شده ۹۵–۹۶ درست در برابرِ ۸۵–۹۰ برای `gpt-4o-mini` ؛ هر جواب با پرامپتِ امروز حدودِ ۰٫۲ سنت (≈ ۶۰۰ تومان؛ `docs/ai-cost-and-guardrails.md`). دستیار فقط درباره‌ی ریاضی حرف می‌زند و سقفِ روزانه‌اش برای کاربرِ رایگان ۳ و برای مشترک به اندازه‌ی پلن است. | OpenAIِ مستقیم از ایران در دسترس نیست. مدل‌های گران‌تر در دورِ اولِ سنجش (۱۴ مدل) مزیتی نشان ندادند. | کُندیِ سرویس (حتی «سلام» ۷ ثانیه) دستِ ما نیست؛ اعتبار دو بار تمام شده. | -| ورود | **توکنِ امضاشده‌ی خودمان** + کدِ پیامکی | کاربرها با شماره وارد می‌شوند، نه نام‌کاربری/رمز؛ مدلِ کاربرِ جنگو به این نمی‌خورد. توکن با `django.core.signing` امضا می‌شود؛ یک وابستگیِ کمتر از کتابخانه‌ی JWT. | JWT همان کار را می‌کند با یک کتابخانه‌ی اضافه. | امنیتش به کلیدِ امضا بسته است (بخشِ ۱۲، موردِ ۱). | +| مدلِ زبانی | **GapGPT** ← `gpt-4.1-mini` | GapGPT یک واسطِ سازگار با OpenAI است که از داخلِ ایران جواب می‌دهد. مدل با سنجش انتخاب شد، نه با جدولِ قیمت: روی ۱۰۲ مسئله‌ی تصحیح‌شده ۹۵–۹۶ درست در برابرِ ۸۵–۹۰ برای `gpt-4o-mini` ؛ هر جواب با پرامپتِ امروز حدودِ ۰٫۲ سنت (≈ ۶۰۰ تومان؛ `docs/ai-cost-and-guardrails.md`). دستیار فقط درباره‌ی ریاضی حرف می‌زند و سقفِ روزانه‌اش برای کاربرِ رایگان ۲ و برای مشترک ۴ است. | OpenAIِ مستقیم از ایران در دسترس نیست. مدل‌های گران‌تر در دورِ اولِ سنجش (۱۴ مدل) مزیتی نشان ندادند. | کُندیِ سرویس (حتی «سلام» ۷ ثانیه) دستِ ما نیست؛ اعتبار دو بار تمام شده. | +| ورود | **توکنِ امضاشده‌ی خودمان** + کدِ پیامکی | کاربرها با شماره وارد می‌شوند، نه نام‌کاربری/رمز؛ مدلِ کاربرِ جنگو به این نمی‌خورد. توکن با `django.core.signing` امضا می‌شود؛ یک وابستگیِ کمتر از کتابخانه‌ی JWT. | JWT همان کار را می‌کند با یک کتابخانه‌ی اضافه. | امنیتش به کلیدِ امضا بسته است؛ کلید روی خودِ سرور ساخته شده و در هیچ مخزنی نیست (بخشِ ۱۲). | | پیامک / پرداخت | **کاوه‌نگار** · **زرین‌پال** · **کافه‌بازار** | سرویس‌های ایرانیِ رایج؛ کاوه‌نگار با «ارسال با الگو» که روی خطِ شرکت فعال است؛ زرین‌پال برای وب، بازار برای خریدِ درون‌برنامه‌ایِ اندروید. | — | پرداختِ واقعی تا انتها هنوز آزموده نشده. | -| اجرا | **gunicorn** (gthread) پشتِ **nginx** پشتِ **ArvanCloud** | الگوی استانداردِ اجرای جنگو. آروان CDN و سپرِ حمله‌ی ایرانی است. WhiteNoise فایل‌های استاتیکِ ادمین را از خودِ جنگو سرو می‌کند. | — | سرور مشترک است؛ مالِ ما فقط یک پوشه و یک سرویس. | +| اجرا | **gunicorn** (gthread) پشتِ **nginx** پشتِ **ArvanCloud** | الگوی استانداردِ اجرای جنگو. آروان CDN و سپرِ حمله‌ی ایرانی است و nginx فقط درخواستِ رسیده از آروان را می‌پذیرد. WhiteNoise فایل‌های استاتیکِ ادمین را از خودِ جنگو سرو می‌کند. | — | سرور مشترک است؛ مالِ ما فقط پوشه‌ها و سرویس‌های خودمان (بخشِ ۱۳). | > #### اصلِ کلیِ انتخاب‌ها > @@ -96,17 +97,17 @@ ### درونِ بک‌اند: «یک برنامه، چند اپِ مستقل» (Modular Monolith) -بک‌اند یک برنامه‌ی جنگوست که به ۱۴ «اپ» تقسیم شده (اپِ `family` ۱۰ مهر ۱۴۰۵ حذف شد). هر اپ یک پوشه است با مدل‌ها، APIها و تست‌های خودش. این همان الگویی است که اجازه می‌دهد چند نفر هم‌زمان روی بک‌اند کار کنند (بخشِ ۱۴). +بک‌اند یک برنامه‌ی جنگوست که به ۱۴ «اپ» تقسیم شده. هر اپ یک پوشه است با مدل‌ها، APIها و تست‌های خودش. این همان الگویی است که اجازه می‌دهد چند نفر هم‌زمان روی بک‌اند کار کنند (بخشِ ۱۴). | اپ | مسئولیت | مدل‌ها (جدول‌ها) | |---|---|---| -| `accounts` | هویت، ورود با کدِ پیامکی، نشستِ ورود، پروفایل | `User Profile Session OTPCode` | -| `content` | برنامه‌ی درسی، سؤال، آزمونِ جزیره، ثبتِ جواب و پیشرفت | `Grade Unit Node Lesson Question Choice UserAnswer UserLessonCompletion UserNodeCompletion NodeProgress QuestionReport` | +| `accounts` | هویت، ورود با کدِ پیامکی، نشستِ ورود، پروفایل | `User Profile Session OTPCode LoginEvent` | +| `content` | کتاب‌ها، برنامه‌ی درسی، سؤال، آزمونِ جزیره، ثبتِ جواب و پیشرفت، رأی و گزارشِ دانش‌آموز | `Grade Subject Unit Node Lesson Question Choice UserAnswer UserLessonCompletion UserNodeCompletion NodeProgress ContentVote QuestionReport LessonReport SeedRecord` | | `progress` | امتیاز، سکه، قلب، زنجیره، جدولِ رتبه، همراه‌ها | `UserStats UserHearts StreakRecord ActiveDay LeaderboardEntry Character` | | `ai_tutor` | گفتگو با دستیار، سهمیه، بازخوردِ 👍/👎 | `Conversation Message MessageFeedback AskIdempotency` | | `rag` | خواندنِ کتاب، Qdrant، ساختِ پرامپت، شکل‌ها | `LessonChunk LessonChunkImage IngestedPage` | | `subscription` | پلن‌های اشتراک و اشتراکِ هر کاربر | `Plan Subscription` | -| `payment` | سفارش، تراکنش، زرین‌پال، بازار، لغو و بازپرداخت از ادمین | `Order Transaction BazaarCredential` | +| `payment` | سفارش، تراکنش، زرین‌پال، بازار، لغو و بازپرداخت از ادمین، دفترِ رویدادِ خرید | `Order Transaction PaymentEvent BazaarCredential` | | `discount` | کدِ تخفیف و پیشنهادها | `DiscountCode Redemption Offer UserOffer` | | `affiliate` | معرفی به دوستان و پورسانت | `AffiliateAccount Conversion` | | `notifications` | اعلان‌های داخلِ اپ | `Notification` | @@ -148,19 +149,22 @@ MathApp/ ├── config/ تنظیمات و سیم‌کشیِ کلِ پروژه │ ├── settings.py همه‌ی تنظیمات؛ خواندنِ .env؛ نرخِ throttleها؛ کلیدِ امضا؛ مدل‌های AI │ ├── urls.py ریشه‌ی مسیرها: /admin، /api/docs، /api/health و include هر اپ -│ ├── middleware.py لاگِ JSONِ هر درخواست، no-store، سقفِ ورودِ ادمین +│ ├── middleware.py IPِ واقعی فقط از آروان، لاگِ JSONِ هر درخواست، no-store، سقفِ ورودِ ادمین │ ├── exceptions.py هر خطا → پاسخِ JSONِ یکدست، بی‌جزئیاتِ داخلی -│ ├── health.py GET /api/health: دیتابیس، migration، کش، محتوا، هشدارها +│ ├── health.py GET /api/health: دیتابیس، migration، کش، pool، بکاپ، هشدارها +│ ├── db.py · cache.py poolِ اتصالِ Postgres (۵۰۳ به‌جای ۵۰۰)، Redisِ تحمل‌پذیر +│ ├── renderers.py خروجیِ JSON بی‌اعراب و بی «ی»ِ اضافه │ └── log_formatters.py قالبِ JSONِ لاگ ├── accounts/ content/ progress/ ai_tutor/ rag/ subscription/ payment/ ├── discount/ affiliate/ notifications/ support/ campaigns/ statuslog/ ├── content/seed/ curriculum.json و questions.json — کلِ محتوا به‌صورتِ فایل در git ├── data/textbooks/ PDFِ کتاب‌ها (در git نیست؛ فقط README) ├── templates/tadrisino/ پوسته‌ی راست‌به‌چپِ Swagger و ReDoc -├── tools/fake_openai.py جایگزینِ GapGPT برای تستِ بی‌هزینه +├── tools/ fake_openai.py (جایگزینِ GapGPT) · loadtest/ (آزمونِ بار) +├── ops/ نسخه‌ی اصلیِ nginx، بکاپِ Postgres و آرشیوِ لاگ روی سرورها ├── docs/ operations، architecture، security-before-production، content-authoring، … ├── deploy.sh کلِ انتشار در یک اسکریپت (بخشِ ۱۳) -├── gunicorn.conf.py ۸ نخ در هر worker، timeoutِ ۱۲۰ +├── gunicorn.conf.py ۴ worker × ۳۲ نخ، timeoutِ ۱۲۰ ├── requirements.txt Django 5.2، DRF، drf-spectacular، openai، qdrant-client، pymupdf، … ├── docker-compose.yml فقط محلی: Loki + Alloy + Grafana برای دیدنِ لاگ └── manage.py @@ -198,7 +202,7 @@ src/ ├── state/ داده‌ی مشترک (پروفایل، نقشه) و گفتگوی دستیار ├── screens/ یک فایل برای هر صفحه: HomeScreen، QuizScreen، AskScreen، ReportScreen، … ├── components/ اجزای مشترک: مسیرِ جزیره‌ها، برگه‌ی پایین، نوارِ پایین -├── mascots/ سیگما، دلتا، پی، تدی — SVG از کد، ۶ حالتِ چهره +├── mascots/ سیگما، دلتا، پی، امگا — SVG از کد، ۶ حالتِ چهره ├── diagram/ JSONِ شکلِ دستیار → SVG؛ مختصات، ریشه، زاویه این‌جا حساب می‌شود └── lib/ رقمِ فارسی، تاریخِ شمسی، MathText، پیامِ خطای دوستانه، صدا ``` @@ -230,7 +234,7 @@ Authorization: Bearer 10:1tQx9k:Xy3… ← user_id:زمان:امضا Idempotency-Key: 7f3c… {"selectedOptionId": 7290} ``` -**4. شبکه** — اینترنت ← **ArvanCloud** ← **nginx** روی سرور ← سوکتِ یونیکس ← یک نخِ آزاد از **gunicorn** ( `gunicorn.conf.py` ). +**4. شبکه** — اینترنت ← **ArvanCloud** ← **nginx** روی سرور (فقط درخواستِ رسیده از آروان) ← سوکتِ یونیکس ← یک نخِ آزاد از **gunicorn** ( `gunicorn.conf.py` ). **5. بک‌اند · middleware** — `config/settings.py → MIDDLEWARE` — لایه‌های عمومی به‌ترتیب: امنیت، **CORS** (آیا درخواست از `math.tadrisino2.ir` آمده؟)، فایل‌های استاتیک، سقفِ ورودِ ادمین، …، `APINoStoreMiddleware` (پاسخ در کشِ آروان نماند)، و در آخر `APIRequestLoggingMiddleware` که در پایان یک خطِ JSON لاگ می‌نویسد ( `config/middleware.py` ). @@ -241,7 +245,7 @@ path('api/', include('content.urls')), # config/urls.py path('questions//answer', QuestionAnswerView.as_view()), # content/urls.py ``` -**7. بک‌اند · هویت** — `accounts/authentication.py` — امضا و انقضای توکن را بی‌رجوع به دیتابیس می‌سنجد و `request.user` را می‌سازد. بی‌هدر یعنی «مهمانِ ناشناس»، نه خطا؛ ویو تصمیم می‌گیرد. +**7. بک‌اند · هویت** — `accounts/authentication.py` — امضا و انقضای توکن را بی‌رجوع به دیتابیس می‌سنجد و `request.user` را می‌سازد. بی‌هدر یعنی «درخواستِ ناشناس»، نه خطا؛ ویو تصمیم می‌گیرد. ``` value = TimestampSigner(salt='tadrisino-access-token').unsign(token, max_age=3600) @@ -294,7 +298,7 @@ with transaction.atomic(): هر نمودار از راست به چپ خوانده می‌شود: ستون‌ها بازیگرها هستند، پیکان‌ها درخواست‌ها. پیکانِ خط‌چین یعنی پاسخ. شماره‌ها ترتیبِ زمانی‌اند. -### ۶-۱ ورود: مهمان، بعد ثبت‌نام با شماره +### ۶-۱ ورود با شماره و کدِ پیامکی ![سناریوی ورود](images/technical-handbook/scenario-login.png) @@ -331,17 +335,17 @@ with transaction.atomic(): > - **کافه‌بازار:** اپ با Poolakey اشتراک می‌خرد و سرور توکن را از خودِ بازار می‌سنجد؛ بازار تمدیدِ خودکار را خبر نمی‌دهد، پس سرور نزدیکِ پایانِ دوره خودش می‌پرسد ( `sync_bazaar_renewals` ). کدِ تخفیف با «تخفیف پویا»ی بازار به قیمت می‌رسد. > - **لغو و بازپرداخت از پنلِ ادمین:** دو action روی سفارش‌ها؛ بازپرداخت پس از صفحه‌ی تأیید روزهای اشتراکِ همان سفارش را پس می‌گیرد. خودِ پول از پنلِ درگاه برگردانده می‌شود ( `docs/operations.md` ). > -> آزموده‌نشده زرین‌پال درخواستِ پرداخت را با کدِ ۱۰۰ می‌پذیرد، ولی یک پرداختِ واقعی تا انتها هنوز انجام نشده؛ خریدِ بازار منتظرِ ساختِ اپِ اندروید است. +> آزموده‌نشده: زرین‌پال درخواستِ پرداخت را با کدِ ۱۰۰ می‌پذیرد، ولی یک پرداختِ واقعی تا انتها هنوز انجام نشده؛ خریدِ بازار منتظرِ نسخه‌ی تازه‌ی اپِ اندروید است. ### ۶-۵ گزارشِ والدین -والد حسابِ جدا و کدِ دعوت ندارد (تا مهرِ ۱۴۰۵ داشت؛ به تصمیمِ پس از ارائه حذف شد). صفحه‌ی «گزارشِ والدین» در پروفایلِ خودِ دانش‌آموز است. +والد حسابِ جدا ندارد؛ صفحه‌ی «گزارشِ والدین» در پروفایلِ خودِ دانش‌آموز است. | # | درخواست | چه می‌شود | |---|---|---| | ۱ | `GET /api/user/weekly-report` | برای هر یک از ۷ روزِ اخیر و ۷ روزِ قبلش: زمانِ تمرین، سوالِ حل‌شده و درست، امتیاز، درس و جزیره‌ی تمام‌شده، سوال از دستیار. فقط دیتای خودِ صاحبِ توکن. | | ۲ | — | فرانت جمله‌ی مقایسه («آفرین! این هفته ۲۰ دقیقه بیشتر…») و دو نمودار می‌سازد: زمانِ هر روز در برابرِ همان روزِ هفته‌ی پیش، و سوال‌های درست/نادرستِ هر روز. | -| ۳ | `GET /api/progress/nodes` | پارامترِ `userId` برداشته شد؛ هر کاربر فقط پیشرفتِ خودش را می‌بیند. | +| ۳ | `GET /api/progress/nodes` | هر کاربر فقط پیشرفتِ خودش را می‌بیند؛ شناسه‌ی کاربرِ دیگر پذیرفته نمی‌شود. | > **تخمینی:** «زمانِ تمرین» اندازه‌گیری نیست: از فاصله‌ی جواب‌ها و پرسش‌ها حساب می‌شود (فاصله‌ی بیش از ۱۰ دقیقه وقفه است). خواندنِ درس بدونِ جواب دادن در آن نیست، پس عدد معمولاً از زمانِ واقعی کمتر است. @@ -350,25 +354,29 @@ with transaction.atomic(): جدول‌های اصلی، دسته‌بندی‌شده بر اساسِ اپ. `→` یعنی کلیدِ خارجیِ واقعی (داخلِ همان اپ)، و `user_id` یعنی ارجاعِ سبک به اپِ دیگر. **accounts هویت** -- `User phone, name, is_guest, device_id, grade_id, tokens_valid_after` -- `Profile → User · birth_year, parent_phone` -- `Session → User · refresh_token, expires_at, revoked` +- `User phone, name, grade_id, tokens_valid_after` +- `Profile → User · birth_date (شمسی در API)، birth_year` +- `Session → User · refresh_token (هش‌شده), expires_at, revoked` - `OTPCode phone, code, expires_at, failed_attempts` +- `LoginEvent ثبتِ هر ورود` **content محتوا** - `Grade id = شماره‌ی پایه (۸ = هشتم)` -- `Unit → Grade · title, blurb, order` +- `Subject key, title (ریاضی، حسابان، هندسه، آمار و احتمال، گسسته)` +- `Unit → Grade, → Subject · title, blurb, order` - `Node → Unit · order (جزیره)` -- `Lesson → Node · kind, example, steps` -- `Question → Lesson · prompt, type` +- `Lesson → Node · kind, example, steps, figures` +- `Question → Lesson · prompt, type, figure` - `Choice → Question · value, is_correct` +- `SeedRecord دفترِ آنچه seed تحویل داده (تا کارِ ادمین برگردانده نشود)` **content فعالیتِ کاربر** - `UserAnswer user_id, → Question, → Choice` - `UserLessonCompletion user_id, → Lesson` - `UserNodeCompletion user_id, → Node, stars, xp_earned, coins_earned` - `NodeProgress user_id, → Node, status` -- `QuestionReport «این سؤال ایراد دارد»` +- `ContentVote user_id، سؤال یا درس، ۱ / −۱` +- `QuestionReport · LessonReport «ایراد دارد»` **progress بازی** - `UserStats user_id, xp, coins, selected_character_id` @@ -390,6 +398,7 @@ with transaction.atomic(): - `Subscription user_id, → Plan, status, expires_at` - `Order user_id, plan_id, subscription_id, amount, list_price, plan_title, duration_days, status (… canceled, refunded), provider, gateway_authority, bazaar_purchase_token, bazaar_valid_until` - `Transaction → Order · gateway_ref_id, status, paid_at` +- `PaymentEvent order_id, event, detail — هر قدمِ مسیرِ خرید` - `BazaarCredential توکنِ اتصال به بازار` **discount · affiliate بازاریابی** @@ -421,7 +430,7 @@ with transaction.atomic(): | یکتا: `CampaignDelivery(کمپین، کاربر، کانال)` | پیامِ تکراری وقتی کمپین دو بار هم‌زمان اجرا شود | | یکتا: `LessonChunk(collection, qdrant_point_id)` و `IngestedPage(collection, page)` | تکه‌ی تکراری در Qdrant وقتی خواندنِ کتاب از سر گرفته می‌شود | -> **کد و محتوا دو مسیرِ جدا دارند.** `db.sqlite3` در git نیست. محتوا (برنامه‌ی درسی و سؤال‌ها) به‌صورتِ JSON در `content/seed/` است و `deploy.sh` در هر انتشار آن را وارد می‌کند. داده‌ی کاربرها فقط در فایلِ دیتابیسِ سرور است. +> **کد و محتوا دو مسیرِ جدا دارند.** دیتابیس در git نیست. محتوا (برنامه‌ی درسی و سؤال‌ها) به‌صورتِ JSON در `content/seed/` است و `deploy.sh` در هر انتشار آن را وارد می‌کند. داده‌ی کاربرها فقط در Postgresِ سرور (و کپیِ زنده‌اش روی سرورِ بکاپ) است. ## ۸. API: قواعد و فهرستِ کامل @@ -434,12 +443,13 @@ with transaction.atomic(): | نشانیِ پایه | `https://mathapi.tadrisino2.ir/api/…` — دامنه در کد نیامده و از تنظیمات ( `PUBLIC_BASE_URL` ) خوانده می‌شود. | | ورود | هدرِ `Authorization: Bearer `. توکن یک ساعت اعتبار دارد و با `POST /api/auth/refresh` تازه می‌شود. | | قالبِ داده | JSON؛ نامِ فیلدها camelCase ( `selectedOptionId` )، شناسه‌ها در پاسخ رشته‌اند. | -| خطا | همیشه JSON با `detail` (و گاهی `code` مثلِ `no_hearts` ): ۴۰۰ ورودیِ بد، ۴۰۱ توکنِ نامعتبر، ۴۰۳ اجازه نداری، ۴۰۴، ۴۲۹ زیاد درخواست دادی، ۵۰۳ سرویسِ بیرونی در دسترس نیست. | +| خطا | همیشه JSON با `detail` (و گاهی `code` مثلِ `no_hearts`، `node_locked`، `answers_missing`، `subscription_required` ): ۴۰۰ ورودیِ بد، ۴۰۱ توکنِ نامعتبر، ۴۰۲ اشتراک لازم است، ۴۰۳ اجازه نداری، ۴۰۴، ۴۰۹ پیش‌شرط برقرار نیست، ۴۲۹ زیاد درخواست دادی، ۵۰۳ سرویسِ بیرونی یا ظرفیت در دسترس نیست (با `Retry-After` ). | +| متن | متنِ فارسیِ پاسخ‌ها بی‌اعراب و بی «ی»ِ اضافه است، و هیچ LaTeX یا markdownی ندارد. | | تکرارِ امن | هدرِ اختیاریِ `Idempotency-Key` روی خرید، دستیار، جواب، پایانِ درس و جزیره: درخواستِ تکراری با همان کلید، همان نتیجه‌ی قبلی را برمی‌گرداند. | | سقفِ نرخ | هر اندپوینتِ حساس یک `throttle_scope` دارد؛ نرخ‌ها همه یک‌جا در `config/settings.py → DEFAULT_THROTTLE_RATES`. | | استریم | `POST /api/ai/ask` با `"stream": true` پاسخ را SSE می‌فرستد: رویدادهای `chunk`، `done`، `error`. | -### فهرستِ ۵۹ اندپوینت +### فهرستِ اندپوینت‌ها (۶۱ عملیات) عمومی بی‌ورود · ورود توکن لازم · داخلی با هدرِ کلیدِ داخلی (سرور-به-سرور) @@ -451,11 +461,11 @@ with transaction.atomic(): | POST | `/api/auth/refresh` | عمومی | توکنِ تازه با refresh token | | POST | `/api/auth/logout` | ورود | خروج؛ توکن‌های قبلی باطل | | GET · PATCH | `/api/user/profile` | ورود | خواندن و ویرایشِ پروفایل (تاریخِ تولدِ شمسی، شناسه‌ی نمایشی) | -| POST | `/api/user/delete` | ورود | حذفِ حساب؛ شماره‌دار با کدِ پیامکیِ تازه، مهمان با تایپِ «حذف» | +| POST | `/api/user/delete` | ورود | حذفِ حساب با کدِ پیامکیِ تازه | | POST | `/api/user/grade` | ورود | ثبتِ پایه | | **محتوا و آزمون — `content`** | | | | | GET | `/api/grades` | عمومی | فهرستِ پایه‌ها | -| GET | `/api/home` | ورود | نقشه‌ی جزیره‌ها + آمار، در یک درخواست | +| GET | `/api/home` | ورود | نقشه‌ی جزیره‌ها + آمار، در یک درخواست؛ `?subject=` کتاب را انتخاب می‌کند | | GET | `/api/nodes/{id}/lessons` | عمومی | درس‌های یک جزیره | | GET | `/api/nodes/{id}/quiz` | عمومی | آزمونِ ۴ سؤالیِ جزیره | | POST | `/api/nodes/{id}/quiz/complete` | ورود | پایانِ جزیره؛ ستاره، امتیاز، سکه، جزیره‌ی بعد | @@ -463,7 +473,8 @@ with transaction.atomic(): | GET | `/api/lessons/{id}/questions` | عمومی | سؤال‌های یک درس | | POST | `/api/lessons/{id}/complete` | ورود | پایانِ درس | | POST | `/api/questions/{id}/answer` | ورود | ثبتِ جواب | -| POST | `/api/questions/{id}/report` | ورود | گزارشِ ایرادِ سؤال | +| POST | `/api/questions/{id}/report` · `/api/lessons/{id}/report` | ورود | گزارشِ ایرادِ سؤال یا درس | +| POST | `/api/questions/{id}/vote` · `/api/lessons/{id}/vote` | ورود | 👍/👎 روی سؤال یا درس ( `value` ۱، −۱ یا ۰) | | GET | `/api/progress/nodes` | ورود | وضعیتِ خودِ کاربر روی جزیره‌ها | | **پیشرفت و بازی — `progress`** | | | | | GET | `/api/user/summary` | ورود | پروفایل + آمار + زنجیره در یک درخواست | @@ -479,10 +490,10 @@ with transaction.atomic(): | POST | `/api/ai/messages/{id}/feedback` | ورود | 👍/👎 روی یک جواب | | POST | `/api/rag/ingest/text` · `/pdf` | داخلی | افزودنِ متن یا PDF به Qdrant | | GET | `/api/rag/chunks` | داخلی | مرورِ تکه‌های ذخیره‌شده | -| **بسته، پرداخت، تخفیف** | | | | +| **اشتراک، پرداخت، تخفیف** | | | | | GET | `/api/subscription/plans` | عمومی | اشتراک‌های در حالِ فروش | | POST | `/api/subscription/purchase` | ورود | شروعِ خرید؛ سفارش و لینکِ پرداخت | -| GET | `/api/subscription/status` | ورود | اعتبارِ سوال، قلبِ نامحدود تا کِی، اشتراکِ قدیمی (و بازسنجیِ پرداختِ جاافتاده) | +| GET | `/api/subscription/status` | ورود | اشتراکِ فعال و پایانش (و بازسنجیِ پرداختِ جاافتاده و تمدیدِ بازار) | | POST | `/api/payment/bazaar/orders/{id}/start` | ورود | شروعِ خریدِ بازار (SKU و قیمتِ امضاشده) | | GET | `/api/payment/orders/{id}` | ورود | وضعیتِ سفارش | | GET | `/api/payment/invoices` | ورود | پرداخت‌ها و فاکتورهای کاربر (شماره‌ی فاکتور، تخفیف، کدِ پیگیری) | @@ -501,7 +512,7 @@ with transaction.atomic(): | POST | `/api/support/bug-reports` | ورود | گزارشِ باگ | | GET | `/api/user/weekly-report` | ورود | «گزارشِ والدین»: ۷ روزِ اخیر در کنارِ ۷ روزِ قبلش | | **سیستم** | | | | -| GET | `/api/health` | عمومی | سلامتِ سرویس (بخشِ ۱۳) | +| GET | `/api/health` | عمومی (جزئیات با کلیدِ داخلی) | سلامتِ سرویس (بخشِ ۱۳) | ## ۹. دستیارِ هوشِ مصنوعی و داده‌ی Qdrant @@ -513,14 +524,14 @@ Qdrant یک «بانکِ برداری» است: هر تکه‌ی کتاب به | collection (کتاب) | تکه | صفحه‌ی خوانده‌شده | چه کسی ساخت | |---|---|---|---| -| ریاضی هشتم | ۵۱۸ | ۱۵۰ | ما — خواندنِ PDF با مدلِ بینایی | -| ریاضی نهم | ۴۵۰ | ۱۴۳ | ما | -| ریاضی دهم | ۴۵۸ | ۱۷۰ | ما | -| حسابان یازدهم | ۳۷۶ | ۱۵۱ | ما | -| حسابان دوازدهم | ۳۲۶ | ۱۴۶ | ما | +| ریاضی چهارم · پنجم | ۵۹۰ · ۵۹۴ | ۱۵۴ · ۱۹۶ | ما — خواندنِ PDF با مدلِ بینایی | +| ریاضی هشتم · نهم · دهم | ۵۱۸ · ۴۵۰ · ۴۵۸ | ۱۵۰ · ۱۴۳ · ۱۷۰ | ما | +| حسابان یازدهم · دوازدهم | ۳۷۶ · ۳۲۶ | ۱۵۱ · ۱۴۶ | ما | +| هندسه دهم · یازدهم · دوازدهم | ۲۲۹ · ۱۷۱ · ۱۹۲ | ۸۸ · ۷۲ · ۷۸ | ما | +| آمار و احتمال یازدهم · گسسته دوازدهم | ۲۷۱ · ۲۵۴ | ۱۲۱ · ۸۴ | ما | | ریاضی سوم · ششم · هفتم | ۴۳۶ · ۴۳۷ · ۳۷۹ | — | تیمِ دیگرِ شرکت؛ با `rag_import_collection` خوانده شد | | علوم تجربی سوم · مطالعات اجتماعی ششم و چهارم | ۷۰ · ۴۰ · ۲۷ | — | تیمِ دیگر (در اپِ ریاضی استفاده نمی‌شود) | -| جمع | ۳٬۵۱۷ | ۷۶۰ | نشانی: `qdrant.tadrisino2.ir` — سرویسِ مشترک با تیمِ دیگر | +| جمع | ۵٬۸۱۸ | ۱٬۷۵۳ | نشانی: `qdrant.tadrisino2.ir` — سرویسِ مشترک با تیمِ دیگر | #### مشخصاتِ فنی @@ -555,7 +566,7 @@ Qdrant یک «بانکِ برداری» است: هر تکه‌ی کتاب به 5. LessonChunk ردیف در دیتابیس (منبعِ اصلی، با بردار) 6. Qdrant upsert نقطه + payload IngestedPage برای ازسرگیری -قاعده‌ی مهمِ پرامپت ( `rag/services/pdf.py` ): اگر مسئله به «شکلِ زیر» ارجاع می‌دهد، مدل باید اندازه‌ها و برچسب‌های شکل را **داخلِ متن** بنویسد. این قاعده سهمِ مسئله‌های بی‌استفاده‌ی وابسته به شکل را از ~۴۲٪ به ~۱۴٪ رساند. هزینه‌ی خواندنِ هر پنج کتاب در یک روز حدودِ ۳٫۴۴ دلار بود. +قاعده‌ی مهمِ پرامپت ( `rag/services/pdf.py` ): اگر مسئله به «شکلِ زیر» ارجاع می‌دهد، مدل باید اندازه‌ها و برچسب‌های شکل را **داخلِ متن** بنویسد. این قاعده سهمِ مسئله‌های بی‌استفاده‌ی وابسته به شکل را از ~۴۲٪ به ~۱۴٪ رساند. هزینه‌ی خواندنِ پنج کتابِ اول در یک روز حدودِ ۳٫۴۴ دلار بود. ### ۹-۳ کنترلِ کیفیت: راستی‌آزماییِ راه‌حل‌های کتاب @@ -563,10 +574,10 @@ Qdrant یک «بانکِ برداری» است: هر تکه‌ی کتاب به | رأی | تعداد | اثر | |---|---|---| -| agrees راه‌حلِ کتاب درست است | ۲٬۴۶۶ | فقط از این‌ها سؤال ساخته می‌شود | -| disagrees راه‌حلِ کتاب غلط است | ۳۵۴ | راه‌حلش به دستیار داده نمی‌شود؛ فقط روش و صورتِ مسئله | -| unjudgeable بی‌شکل قابلِ قضاوت نیست | ۵۰۸ | کنار می‌ماند | -| بررسی‌نشده | ۱۸۹ | علوم و مطالعاتِ تیمِ دیگر | +| agrees راه‌حلِ کتاب درست است | ۴٬۱۹۱ | فقط از این‌ها سؤال ساخته می‌شود | +| disagrees راه‌حلِ کتاب غلط است | ۶۴۴ | راه‌حلش به دستیار داده نمی‌شود؛ فقط روش و صورتِ مسئله | +| unjudgeable بی‌شکل قابلِ قضاوت نیست | ۷۴۲ | کنار می‌ماند | +| بررسی‌نشده | ۲۴۱ | بیشترش علوم و مطالعاتِ تیمِ دیگر | متنِ اصلیِ کتاب هیچ‌وقت بازنویسی نمی‌شود؛ جوابِ مستقل جدا در `checked_answer` ذخیره می‌شود تا آدم بتواند داوری کند. @@ -591,9 +602,9 @@ Qdrant یک «بانکِ برداری» است: هر تکه‌ی کتاب به |---|---|---| | کدام کتاب؟ | `rag/services/lesson_context.py` | از `lessonId` زنجیره‌ی درس → جزیره → فصل → پایه؛ یا پایه‌ی خودِ کاربر | | جست‌وجو | `retrieval.py` · `qdrant_store.py` · `embedding.py` | متنِ جست‌وجو = سؤال + سؤالِ قبلیِ کاربر (برای «نفهمیدم») | -| پرامپت | `generation.py` | نسخه‌ی `tutor-v10-shape-anchor` ؛ لحنِ سه‌سطحی (دبستان/متوسطه‌ی اول/دبیرستان)؛ متنِ ساده بی‌LaTeX؛ حلِ مرحله‌به‌مرحله با وارسی و «✅ جواب»؛ ۶ پیامِ آخرِ گفتگو | +| پرامپت | `generation.py` | نسخه‌ی `tutor-v12-age-tone` ؛ فقط ریاضی؛ لحنِ سه‌سطحی (دبستان/متوسطه‌ی اول/دبیرستان) و لحنِ بزرگسال برای ۱۸+؛ متنِ ساده بی‌LaTeX؛ حلِ مرحله‌به‌مرحله با وارسی و «✅ جواب»؛ ۶ پیامِ آخرِ گفتگو | | جوابِ درست/غلطِ دانش‌آموز | `generation.py → question_focus` | با `questionId` دستیار صورتِ سؤال و آخرین جوابِ دانش‌آموز را می‌بیند و می‌گوید اشتباه کجا بود | -| تمیزکاری | `mathtext.py` | هرچه LaTeX از دستِ مدل در برود به یونیکد برمی‌گردد ( `x²`، `√3` ) | +| تمیزکاری | `mathtext.py` | هرچه LaTeX از دستِ مدل در برود به یونیکد برمی‌گردد ( `x²`، `√3` ) و اعراب حذف می‌شود | | شکل | `diagram.py` ← فرانت `src/diagram/` | مدل فقط توصیفِ JSON می‌نویسد؛ سرور جدا، تعمیر و با فهرستِ سفید وارسی می‌کند؛ اپ مختصات را حساب و SVG رسم می‌کند. ۱۵ نوع شکل — شرحِ کامل در کادرِ بنفشِ پایین. | | ثبت | `ai_tutor/models.py → Message` | مدل، نسخه‌ی پرامپت، تعدادِ تکه، تأخیر و شکل برای هر جواب ذخیره می‌شود ← کیفیت قابلِ اندازه‌گیری است | @@ -698,7 +709,7 @@ if (Math.sign(y) !== Math.sign(py)) { const r = bisect(f, px, x); if (Math.abs(f | انتخابِ مدلِ جواب: ۱۰۲ مسئله‌ی تصحیح‌شده‌ی دستی | `gpt-4.1-mini` : ۹۵–۹۶ درست · `gpt-4o-mini` : ۸۵–۹۰ | | شکل‌ها: ۶۴ مسئله‌ی تصادفیِ کتاب، وارسیِ چشمیِ تک‌تک | ۴۰ درست، ۱۲ قابلِ قبول، ۷ غلط، ۵ بی‌شکل — حدودِ یکی از پنج هنوز ایراد دارد؛ برای همین شکل همیشه کنارِ حلِ متنی می‌آید | | سرعت | جوابِ کامل با شکل ~۳۰ ثانیه؛ اندازه‌گیری شد که در خودِ GapGPT است («فقط بگو سلام» = ۷ ثانیه) | -| سقف‌ها | ۲ سؤالِ رایگان در روز (مشترک ۴)؛ ۲۰ در ساعت؛ مهمان‌ها بر اساسِ IP (تا با ساختِ حسابِ مهمانِ تازه دور زده نشود) | +| سقف‌ها | ۲ سؤالِ رایگان در روز (مشترک ۴)؛ ۲۰ در ساعت؛ ۳٬۰۰۰ در روز برای کلِ سامانه؛ حداکثر ۱۲ جوابِ هم‌زمان در هر worker (بیشتر از آن ۵۰۳ «دستیار شلوغه» پیش از کم شدنِ سهمیه) | ### ۹-۷ فقط ریاضی، و چه چیزی هزینه دارد @@ -720,12 +731,12 @@ if (Math.sign(y) !== Math.sign(py)) { const r = bisect(f, px, x); if (Math.abs(f | فایل | محتوا | |---|---| -| `content/seed/curriculum.json` | ۸ پایه، ۵۸ فصل، ۲۶۳ جزیره، ۲٬۶۱۷ درس | -| `content/seed/questions.json` | ۲٬۵۴۵ سؤالِ چهارگزینه‌ای | +| `content/seed/curriculum.json` | ۱۰ پایه، ۵ کتاب، ۸۹ فصل، ۱٬۱۴۵ جزیره، ۵٬۴۰۶ درس، با مثال، قدم‌ها و شکلِ درس | +| `content/seed/questions.json` | ۱۸٬۵۲۲ سؤالِ چهارگزینه‌ای، بعضی با شکل | - `seed_content` در هر انتشار اجرا می‌شود؛ **هیچ‌وقت چیزی پاک نمی‌کند** و اجرای دوباره‌اش بی‌خطر است. - سؤال با **مسیرِ عنوان** (پایه › فصل › جزیره › درس) به درس وصل می‌شود، نه با شناسه — چون شناسه‌ها بینِ دو دیتابیس فرق دارند. -- متنی که ادمین در پنل نوشته با انتشارِ بعدی بازنویسی نمی‌شود؛ seed فقط خانه‌ی خالی را پر می‌کند. +- کارِ ادمین با انتشارِ بعدی برنمی‌گردد: دفترِ `SeedRecord` به یاد دارد seed چه چیزی تحویل داده، پس سؤالِ پاک‌شده یا فصلِ تغییرنام‌داده دوباره ساخته نمی‌شود؛ مثال، قدم‌ها و شکل فقط خانه‌ی خالی را پر می‌کنند. ### کارهای روزمره از پنلِ ادمین @@ -736,7 +747,7 @@ if (Math.sign(y) !== Math.sign(py)) { const r = bisect(f, px, x); if (Math.abs(f | خروجیِ سؤال یا برنامه‌ی درسی | انتخاب ← اکشنِ «دریافتِ فایل» (همان قالبی که ورودی می‌پذیرد) | | پایه‌ی تازه | `/admin/content/grade/add/` — عنوانِ «پایه چهارم»، شناسه خودکار ۴ | | پلن، همراه، تیکت، کدِ تخفیف | صفحه‌ی اپِ مربوط در پنل | -| سؤال‌های گزارش‌شده | `QuestionReport` — دانش‌آموز «این سؤال ایراد دارد» زده | +| بازخوردِ دانش‌آموز | ستونِ 👍/👎 در فهرستِ سؤال‌ها و درس‌ها؛ صفِ «گزارش‌های سوال» و «گزارش‌های درس» | ### سؤال‌ها چطور ساخته شدند @@ -748,7 +759,9 @@ if (Math.sign(y) !== Math.sign(py)) { const r = bisect(f, px, x); if (Math.abs(f بازده حدودِ ۸۷–۹۲٪ بود؛ بقیه عمداً رد شدند (بیشتر مسئله‌هایی که بی‌شکلِ کتاب خودکفا نمی‌شوند). همین مسیر با API پولی هم ممکن است ولی اندازه‌گیری‌شده ~۰٫۷۷ سنت برای هر سؤال گران‌تر بود بی‌کیفیتِ بهتر. نویسنده‌های سؤال چند بار خطای حسابِ خودِ کتاب را هم گرفتند. -**پوششِ فعلی:** هر جزیره‌ی هر ۸ پایه دستِ‌کم ۴ سؤال دارد. ۵۹۰ درس و ۵۸ فصل متنِ معرفی و مثال دارند ( `fill_lesson_texts`، بی‌هزینه، هرگز همان مسئله‌ی آزمون). +**جزیره‌های تمرین:** آخرِ هر فصل از پایه‌ی سوم تا دوازدهم یک تا چند جزیره‌ی «تمرین» است (۱۶۰ جزیره، ~۱۳٫۷ هزار سؤال). جوابِ این سؤال‌ها با برنامه **محاسبه** شده، نه حفظ، و گزینه‌های غلط از جنسِ خطای رایجِ دانش‌آموزند. قالبِ سؤال‌های هر خانواده یکی است و فقط عددها عوض می‌شوند؛ بازبینیِ معلم هنوز انجام نشده است. + +**شکلِ درس:** درس‌های هندسه، آمار و گسسته شکلِ ثابت دارند ( `Lesson.figures` )، دستی نوشته و با چشم بازبینی‌شده؛ هنگامِ نمایش هیچ مدلی صدا زده نمی‌شود. ## ۱۱. گیمیفیکیشن و همراه‌ها @@ -776,10 +789,11 @@ if (Math.sign(y) !== Math.sign(py)) { const r = bisect(f, px, x); if (Math.abs(f | امتیاز (XP) | پایانِ درس یا جزیره: **۱۰ × جوابِ درست**، فقط بارِ اول. عددِ جوابِ درستی که اپ می‌فرستد به جواب‌های درستِ **ثبت‌شده** محدود می‌شود؛ صدا زدنِ مستقیمِ API امتیازِ مجانی نمی‌دهد. | `content/views.py → NodeQuizCompleteView` | | ستاره | ۹۰٪ به بالا ۳ · ۶۰٪ به بالا ۲ · بیشتر از صفر ۱ | همان | | سکه | **۵ × ستاره**، فقط بارِ اول | همان | -| قلب | ۵ قلب؛ هر جوابِ غلط یکی کم؛ هر **۵ ساعت** یکی برمی‌گردد و **۲۴ ساعت** بعد از اولین قلبِ ازدست‌رفته همه پر می‌شوند. با قلبِ صفر نه جوابی پذیرفته می‌شود نه پایانِ درس یا جزیره‌ی بی‌جواب ( `403 no_hearts` ). اشتراکِ فعال یا روزهای قلبِ بسته = قلبِ نامحدود. | `progress/models.py → UserHearts` · `content/views.py` | +| قلب | ۵ قلب؛ هر جوابِ غلط یکی کم؛ هر **۵ ساعت** یکی برمی‌گردد و **۲۴ ساعت** بعد از اولین قلبِ ازدست‌رفته همه پر می‌شوند. با قلبِ صفر نه جوابی پذیرفته می‌شود نه پایانِ درس یا جزیره‌ی بی‌جواب ( `403 no_hearts` ). اشتراکِ فعال = قلبِ نامحدود. | `progress/models.py → UserHearts` · `content/views.py` | | زنجیره (Streak) | جواب، پایانِ درس یا جزیره امروز را (به وقتِ تهران) روزِ فعال می‌کند. اگر آخرین روزِ فعال نه امروز باشد نه دیروز، صفر نشان داده می‌شود. | `progress/services.py` | | جدولِ رتبه | هفتگی (امتیازِ ۷ روزِ اخیر) و همه‌ی زمان‌ها؛ زنده حساب و ۶۰ ثانیه کش می‌شود. | `progress/leaderboard.py` | -| پیشرفت روی نقشه | پایانِ آزمون، جزیره را با هر نمره‌ای تمام می‌کند و جزیره‌ی بعدی ( `nextNodeId` ) باز می‌شود؛ نمره فقط ستاره و امتیاز را تعیین می‌کند (تصمیمِ محصولی: ساده بماند). | `content/services.py` | +| پیشرفت روی نقشه | پایانِ آزمون، جزیره را با هر نمره‌ای تمام می‌کند و جزیره‌ی بعدی ( `nextNodeId` ) باز می‌شود؛ نمره فقط ستاره و امتیاز را تعیین می‌کند (تصمیمِ محصولی: ساده بماند). جزیره‌ی قفل ( `403 node_locked` ) و آزمونِ جواب‌نداده ( `409 answers_missing` ) پذیرفته نمی‌شوند. آزمونِ هر جزیره برای هر دانش‌آموز ثابت است. | `content/views.py` | +| دیوارِ پرداخت | فصلِ اولِ هر کتاب رایگان؛ بقیه با اشتراکِ فعال ( `402 subscription_required` ). | `content/views.py → unit_is_free` | | اعلان | «جزیره را با ۳ ستاره تمام کردی و ۴۰ امتیاز گرفتی» — با رقمِ فارسی. | `notifications/services.py` | > **قلب واقعی است** ( `HEARTS_ENFORCED` پیش‌فرض روشن): قلبِ صفر بازی را متوقف می‌کند تا قلبِ بعدی برگردد. چکِ قلب داخلِ همان تراکنشی است که قلب را کم می‌کند، پس دو جوابِ هم‌زمان با یک قلبِ باقی‌مانده هر دو پذیرفته نمی‌شوند؛ و پایانِ آزمون بی‌جواب دادن هم بسته است. کسی که آخرین قلبش را روی آخرین سوال از دست داده، جزیره را تمام می‌کند. تست‌ها: `content/tests_hearts_gate.py` . @@ -790,7 +804,7 @@ if (Math.sign(y) !== Math.sign(py)) { const r = bisect(f, px, x); if (Math.abs(f ## ۱۲. امنیت و احرازِ هویت -سه دور بازبینیِ بدبینانه روی کلِ بک‌اند انجام شد (مرداد، ۳ مهر و ۱۰ مهر)، و یک وارسیِ نهایی روی سایتِ زنده (۷ مهر) با این سؤال: «یک دانش‌آموزِ متقلب، یک مهاجم یا یک والدِ سردرگم چطور این را می‌شکند؟» هر یافته با یک تست بسته شد. این بخش صادقانه می‌گوید چه بسته است و چه هنوز باز. +چند دور بازبینیِ بدبینانه روی کلِ بک‌اند انجام شد (مرداد، ۳، ۱۰ و ۲۱ مهر)، با وارسی روی سایت و سرورِ زنده، با این سؤال: «یک دانش‌آموزِ متقلب، یک مهاجم یا یک والدِ سردرگم چطور این را می‌شکند؟» هر یافته با یک تست بسته شد. این بخش صادقانه می‌گوید چه بسته است و چه هنوز باز. ### ۱۲-۱ هویت و توکن @@ -806,36 +820,33 @@ if (Math.sign(y) !== Math.sign(py)) { const r = bisect(f, px, x); if (Math.abs(f | حوزه | چه کرده‌ایم | کجا | |---|---|---| | دسترسی به داده‌ی دیگران | شناسه‌ی کاربر همیشه از توکن خوانده می‌شود، هرگز از ورودی؛ هر کاربر فقط داده‌ی خودش را می‌بیند (گزارشِ والدین هم در حسابِ خودِ دانش‌آموز است). | همه‌ی `views.py` ها | -| سقفِ نرخ | روی همه‌ی نقاطِ حساس (ورود، دستیار، خرید، کدِ تخفیف، جواب…)؛ مهمان بر اساسِ IPِ واقعی ( `NUM_PROXIES=2` : آروان + nginx). | `config/settings.py` | -| پیامک و هزینه‌اش | شماره به `09XXXXXXXXX` نرمال می‌شود؛ فاصله بینِ دو درخواست، ۱۰ در روز برای هر شماره، ۳۰۰ در روز برای کلِ سامانه (ضدِ SMS pumping) — شمارش در دیتابیس، نه حافظه‌ی موقت. | `accounts/phone.py` · `tests_otp_limits.py` | +| سقفِ نرخ | روی همه‌ی نقاطِ حساس (ورود، دستیار، خرید، کدِ تخفیف، جواب…)؛ درخواستِ بی‌ورود بر اساسِ IPِ واقعی، که فقط از پروکسی‌های آروان پذیرفته می‌شود (جعلِ `X-Forwarded-For` اثری ندارد). nginx هر درخواستِ نرسیده از آروان را رد می‌کند. | `config/middleware.py → TrustedProxyMiddleware` · `ops/nginx/` | +| پیامک و هزینه‌اش | شماره به `09XXXXXXXXX` نرمال می‌شود؛ فاصله بینِ دو درخواست، ۱۰ در روز برای هر شماره، سقفِ روزانه برای کلِ سامانه (روی سرور ۲٬۰۰۰، ضدِ SMS pumping) — شمارش در دیتابیس، نه حافظه‌ی موقت. | `accounts/phone.py` · `tests_otp_limits.py` | | پرداخت | فقط تأییدِ سرور-به-سرور؛ برگشتِ جعلی اثری ندارد؛ webhookِ عمومیِ ناامن حذف شد؛ هر درگاهی بی‌کلید «بسته» می‌ماند (fail-closed)، نه موفقیتِ ساختگی. هر سفارش فقط یک بار روز می‌دهد (قیدِ یکتا روی تراکنش)؛ وضعیتِ سفارش در ادمین فقط با actionِ لغو/بازپرداخت عوض می‌شود. | `payment/` · `subscription/` | | تقلبِ امتیاز | امتیاز فقط برای جوابِ ثبت‌شده؛ جایزه فقط بارِ اول. | `content/views.py` | -| درخواستِ تکراری و هم‌زمانی | Idempotency-Key در شش نقطه؛ `transaction.atomic` ؛ قیدِ یکتا در دیتابیس؛ SQLite در حالتِ `IMMEDIATE` تا قفل‌ها واقعاً قفل کنند. | `config/settings.py` | +| درخواستِ تکراری و هم‌زمانی | Idempotency-Key در شش نقطه؛ `transaction.atomic` ؛ قیدِ یکتا در دیتابیس؛ قفلِ ردیفیِ Postgres. | `config/settings.py` | | خطا و لاگ | هر خطا JSONِ یکدست بی‌جزئیاتِ داخلی؛ لاگ بدونِ بدنه، هدر، توکن یا رمز. | `config/exceptions.py` · `middleware.py` | | محتوای مدل | HTML یا SVGِ تولیدِ مدل هرگز واردِ صفحه نمی‌شود؛ فقط JSONِ وارسی‌شده با فهرستِ سفید. دستیار فقط ریاضی (بخشِ ۹-۷). | `rag/services/diagram.py` · `guardrails.py` | -| حذفِ حساب | شماره‌دار با کدِ پیامکیِ تازه (همان سقفِ حدسِ ورود)، مهمان با تایپِ «حذف»؛ حسابِ نمایشی حذف نمی‌شود؛ سفارش‌ها برای حسابداری می‌مانند؛ توکنِ قبلی بعد از حذف کار نمی‌کند. | `accounts/views.py → DeleteAccountView` | -| مرورگر | CORS فقط برای دامنه‌ی فرانت؛ پاسخِ API با `no-store` در کشِ آروان نمی‌ماند؛ نامِ نمایشی از شماره و لینک پاک می‌شود؛ سقفِ حدسِ رمزِ ادمین برای هر IP. | `config/middleware.py` | -| کلیدها | همه در `.env` ِ سرور؛ هیچ کلیدی در git یا در پاسخِ API نیست؛ `/api/health` فقط «بله/خیر» می‌گوید. | `.env.example` | +| حذفِ حساب | با کدِ پیامکیِ تازه (همان سقفِ حدسِ ورود)؛ سفارش‌ها برای حسابداری می‌مانند؛ توکنِ قبلی بعد از حذف کار نمی‌کند. | `accounts/views.py → DeleteAccountView` | +| مرورگر | CORS فقط برای دامنه‌ی فرانت؛ CSP و هدرهای امنیتی روی سایتِ فرانت؛ پاسخِ API با `no-store` در کشِ آروان نمی‌ماند؛ نامِ نمایشی از شماره و لینک پاک می‌شود؛ سقفِ حدسِ رمزِ ادمین برای هر IP. | `config/middleware.py` · `ops/nginx/` | +| کلیدها | همه در `.env` ِ سرور؛ کلیدِ امضای توکن روی خودِ سرور ساخته شده و در هیچ مخزنی نیست؛ `/api/health` بی‌کلیدِ داخلی فقط «ok/error» می‌گوید و Swagger فقط برای ادمینِ واردشده باز است. هیچ ورودِ آزمایشی با کدِ ثابت روی سرور نیست. | `.env.example` | -### ۱۲-۳ آنچه عمداً باز مانده (پیش از کاربرِ واقعی باید بسته شود) +### ۱۲-۳ آنچه هنوز باز است (پیش از کاربرِ عمومی باید بسته شود) -این موارد در مرحله‌ی نمایشی (دمو) برای کارِ تیم عمداً باز گذاشته شده‌اند؛ فهرستِ کامل با خطر و راهِ بستنِ هرکدام در `docs/security-before-production.md` است (۱۸ مورد). +فهرستِ کامل با خطر و راهِ بستنِ هرکدام در `docs/security-before-production.md` است؛ مهم‌ترین‌ها: -| # | مورد | خطر | راهِ بستن | -|---|---|---|---| -| ۱ | کلیدِ امضای توکن روی سرور همان کلیدِ پیش‌فرضِ داخلِ مخزن است؛ هر کس مخزن را ببیند می‌تواند توکن جعل کند. | بحرانی | حذف از `.env` تا تنظیمات خودش کلیدِ تصادفی بسازد (کد آماده است)؛ همه یک بار دوباره وارد می‌شوند. | -| ۲ | ۷ شماره‌ی دمو با کدِ ثابت روی سرورِ واقعی | بالا | پاک کردنِ `TEST_LOGIN_PHONES` | -| ۳ | داده و حساب‌های دمو و آزمایشی در دیتابیسِ واقعی | بالا | پاک‌سازی پس از بکاپ | -| ۴ | دسترسیِ سرور: حسابِ مشترک با رمز و sudoِ کامل | بالا | کلیدِ SSH و حسابِ محدود — کارِ ادمینِ سرور | -| ۵ | `/api/health`، Swagger و جدولِ رتبه بی‌ورود عمومی‌اند؛ پنلِ ادمین روی دامنه‌ی عمومی | متوسط | محدود کردن با ورود یا IP | -| ۶ | سرور بی‌آروان هم روی پورتِ ۸۰ جواب می‌دهد (امکانِ جعلِ IP برای دور زدنِ سقف) | متوسط | بستنِ پورت جز برای آروان — ادمینِ سرور | -| ۷ | کشِ مشترک (Redis) تنظیم نشده؛ سقف‌های نرخ برای هر worker جداست | متوسط | Redisِ اختصاصیِ خودمان (به Redisِ مشترکِ ماشین وصل نمی‌شویم؛ تصمیمِ باز) | -| ۸ | کدِ پیامکی و refresh token بی‌هش در دیتابیس؛ توکن در `localStorage` ِ مرورگر | کم | هش کردن؛ کوکیِ httpOnly | -| ۹ | آزمونِ جزیره قفل بودنِ جزیره را نمی‌سنجد (می‌شود جزیره‌ی جلوتر را با API تمام کرد) | کم | بررسیِ جزیره‌ی قبلی در `NodeQuizCompleteView` | +| مورد | خطر | راهِ بستن | +|---|---|---| +| سقفِ روزانه‌ی پیامکِ کلِ سامانه را یک ناشناس با شماره‌های تصادفی پر می‌کند و ورود برای همه بسته می‌شود | بالا | CAPTCHA یا مانعِ مشابه پس از یک حد؛ شمارش بر اساسِ شبکه — تصمیمِ طراحی | +| دسترسیِ سرور: حسابِ مشترک با رمز و sudoِ کامل | بالا | کلیدِ SSH و حسابِ محدود — کارِ ادمینِ سرور | +| پنلِ `/admin/` روی دامنه‌ی عمومی (با سقفِ تلاش، بی ۲FA) | متوسط | محدود به IP یا VPN، ۲FA | +| با دانستنِ شماره‌ی کسی، ورودِ او تا ۲۴ ساعت بسته می‌شود | متوسط | گره زدنِ کد به درخواست‌کننده — تصمیمِ طراحی | +| کدِ پیامکی بی‌هش در دیتابیس؛ توکن در `localStorage` ِ مرورگر | کم | هش کردن؛ کوکیِ httpOnly | +| کلیدِ جوابِ هر سوال با حسابِ دوم درمی‌آید | کم | گزینه‌ی درست فقط پس از پایانِ آزمون — تصمیمِ محصولی | > #### جمع‌بندیِ وضعیتِ امنیت > -> لایه‌ی برنامه بازبینی و تست شده است: دسترسی، سقفِ نرخ، پرداخت، تکرارِ درخواست و تقلبِ امتیاز بسته‌اند. چیزهایی که باز مانده عمدی و مستند است — مهم‌ترینِ آن‌ها کلیدِ امضای سرور است که کدِ رفعش آماده است و اعمالش به زمانی موکول شده که خروجِ همه‌ی کاربرها از حساب مشکلی ایجاد نکند. بخشی از موارد نیز به زیرساخت مربوط است و بر عهده‌ی ادمینِ سرور است. +> لایه‌ی برنامه بازبینی و تست شده است: دسترسی، سقفِ نرخ، جعلِ IP، پرداخت، تکرارِ درخواست و تقلبِ امتیاز بسته‌اند؛ کلیدِ امضا عوض شده، حساب‌ها و ورودهای نمایشی پاک شده‌اند و nginx فقط از آروان می‌پذیرد. آنچه مانده بیشتر تصمیمِ طراحی (پیامک و ورود) یا کارِ ادمینِ سرور است. ## ۱۳. استقرار (Deploy) و نگهداری @@ -850,7 +861,7 @@ ssh -t administrator@ 'cd ~/mathapp-v2 && ./deploy.sh; sudo systemctl re 1. ۱ · fetch از Gitea 2. ۲ · reset تغییرِ دستی ← patch در .deploy-backups/ 3. ۳ · pip install -4. ۴ · migrate +4. ۴ · بکاپِ دیتابیس (pg_dump) و migrate 5. ۵ · seed_content محتوا 6. ۶ · collectstatic 7. ۷ · restart + اثباتِ ری‌استارت @@ -874,11 +885,12 @@ fetch ← `npm ci` ← بررسیِ نوع‌ها ← build در `dist.next` ← |---|---| | `PUBLIC_BASE_URL` · `FRONTEND_URL` | دامنه‌های فعلی. همه‌ی تنظیماتِ دامنه (CORS، callbackِ زرین‌پال، …) از همین دو ساخته می‌شوند؛ جابه‌جاییِ دامنه یعنی عوض کردنِ همین دو. | | `DJANGO_SECRET_KEY` | یک کلیدِ تصادفی ساخته و در `.django_secret_key` نگه داشته می‌شود (حالتِ مطلوب). | -| `OPENAI_API_KEY` · `QDRANT_API_KEY` | دستیار جوابِ ثابتِ نمایشی می‌دهد. | +| `OPENAI_API_KEY` · `QDRANT_API_KEY` | دستیار ۵۰۳ صریح می‌دهد؛ جوابِ نمایشی فقط در حالتِ توسعه. | | `KAVENEGAR_API_KEY` · `KAVENEGAR_OTP_TEMPLATE` | درخواستِ کد ۵۰۳ صریح می‌گیرد. الگو باید `SMSVerify` بماند. | | `ZARINPAL_MERCHANT_ID` · `BAZAAR_*` | پرداخت بسته می‌ماند (۵۰۳). | -| `CACHE_URL` | کشِ جدا برای هر worker؛ `/api/health` هشدار می‌دهد. | -| `HEARTS_ENFORCED` · `TEST_LOGIN_PHONES` | روشن (قلب واقعی؛ `0` خاموشش می‌کند) · هیچ حسابِ آزمایشی. | +| `CACHE_URL` | روی سرور به Redisِ اختصاصیِ خودمان اشاره می‌کند. بی آن کش برای هر worker جداست و `/api/health` هشدار می‌دهد. | +| `DB_NAME` · `DB_USER` · `DB_PASSWORD` · `DB_HOST` · `DB_PORT` · `DB_POOL_MAX_SIZE` | Postgres و اندازه‌ی poolِ هر worker. بی `DB_NAME` برنامه SQLite را برمی‌دارد (فقط برای محلی). | +| `HEARTS_ENFORCED` · `TEST_LOGIN_PHONES` | روشن (قلب واقعی؛ `0` خاموشش می‌کند) · خالی؛ ورودِ آزمایشی فقط با کدِ یک‌بارمصرفی که ابرکاربر در پنل می‌سازد. | | `AI_DAILY_QUOTA_FREE` | ۲ سوالِ رایگان در روز (پیش‌فرض). | | `DJANGO_DEBUG` | خاموش — و **هرگز روی سرور روشن نشود** (کدِ ورودِ همگانیِ `0000` را فعال می‌کند). | @@ -888,18 +900,20 @@ fetch ← `npm ci` ← بررسیِ نوع‌ها ← build در `dist.next` ← > #### `GET /api/health` > -> بی‌ورود و بی‌SSH می‌گوید: دیتابیس سالم است؟ migrationِ جامانده هست؟ کش کار می‌کند؟ چند پایه/درس/سؤال؟ کدام سرویسِ بیرونی تنظیم است؟ اعتبارِ دستیار تمام شده ( `aiProvider` )؟ هشدارهای امنیتی و ترافیکِ اخیر. ۲۰۰ = سالم، ۵۰۳ = خرابیِ واقعی. +> بی‌ورود فقط «ok/error» می‌گوید. با هدرِ `X-Internal-Key`: دیتابیس سالم است؟ migrationِ جامانده هست؟ کش و poolِ اتصال؟ کپیِ زنده‌ی بکاپ عقب نیست؟ کدام سرویسِ بیرونی تنظیم است و اعتبارِ دستیار تمام شده ( `aiProvider` )؟ هشدارهای امنیتی و ترافیکِ اخیر. ۲۰۰ = سالم، ۵۰۳ = خرابیِ واقعی. > #### بکاپ > -> دیتابیس در git نیست و تنها نسخه‌ی داده‌ی کاربرهاست. بکاپ با `sqlite3.Connection.backup()` ِ پایتون (نه `cp` ) پیش از هر انتشارِ مهم، در `.deploy-backups/`. هنوز خودکار نیست — ارزشمندترین کارِ بعدی. +> هر تغییرِ دیتابیس لحظه‌ای به سرورِ دوم (۲۴۷) می‌رود ( `pg_receivewal` از راهِ تونلِ محدود)، با یک نسخه‌ی کاملِ روزانه؛ بازیابی تا هر لحظه از ۷ روزِ اخیر ممکن است و آزموده شده. `deploy.sh` هم پیش از هر `migrate` یک `pg_dump` می‌گیرد. دفترِ رویدادهای خرید هر ۶ ساعت به CSV و لاگِ برنامه هر شب فشرده و برای همیشه روی ۲۴۷ نگه داشته می‌شود. رویه: `docs/operations.md`. > #### سرور مالِ ما نیست؛ ما یکی از مستأجرهاییم > -> - مالِ ما فقط `/home/administrator/mathapp-v2` و سرویسِ `zareian-mathapp` است. `gunicorn` ِ خالی سرویسِ تیمِ دیگری است. -> - هرگز `git clean` یا «پاک کن و از نو clone کن»: `db.sqlite3`، `.env` و کلیدِ امضا در همان پوشه‌اند و در git نیستند. -> - پیش از هر کارِ پرحجم `df -h /` : دیسک حدودِ ۹۵٪ پر است و پر شدنش Gitea و ایمیلِ شرکت را هم می‌خواباند. +> - مالِ ما فقط پوشه‌های `~/mathapp-v2` و `~/mathapp_frontend_new`، سرویس‌های `zareian-*` و clusterِ Postgresِ `zareian` است. `gunicorn` ِ خالی سرویسِ تیمِ دیگری است. +> - هرگز `git clean` یا «پاک کن و از نو clone کن»: `.env`، کلیدِ امضا، لاگ‌ها و بکاپ‌ها در همان پوشه‌اند و در git نیستند. +> - nginx مشترک است: `nginx -t` و بعد `reload`، هرگز `restart`. +> - پیش از هر کارِ پرحجم `df -h /` : پر شدنِ دیسک Gitea و ایمیلِ شرکت را هم می‌خواباند. > - `.env` فقط از راهِ جنگو خوانده شود؛ مقدارش هرگز در چت یا گزارش نیاید. +> - قواعدِ کامل برای هر کسی (یا agentی) که روی سرور کار می‌کند: `CLAUDE.md` ، بخشِ «Working on the servers». ## ۱۴. مقیاس‌پذیری و پیوستنِ توسعه‌دهنده‌ی تازه @@ -912,7 +926,7 @@ fetch ← `npm ci` ← بررسیِ نوع‌ها ← build در `dist.next` ← | ۱۴ اپ با مرزِ روشن | هر نفر صاحبِ چند اپ می‌شود (مثلاً یکی پرداخت و اشتراک، یکی محتوا و گیمیفیکیشن، یکی دستیار)؛ تغییرِ هر کس کمتر به فایلِ دیگری می‌خورد. | | بی‌FK بینِ اپ‌ها | تغییرِ جدول‌های یک اپ migrationِ اپِ دیگر را نمی‌شکند؛ هر اپ بعداً قابلِ جدا شدن به سرویسِ مستقل است. | | قراردادِ OpenAPI | فرانت و بک‌اند بی‌جلسه روی یک شکلِ داده توافق دارند؛ `tests_frontend_contract.py` تغییرِ ناخواسته را می‌گیرد. | -| ۷۷۹ تستِ خودکار | تازه‌وارد بی‌ترس تغییر می‌دهد: اگر چیزی را شکست، تست قبل از سرور خبر می‌دهد. | +| ۹۷۳ تستِ خودکار | تازه‌وارد بی‌ترس تغییر می‌دهد: اگر چیزی را شکست، تست قبل از سرور خبر می‌دهد. | | قواعدِ یک‌جا | نرخ‌ها، دامنه‌ها و تنظیمات فقط در `config/settings.py` ؛ پیامِ خطا، لاگ و هویت یک مسیرِ مشترک دارند. | | مستندات در مخزن | `docs/` + همین سند + Swagger + توضیحِ «چرا» کنارِ کدِ حساس. | @@ -929,25 +943,26 @@ fetch ← `npm ci` ← بررسیِ نوع‌ها ← build در `dist.next` ← ### ۱۴-۳ مقیاسِ اجرا: کجا گلوگاه است و بعد چه -وضعِ فعلی: ۳ worker × ۳۲ نخ = ۹۶ درخواستِ هم‌زمان؛ درخواستِ عادی چند ده میلی‌ثانیه، جوابِ دستیار روی سرور میانه‌ی ۵ تا ۸ ثانیه و گاهی ۴۰ ثانیه، که تمامش یک نخ را نگه می‌دارد. در لودتست تا ۷۵ پرسشِ هم‌زمان صفحه‌های عادی صف نکشیدند و از ۱۰۰ به بعد کشیدند. توکن stateless است، پس هر تعداد نسخه‌ی دیگر از بک‌اند پشتِ nginx بی‌تغییرِ کد کار می‌کند — به شرطِ دیتابیس و کشِ مشترک. +وضعِ فعلی: ۴ worker × ۳۲ نخ؛ هر worker حداکثر ۸ اتصالِ Postgres از pool قرض می‌گیرد و حداکثر ۱۲ نخ به دستیار می‌دهد، تا رگبارِ سوال نقشه و آزمون را نخواباند. توکن stateless است، پس هر تعداد نسخه‌ی دیگر از بک‌اند پشتِ nginx بی‌تغییرِ کد کار می‌کند. + +**آزمونِ بار روی خودِ سرور** (بازیکنانِ شبیه‌سازی‌شده که هر ۱ تا ۵ ثانیه یک درخواست می‌زنند): ۳۰۰ نفر ۸۸ درخواست در ثانیه با میانه‌ی ۰٫۱ ثانیه؛ ۶۰۰ نفر ۱۳۷ درخواست با میانه‌ی ۱٫۴ ثانیه و بی‌خطا؛ ۱٬۰۰۰ نفر به سقفِ ~۱۳۵ درخواست در ثانیه رسید (صدکِ ۹۵ ۱۵ ثانیه). گلوگاه CPUِ ماشینِ مشترک است، نه دیتابیس: دو سرویسِ خرابِ تیم‌های دیگر مدام ری‌استارت می‌شوند و حدودِ ۳ از ۸ هسته را می‌گیرند. | # | گلوگاه | راه‌حلِ ساده‌ی بعدی | نیاز | |---|---|---|---| -| ۱ | SQLite فقط یک نویسنده دارد | Postgres (همین حالا روی سرور هست)؛ تغییرِ تنظیمات + انتقالِ داده؛ حالتِ `IMMEDIATE` برداشته می‌شود و قفلِ ردیف واقعی می‌شود. | ادمینِ سرور | -| ۲ | کش و سقفِ نرخِ جدا برای هر worker | Redisِ اختصاصیِ خودمان، بعد `CACHE_URL`. Redisِ موجودِ ماشین مالِ دیگران است و وصل نمی‌شویم. | تصمیمِ باز + ساختِ Redis | -| ۳ | جوابِ دستیار نخ را نگه می‌دارد | صفِ کار (مثلاً Celery، که RabbitMQ و Redis‌اش روی سرور هست) یا ویوی async برای `/api/ai/ask`. | کد | -| ۴ | یک سرور برای همه | چند نسخه‌ی gunicorn پشتِ nginx؛ فرانت از CDNِ آروان. | زیرساخت | -| ۵ | هزینه‌ی دستیار با کاربرِ زیاد | کش، سقفِ روزانه (هست)، و سنجشِ مدلِ ارزان‌تر روی همان ابزارِ `rag_model_benchmark`. | سنجش | +| ۱ | CPUِ ماشینِ مشترک | رفعِ دو سرویسِ خرابِ دیگران، هسته‌ی بیشتر و ۸ worker (برآورد ~۲۷۰ درخواست در ثانیه) | ادمینِ سرور | +| ۲ | جوابِ دستیار نخ را نگه می‌دارد | صفِ کار یا ویوی async برای `/api/ai/ask` | کد | +| ۳ | یک سرور برای همه | چند نسخه‌ی gunicorn پشتِ nginx یا سرورِ دوم؛ فرانت از CDNِ آروان | زیرساخت | +| ۴ | هزینه‌ی دستیار با کاربرِ زیاد | کش، سقفِ روزانه (هست)، و سنجشِ مدلِ ارزان‌تر با `rag_model_benchmark` | سنجش | -بهینه‌سازی‌های انجام‌شده: رفعِ N+1 در صفحه‌ی اصلی (از ۱+N کوئری به ۳ کوئریِ ثابت)، کشِ نقشه، کشِ جدولِ رتبه (۶۰ ثانیه)، کشِ جوابِ دستیار، ادغامِ چند درخواست در یکی ( `/api/home`، `/api/user/summary`، `/api/nodes/{id}/quiz` )، و حجمِ بارِ اولِ فرانت از ۵۰۵ به ۸۵ کیلوبایت. +بهینه‌سازی‌های انجام‌شده: رفعِ N+1 در صفحه‌ی اصلی، کشِ نقشه و جدولِ رتبه، کشِ جوابِ دستیار، poolِ اتصال (پیش از آن ۱۰۰ کاربرِ هم‌زمان ۲۱٪ خطای ۵۰۰ می‌گرفتند؛ بعد صفر)، ادغامِ چند درخواست در یکی ( `/api/home`، `/api/user/summary`، `/api/nodes/{id}/quiz` )، و حجمِ بارِ اولِ فرانت از ۵۰۵ به ۸۵ کیلوبایت. ## ۱۵. تست و کیفیت | چه | چطور | |---|---| -| تستِ خودکارِ بک‌اند | **۷۷۹ تست**، همه سبز ( `python manage.py test` ). روی فایلِ دیتابیسِ واقعی ( `.test-db.sqlite3` ) اجرا می‌شوند تا رفتارِ قفل‌ها مثلِ سرور باشد. هر باگِ پیداشده در بازبینی‌ها یک تستِ اختصاصی دارد ( `tests_audit_fixes.py`، `tests_otp_limits.py`، `tests_hearts_gate.py`، …). هم‌زمانی با دو thread واقعی آزموده می‌شود (اعتبارِ دستیار، آخرین قلب). | +| تستِ خودکارِ بک‌اند | **۹۷۳ تست**، همه سبز ( `python manage.py test --noinput` ). روی فایلِ دیتابیس ( `.test-db.sqlite3` ) اجرا می‌شوند و روی Postgres هم سبزند. هر باگِ پیداشده در بازبینی‌ها یک تستِ اختصاصی دارد ( `tests_audit_fixes.py`، `tests_otp_limits.py`، `tests_hearts_gate.py`، …). هم‌زمانی با دو thread واقعی آزموده می‌شود (اعتبارِ دستیار، آخرین قلب). | | قرارداد با فرانت | `content/tests_frontend_contract.py` و build با TypeScriptِ strict در فرانت. | -| تستِ روی سایتِ زنده | هر صفحه با حساب‌های نمایشی روی سرورِ واقعی؛ مرورگرِ خودکار (puppeteer) برای وارسیِ صفحه‌ها. | +| تستِ روی سایتِ زنده | با حساب‌های آزمونگر روی سرورِ واقعی؛ مرورگرِ خودکار (puppeteer) برای وارسیِ صفحه‌ها؛ آزمونِ بار با `tools/loadtest/`. | | کیفیتِ دستیار | `rag_model_benchmark` (مسئله‌های واقعیِ کتاب از مسیرِ واقعی، با هزینه‌ی واقعی)، `ai_feedback_report` (👍/👎 به تفکیکِ نسخه‌ی پرامپت)، وارسیِ چشمیِ نمونه پیش از اعتماد به هر اجرای انبوه. | | بی‌هزینه آزمودن | `tools/fake_openai.py` جای GapGPT می‌نشیند. | | پایش | لاگِ JSONِ هر درخواست + `/api/health` ؛ محلی با Grafana/Loki ( `docs/docker.md` ). | @@ -956,40 +971,40 @@ fetch ← `npm ci` ← بررسیِ نوع‌ها ← build در `dist.next` ← ## ۱۶. وضعیتِ فعلی، محدودیت‌ها و تصمیم‌های باز -### وضعیت در تاریخِ نگارشِ سند (۱۰ مهر ۱۴۰۵، روی سرورِ زنده) +### وضعیت در تاریخِ نگارشِ سند (۲۲ مهر ۱۴۰۵، روی سرورِ زنده) | بخش | وضعیت | توضیح | |---|---|---| -| اپ و API | زنده | هر دو روی آخرین نسخه؛ همه‌ی صفحه‌ها با حساب‌های نمایشی وارسی شد | -| محتوا | کامل | ۸ پایه؛ هیچ جزیره‌ای زیرِ ۴ سؤال نیست | -| پیامکِ ورود | کار می‌کند | کدِ واقعی به گوشی رسید (الگوی SMSVerify)؛ هر پیامک هزینه دارد | -| دستیار + شکل | کار می‌کند | کُند (~۳۰ ثانیه، از سمتِ GapGPT)؛ ~یک شکل از پنج ایراد دارد | +| اپ و API | زنده | با حساب‌های آزمونگر وارسی می‌شود؛ هنوز به روی کاربرِ عمومی باز نشده | +| محتوا | کامل برای سوم تا دوازدهم | ۱۵ کتاب؛ همه‌ی جزیره‌ها سؤال دارند؛ جزیره‌های تمرینِ آخرِ هر فصل | +| پیامکِ ورود | کار می‌کند | الگوی SMSVerify؛ هر پیامک هزینه دارد؛ سقفِ روزانه‌ی کلِ سامانه ۲٬۰۰۰ | +| دستیار + شکل | کار می‌کند | فقط ریاضی؛ ~یک شکل از پنج ایراد دارد | | زرین‌پال | نیمه | درخواستِ پرداخت پذیرفته می‌شود؛ پرداختِ واقعی تا انتها آزموده نشده | -| کافه‌بازار | منتظرِ اپ | اتصالِ API برقرار؛ سه اشتراک در پیشخان (۱۰۳۴۲–۱۰۳۴۴) و روی پلن‌ها؛ منتظرِ ساختِ اپِ اندروید و یک خریدِ واقعی | -| اشتراک و قلب | زنده | سه اشتراک روی سایت؛ قلبِ صفر بازی را متوقف می‌کند | -| پیامِ گروهی | پایه | ذخیره و هدف‌گیری هست؛ زمان‌بندیِ خودکار نه | -| بکاپِ خودکار | ندارد | دستی پیش از هر انتشار | +| کافه‌بازار | منتظرِ APKِ تازه | سه اشتراک (۱۰۳۴۲–۱۰۳۴۴) روی پلن‌ها؛ کدِ اپ آماده ولی build و خریدِ واقعی انجام نشده | +| اشتراک و قلب | زنده | فصلِ اولِ هر کتاب رایگان؛ قلبِ صفر بازی را متوقف می‌کند | +| بکاپ | خودکار | کپیِ زنده روی سرورِ دوم، بازیابی آزموده شده | +| ظرفیت | سنجیده | ۶۰۰ بازیکنِ هم‌زمان بی‌خطا؛ سقف CPUِ ماشینِ مشترک | +| پیامِ گروهی | پایه | ذخیره و هدف‌گیری هست؛ ارسالِ خودکار نه | ### تصمیم‌هایی که با مدیریت است، نه با کد | موضوع | چرا تصمیم لازم است | |---|---| -| عوض کردنِ کلیدِ امضا | کد آماده؛ همه یک بار از حساب بیرون می‌افتند — زمانش را باید تعیین کرد. | -| پاک‌سازیِ داده‌ی دمو | حساب‌های آزمایشیِ شماره‌دار ۱۰ مهر پاک شدند (با بکاپ)؛ ۷ شماره‌ی نمایشی و حساب‌هایشان عمداً مانده‌اند. | +| مانع در برابرِ پر کردنِ سقفِ پیامک | CAPTCHA تجربه‌ی ورود را سخت‌تر می‌کند؛ بی‌آن یک مهاجم ورود را برای همه می‌بندد. | | سقفِ دستیار و سهمیه‌ی رایگان | بعد از دو هفته مصرفِ واقعی بازبینی شود؛ سود در `docs/ai-cost-and-guardrails.md` حساب شده. | +| یک فصلِ رایگان برای هر کتاب | تعبیرِ ما از «یک فصل رایگان» برای پایه‌های چندکتابی؛ تأییدِ نهایی با مدیریت. | | سیاستِ بازپرداخت | الان روزهای اشتراکِ همان سفارش پس گرفته می‌شود؛ پول از پنلِ درگاه. | -| Redis و Postgres | هماهنگی با ادمینِ سرور؛ هر دو روی همان ماشین موجودند. | -| پورتِ ۸۰ و دسترسیِ SSH | کارِ ادمینِ سرور. | +| پرداختِ درگاه داخلِ اپِ بازار | بازار پرداختِ محتوای دیجیتال بیرون از پرداختِ خودش را محدود می‌کند؛ پیش از انتشار روشن شود. | +| CPU و دسترسیِ SSH | کارِ ادمینِ سرور. | | پیشرفت با هر نمره | در حالِ حاضر جزیره با هر نمره‌ای تمام می‌شود؛ اگر حدِ قبولی لازم است، یک تصمیمِ آموزشی است. | ### کارِ بعدی، به ترتیبِ ارزش -1. بستنِ موارد ۱ تا ۳ِ امنیت و پاک‌سازیِ داده‌ی دمو. -2. بکاپِ خودکارِ دیتابیس، بیرون از همان سرور. -3. یک پرداختِ واقعیِ کامل با زرین‌پال. -4. Redisِ اختصاصی (`CACHE_URL`) و بعد Postgres. +1. یک پرداختِ واقعیِ کامل با زرین‌پال، و ساختِ APKِ تازه و یک خریدِ واقعی از کافه‌بازار. +2. بستنِ مواردِ باز در `docs/security-before-production.md` (پیامک، پنلِ ادمین، دسترسیِ سرور). +3. بازبینیِ سؤال‌های جزیره‌های تمرین با چشمِ معلم، به کمکِ رأی و گزارشِ دانش‌آموزان. +4. رفعِ گلوگاهِ CPU با ادمینِ سرور پیش از هر لانچِ بزرگ. 5. صفِ کار برای دستیار؛ سنجشِ شکل روی نمونه‌ی بزرگ‌تر. -6. تعریفِ سه بسته در پنلِ کافه‌بازار و اپِ اندرویدی با Poolakey. ## پیوست @@ -1020,7 +1035,7 @@ fetch ← `npm ci` ← بررسیِ نوع‌ها ← build در `dist.next` ← python3 -m venv venv && source venv/bin/activate && pip install -r requirements.txt cp .env.example .env # DJANGO_DEBUG=1 برای محلی python manage.py migrate && python manage.py seed_content && python manage.py runserver -python manage.py test # کلِ تست‌ها +python manage.py test --noinput # کلِ تست‌ها python manage.py spectacular --file schema.yaml # قراردادِ API # محتوا و دستیار @@ -1045,12 +1060,14 @@ npm run build # type-check + build در dist/ | `README.md` (هر دو مخزن) | درِ ورودی و راه‌اندازی | | `docs/architecture.md` | تصمیم‌های معماری و دلیلشان، با جزئیات | | `docs/operations.md` | انتشار، متغیرها، بکاپ، جابه‌جاییِ دامنه، ورودِ تیم | -| `docs/security-before-production.md` | ۱۸ موردِ امنیتیِ باز با خطر و راهِ بستن | +| `docs/security-before-production.md` | مواردِ امنیتیِ باز با خطر و راهِ بستن، و حفاظ‌های موجود | | `docs/content-authoring.md` | ویرایشِ محتوا و ساختِ انبوهِ سؤال | | `docs/extraction-playbook.md` | خواندنِ کتاب با مدلِ بینایی: هزینه و درس‌ها | | `docs/ai-cost-and-guardrails.md` | هزینه‌ی هر سوالِ دستیار، سودِ هر اشتراک و قاعده‌ی خرج، محافظِ «فقط ریاضی» | | `docs/model-prices.md` | دفترِ قیمتِ اندازه‌گیری‌شده‌ی مدل‌ها، با تاریخ؛ اگر قیمت عوض شد ردیف اضافه شود | | `data/textbooks/README.md` | افزودنِ کتابِ تازه | +| `tools/loadtest/README.md` | آزمونِ بار، روی لپ‌تاپ و روی سرورِ مشترک | +| `CLAUDE.md` | راهنمای agentهای کدنویس و قواعدِ کار روی سرورِ مشترک | | `/api/docs/` | قراردادِ زنده و قابلِ امتحانِ API | تدریسینو · مستندِ فنی · علیرضا زارعیان · مهرِ ۱۴۰۵
عددها از کد، اسکیمای API، دیتابیس و سنجش‌های ثبت‌شده در مخزن استخراج شده‌اند. diff --git a/docs/technical-handbook.pdf b/docs/technical-handbook.pdf index 31cbc3b..0d4e5ab 100644 Binary files a/docs/technical-handbook.pdf and b/docs/technical-handbook.pdf differ diff --git a/tools/loadtest/README.md b/tools/loadtest/README.md index 7357e5a..3b51749 100644 --- a/tools/loadtest/README.md +++ b/tools/loadtest/README.md @@ -5,7 +5,7 @@ JSON است با زمانِ هر پرسش و هر درخواستِ صفحه‌ی | فایل | کار | |---|---| -| `mkusers.py` | کاربرانِ آزمایشیِ غیرمهمان (شماره‌ی جعلیِ `0900…`، بی‌پیامک، بی‌سیگنال) با توکن و سه سوالِ متفاوت از بانکِ سوال برای هر کدام | +| `mkusers.py` | کاربرانِ آزمایشی (شماره‌ی جعلیِ `0900…`، بی‌پیامک، بی‌سیگنال) با توکن و سه سوالِ متفاوت از بانکِ سوال برای هر کدام | | `loadtest.py` | N نفر هم‌زمان می‌پرسند (استریم، دقیقاً مثلِ فرانت) و B نفرِ دیگر در خانه/آزمون/خلاصه/سهمیه می‌چرخند | | `fake_upstream.py` | جایگزینِ GapGPT و Qdrant برای آزمایشگاه: embedding، جوابِ استریم با زمانِ لاگ‌نرمال (پیش‌فرض: میانه‌ی ۵٫۲ و صدکِ ۹۰ِ ۱۳ ثانیه، از جواب‌های واقعیِ سرور) و جست‌وجوی Qdrant | @@ -37,8 +37,10 @@ python tools/loadtest/loadtest.py http://127.0.0.1:8800 users.json 50 10 0 out.j یعنی کارِ سرورِ ما و صفِ انتظار برای نخِ آزاد. ۵. `manage.py delete_users --ids … --yes` و پاک کردنِ فایلِ توکن‌ها. -سقفِ ساعتیِ دستیار برای مهمان‌ها بر اساسِ IP است؛ با کاربرِ مهمان از یک ماشین بیش از ~۲۰ پرسش در -ساعت ممکن نیست، برای همین `mkusers.py` کاربرِ غیرمهمان می‌سازد. +**روی سرورِ مشترک، throttleهای پروداکشن دست نمی‌خورند.** اگر سقفِ نرخ مانع است، یک gunicornِ موقت روی سوکتِ جدا در +`~/mathapp-v2/.lt/` با `READ_THROTTLE_RATE` بالا و `DB_POOL_MAX_SIZE=4 --workers 2` بالا بیاورید (تا با ۳۲ اتصالِ +پروداکشن از ۵۷ نگذرد)، کوتاه اجرا کنید و آخرش `.lt` را پاک کنید. در اسکریپت روی آن gunicornِ پس‌زمینه `wait` نزنید: +تا ابد منتظر می‌ماند. ## بازیکن‌ها (نه دستیار): `mkplayers.py`، `play.py`، `agg.py`