1109 lines
109 KiB
Markdown
1109 lines
109 KiB
Markdown
# CLAUDE.md
|
||
|
||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||
|
||
## Project
|
||
|
||
Django + DRF backend for Tadrisino, an educational app (Persian/Farsi). Codebase and API responses are in Persian; comments in source are Persian too.
|
||
|
||
## Reviewing this codebase: comments are claims, not evidence
|
||
|
||
Comments here were mostly written by earlier agents to justify their own change. They
|
||
explain intent; they do not prove the code is right. An audit on 2026-09-25 found real
|
||
bugs directly under comments calling the same code "safe" or "race-free" (plan swap
|
||
at checkout, `select_for_update` guards that SQLite silently ignores).
|
||
|
||
- **When asked to find problems, review adversarially.** Judge by behaviour, not by
|
||
the comment above it: write a throwaway test or call the endpoint and watch what
|
||
happens. Ask "how would a cheating student, an attacker, or a confused parent break
|
||
this?" A finding counts once it is reproduced; say which ones were only read.
|
||
- **When writing comments, report — don't vouch.** Say what the code does and why it
|
||
was chosen, and name what was *not* checked. Avoid "this is safe / correct /
|
||
race-free / the right architecture"; write "guards X; not tested under Y" instead.
|
||
Record uncertainty plainly so the next reader re-checks rather than trusts.
|
||
|
||
## No silent fallbacks, no speculative backward compatibility
|
||
|
||
Agents keep adding "helpful" fallbacks (`x or default`, `except: pass`, accepting an old
|
||
field name "just in case") and they cause the hardest bugs here, because the
|
||
failure never shows up anywhere. An audit on 2026-10-03 found and reproduced these:
|
||
|
||
- With RAG keys missing from `.env` and `DEBUG=0`, `/api/ai/ask` answered **200** with
|
||
«این یک پاسخ نمونه (stub)… OPENAI_API_KEY را تنظیم کنید» and still used up the
|
||
student's daily quota. It now returns 503 (`StubNeverReachesProductionTests`).
|
||
- A paid order whose plan had been deleted quietly became 30 days (a yearly buyer got
|
||
a month). It still activates, but now logs ERROR.
|
||
- `?grade=` on `/api/home` was an alias "for clients already out there". No such client
|
||
existed. Removed, and sending it is a 400, not a silent fallback to the user's own grade.
|
||
- `resolve_grade_title` had `except Exception: pass` (now logged); `curriculum_io` read
|
||
`intro_text` that no file ever contains (removed).
|
||
|
||
On the server since 2026-10-03 12:02 (`7307bfc`, service really restarted; backup
|
||
`.deploy-backups/db-before-no-fallbacks-20261003-120205.sqlite3`). Checked from outside:
|
||
`/api/home?grade=8` → 400. The server had RAG configured and no paid order without a plan.
|
||
|
||
Rules:
|
||
|
||
1. **Fail loudly over guessing.** A missing setting, row or field in production should
|
||
be an error the user and `/api/health` can see, not a default that looks like
|
||
success. A default is fine only when it is a real product choice. When you
|
||
add one, say in the comment why that value is right, not "just in case".
|
||
2. **Demo/stub paths only behind `DEBUG` or `RAG_FORCE_MOCK`.** Never reachable on the server.
|
||
3. **No backward compatibility without a named client.** Before keeping an old field, param
|
||
or code path, check who sends it (`~/Desktop/MathApp_frontend/src/api/`, the server
|
||
DB). The only API client is that frontend; there is no Android app yet. If nobody
|
||
sends it, delete it. If you must keep one, write who uses it and when it can go.
|
||
4. **Never `except: pass`.** At least `logger.exception`. Swallowing is acceptable only
|
||
where the comment names the bigger thing it protects (e.g. `affiliate/services.py`:
|
||
commission failure must not undo the user's payment) and it still logs.
|
||
5. **Removing a fallback means making the old input fail visibly** (400/503), not
|
||
silently routing it somewhere else.
|
||
|
||
Fallbacks that remain on purpose (re-check before relying on them): settings boot with
|
||
default hosts/CORS/cache/secret key (`/api/health` warns); `LocMemCache` without
|
||
`CACHE_URL`; `_annotation` defaults for answer-cache entries older than a field; Bazaar's
|
||
OAuth path beside the Pishkhan token (server still uses OAuth); `birthYear` alone in
|
||
`PATCH /api/user/profile` (frontend type still allows it); `totalQuestions` accepted but
|
||
ignored (frontend sends it); orders with no `plan_id` from before 2026-09-25 (none
|
||
paid on the server on 2026-10-03); orders without the `question_credits`/`hearts_days`/
|
||
`duration_days` snapshot (`payment.0011`) grant and refund from the plan's current values.
|
||
|
||
## Before any real user: read `docs/security-before-production.md`
|
||
|
||
The server is deliberately left in demo mode for the internship presentation: seven
|
||
fixed-code demo logins exist, demo users sit in the production DB, and the leaderboard and `/admin/` are public (`/api/health` shows only ok/error without `X-Internal-Key`; Swagger/ReDoc/schema need an admin session since 2026-10-08). (The token signing key is no longer the
|
||
committed one: replaced 2026-10-07, see the status block below.) Each is listed there with its risk, why it was left, and the
|
||
fix. **Do not treat this deployment as production-ready until every item there is closed**,
|
||
and when one is closed, move it to that file's «حفاظهایی که هست» table with the test that holds it (the date lives in the commit message; the owner wants docs to describe the current state only).
|
||
|
||
## Commands
|
||
|
||
```bash
|
||
source venv/bin/activate
|
||
python manage.py runserver
|
||
python manage.py migrate
|
||
python manage.py test # all tests
|
||
python manage.py test accounts # single app
|
||
python manage.py test accounts.tests.SomeTestCase.test_something # single test
|
||
```
|
||
|
||
Local dev requires `DJANGO_DEBUG=1` (enables Swagger auth via `DEV_MASTER_OTP`/`devOtp` in responses). `DEBUG` defaults to `False`. Production also requires `DJANGO_SECRET_KEY`, `DJANGO_ALLOWED_HOSTS` (comma-separated), and ideally `CACHE_URL` (Redis). Without it the app still boots on per-process `LocMemCache` and `/api/health` warns — throttles and admin-driven cache invalidation are then per worker. On the server since 2026-10-08 it points at our own Redis (`zareian-redis.service`, port 6390, localhost, password-protected, 256 MB `allkeys-lru`, no persistence), never the company's Redis on 6379. Its outage is logged and rate limits stop applying (fail-open); not tested under real Redis failure on the server.
|
||
|
||
No pytest; uses Django's built-in test runner. No linter/formatter configured.
|
||
|
||
Docker (`docker compose up --build`) adds a local Loki+Grafana Alloy+Grafana log stack; not a production setup (still `runserver`, not gunicorn). See comments in `docker-compose.yml`, `docker/alloy/config.alloy`, and `LOG_DIR` in `config/settings.py`.
|
||
|
||
API docs: Swagger `/api/docs/`, Redoc `/api/redoc/`, admin `/admin/`.
|
||
|
||
## Architecture
|
||
|
||
**Apps** (each owns its own models, one responsibility):
|
||
|
||
| app | responsibility | key models |
|
||
|---|---|---|
|
||
| `accounts` | identity, sessions, OTP auth | `User`, `Profile`, `Session`, `OTPCode` |
|
||
| `content` | lessons/curriculum | `Grade`, `Unit`, `Node`, `Lesson`, `Question`, `Choice`, `NodeProgress` |
|
||
| `progress` | stats, leaderboard, streaks, gamification characters | `UserStats`, `LeaderboardEntry`, `StreakRecord`, `UserHearts`, `Character` |
|
||
| `ai_tutor` | AI assistant chat | `Conversation`, `Message`, `MessageFeedback` |
|
||
| `subscription` | plans | `Plan`, `Subscription` |
|
||
| `payment` | orders/transactions (Zarinpal gateway + Cafe Bazaar IAP) | `Order`, `Transaction` |
|
||
| `discount` | discount codes/offers | `DiscountCode`, `Redemption`, `Offer`, `UserOffer` |
|
||
| `notifications` | notifications | `Notification` |
|
||
| `affiliate` | referral program | `AffiliateAccount`, `Conversion` |
|
||
| `support` | tickets/bug reports | `BugReport`, `Ticket`, `TicketMessage` |
|
||
| `statuslog` | generic cross-app status-change log | `StatusChangeLog` |
|
||
| `campaigns` | messaging campaign storage only, no send logic yet | `MessageCampaign` |
|
||
| `config` | project settings, root urls, middleware | — |
|
||
|
||
**Auth is custom, not Django's.** `accounts.User` is deliberately *not* `AUTH_USER_MODEL` — it's an independent business model. Auth is a stateless signed bearer token (`django.core.signing`, no JWT library) issued/verified in `accounts/authentication.py` (`TadrisinoTokenAuthentication`, header `Authorization: Bearer <token>`). The access token is only a signed `user_id` (1 h); `accounts.Session` holds refresh tokens, and `POST /api/auth/logout` revokes older access tokens via `User.tokens_valid_after`. Missing/invalid header → anonymous request, not a hard failure; each view's permission class decides if auth is required.
|
||
|
||
**Apps are decoupled by convention**: instead of real FKs across app boundaries, code uses loose refs (`user_id`, `object_id` + `label`) so any app can be split out later. Don't add cross-app FKs — follow the existing loose-reference pattern.
|
||
|
||
**Throttling**: DRF `ScopedRateThrottle` with per-endpoint `throttle_scope` strings, rates centralized in `REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']` in `config/settings.py`. 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/<branch>`, install, migrate,
|
||
seed content, collectstatic, restart, then it polls `/api/health` and prints the likely
|
||
cause if the service does not come back.
|
||
|
||
**It repairs the server rather than stopping at it.** A hand-edited file used to make
|
||
`git pull --ff-only` fail, which aborted the whole script — and because every later step
|
||
was skipped, the server silently stayed on old code. Now local modifications (and local
|
||
commits) are saved to a timestamped patch under `.deploy-backups/` and then discarded with
|
||
`git reset --hard origin/<branch>`, and the deploy continues. Whatever state the server is
|
||
left in, one run puts it back on exactly what Gitea has.
|
||
|
||
**`git clean` is never run there and must never be added.** `db.sqlite3`, `.env` and
|
||
`.django_secret_key` are untracked and live in that directory; `reset --hard` leaves them
|
||
alone, `clean -fd` would destroy the database, the config and every signed-in session.
|
||
|
||
Settings boot with working defaults (hosts, CSRF, CORS, secret key, cache), so a stale or
|
||
partial `.env` on the server no longer breaks startup — env values are *merged with*
|
||
those defaults, not replaced by them.
|
||
|
||
`GET /api/health` is the outside view: database, unapplied migrations, cache, content
|
||
counts, security warnings, and recent traffic. It is how you check whether anyone has
|
||
actually connected without asking a human.
|
||
|
||
## Content: where it lives and how it gets to a server
|
||
|
||
`content/seed/` holds the whole curriculum and question bank as committed JSON —
|
||
`curriculum.json` (8 grades, 58 units, 263 nodes, 2,617 lessons) and `questions.json`
|
||
(2,463 multiple-choice questions). `deploy.sh` runs `seed_content` on every release, so a
|
||
fresh server fills itself with one command: **no Qdrant, no AI credit, no SSH session.**
|
||
|
||
`seed_content` is idempotent and never deletes, 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 itself never rehearsed.
|
||
- 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' '<password>' > "$SCRATCH/.pw" # scratchpad, never the repo
|
||
chmod 600 "$SCRATCH/.pw"
|
||
sshpass -f "$SCRATCH/.pw" ssh administrator@78.38.35.249 '<command>'
|
||
ssh administrator@78.38.35.249 'sudo -S -p "" <command>' < "$SCRATCH/.pw"
|
||
shred -u "$SCRATCH/.pw" # immediately after, not "later"
|
||
```
|
||
|
||
- **Never put the password in the command line.** `ps` on this box shows every process's
|
||
full argv to every logged-in user, and several other teams have accounts here.
|
||
- **Never write it into the repo, a memory file, a commit, or a note.** The scratchpad
|
||
only, and only for as long as the commands take.
|
||
- **`shred -u` as soon as the work is done**, not at the end of the session. Recreate it
|
||
if more work comes; that costs nothing.
|
||
- **Never echo it back into the conversation**, even partially, and never paste `.env`
|
||
values either.
|
||
- Do not create SSH keys, edit `sshd_config`, or change the password unless the user asks
|
||
for that specifically. It is a shared account; changing it locks the admin out.
|
||
|
||
The password reaches an agent only because the user supplies it in that session. If it is
|
||
not in the conversation, it is not available — say so and ask, rather than hunting for it
|
||
on disk. If the user wants to stop pasting it into chat, the macOS Keychain is the local
|
||
fix (`security add-generic-password -s tadrisino-server -a administrator -w`, then read it
|
||
back with `security find-generic-password -s tadrisino-server -w`), and an SSH key is the
|
||
real one.
|
||
|
||
`DEBUG` must never be `1` on this server: it turns on `DEV_MASTER_OTP` (`0000`), which
|
||
accepts any phone number as a valid login, and renders tracebacks with settings in them.
|
||
`DJANGO_DEBUG` belongs in `.env` only — not in the systemd unit, where it once sat as
|
||
`Environment="DJANGO_DEBUG=1"` and was saved only by `.env` happening to override it.
|
||
|
||
`DJANGO_SECRET_KEY` is what signs our bearer tokens. It must be a real random value that
|
||
exists nowhere in git. If the env var is simply absent and `DEBUG=0`, `config/settings.py`
|
||
generates a strong one and stores it in `.django_secret_key` (mode `0600`) — that is the
|
||
preferred setup, because there is then no key to leak. **Never let the server run on the
|
||
`django-insecure-…` fallback from `config/settings.py`: that string is committed here, so
|
||
anyone who can read this repo can forge a token for any user.**
|
||
|
||
## ظرفیت زیرِ بار — روی سرور از ۲۰۲۶-۱۰-۱۱ (بکاند `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`، `AI_GLOBAL_DAILY_MAX` در `.env`؛ هیچکدام الان در `.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`ِ ۲۴۷ دیده میشود.
|
||
- **سنجیده (۲۰۲۶-۱۰-۱۰):** کلید شِل نمیگیرد و تونل به پورتِ دیگر رد میشود؛ `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.0013`):** یکماهه ۴۹۸، سهماهه ۷۹۸، ششماهه ۱٬۲۹۸ هزار تومان؛ همهی فصلها، قلبِ نامحدود و
|
||
۳ سوال در روز از دستیار. **دستیار جدا فروخته میشود** (بستههای سوال روی فروش ماندند). رایگان ۲ سوال در روز
|
||
(`AI_DAILY_QUOTA_FREE`). حساب: بدترین حالتِ ششماهه با سقفِ ۳ ≈ ۲۹٪ِ سهمِ ماهانهی قیمت؛ با سقفِ ۸ ≈ ۷۸٪ بود.
|
||
- **اشتراک داخلِ اپِ بازار خریدنی نیست** (SKU ندارد، `PurchaseView` فقط بسته را از بازار میفروشد).
|
||
- حسابهای دمو اشتراک ندارند، پس از فصلِ دوم به بعد برایشان قفل است.
|
||
- از سرور، `git fetch` از Gitea در این روز کار کرد (بی `DEPLOY_BUNDLE`).
|
||
|
||
## شکلِ ثابتِ درسها (`Lesson.figures`) و کشِ فرانت — روی سرور از ۲۰۲۶-۱۰-۰۷ ۱۶:۴۸ (`b93f43e`)
|
||
|
||
**شکلِ درس یک بار ساخته و تأیید میشود و بعد ثابت است؛ هنگامِ نمایش هیچ فراخوانیِ مدلی نیست.**
|
||
`Lesson.figures` فهرستِ `{where: intro|example|step, index?, diagram}` است (`index` برای step از صفر).
|
||
`GET /api/lessons/{id}/intro` آن را برمیگرداند و فرانت (`LessonScreen`) با همان `Diagram` دستیار میکشد.
|
||
شکل در `content/seed/curriculum.json` هم میرود (`figures`)، و `seed_content` آن را **فقط در خانهی خالی** مینشاند
|
||
(مثلِ `example`/`steps`). پس برای عوضکردنِ شکلِ درسی که روی سرور هست باید `figures` آن درس را خالی کرد و دوباره دیپلو کرد؛
|
||
**شناسهی درس روی سرور با لپتاپ فرق دارد** (۳۰۰۳..۳۰۰۶ محلی = ۲۲۹۸..۲۳۰۱ سرور)، پس با شناسهی محلی روی سرور کار نکن.
|
||
|
||
- **ساخت:** `python manage.py gen_lesson_figures --grade 12 --limit 4 [--lesson ID] [--redo] [--dry-run]`.
|
||
همان مدلِ چت و کاتالوگِ `<diagram>` و اعتبارسنجِ `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`ِ لپتاپ همان حسابِ سرور است؛ سنجشهای شکل (~۲۵۰ فراخوانی، ≈۰٫۳ دلار) باقیمانده را
|
||
خورد. **هر سنجشِ انبوهِ محلی از اعتبارِ پروداکشن خرج میکند؛ پیش از آن موجودی را بسنجید.**
|
||
- **شکل در جوابِ دستیار:** مدل در انتهای جواب `<diagram>{JSON}</diagram>` مینویسد؛
|
||
`rag/services/diagram.py` آن را پیش از `mathtext` جدا، خطاهای قالبیِ شناختهشده را تعمیر
|
||
(رقمِ فارسی، `2*pi`، کامنت، ونِ با اسمِ فارسی) و سختگیرانه اعتبارسنجی میکند؛ روی
|
||
`Message.diagram` (migration `ai_tutor.0006`) مینشیند و در `done`ِ SSE و تاریخچه میآید.
|
||
فرانت (`src/diagram/`) خودش SVG میکشد و ریشه، رأس، مماس، پای عمود و تبدیلها را حساب
|
||
میکند؛ HTML/SVGِ مدل هرگز وارد صفحه نمیشود. ۱۴ نوع (plane، numberline، fraction، grid،
|
||
groups، clock، bar/linechart/pie، table، venn، mapping، tree، solid)؛ گالریِ آزمایشی در
|
||
`?gallery` فقط در حالتِ توسعه. کاتالوگ بخشبخش است و کنارِ هر سوال فقط بخشِ مربوط میرود
|
||
(`hint_for`)؛ سوالِ ادامهای بخشِ سوالِ قبلی را میگیرد (`follow_up_hint_for`)، و درخواستِ صریحِ
|
||
«شکلش رو بکش» بیکلمهی موضوعی کلِ کاتالوگ را (`asked_hint_for`، پرامپت `tutor-v8-asked-figure`؛
|
||
یک بار با GapGPT آزموده شد: مستطیلِ باغچه). فرانت هیچ تشخیصی نمیدهد.
|
||
- **۲۰۲۶-۰۹-۲۹، پرامپت `tutor-v10-shape-anchor`:** ابزارهای `triangle` (مثلث از ضلع و زاویه، با قانونِ
|
||
سینوس/کسینوس در `src/diagram/shapes.ts`)، `quad` (مستطیل، مربع، متوازیالاضلاع، لوزی، ذوزنقه)، نوعِ
|
||
`pattern` (الگوی شکلی و چوبکبریت)، نقطهی `translate`؛ شکلِ دوم روی ضلعِ مشترک یا کنارِ شکلِ قبلی
|
||
مینشیند. تعمیرِ عددِ حسابی («-2 * 3.14»)، گیومهی جاافتاده، کلیدِ تکراری، `kind` با نامِ شکل؛
|
||
`fits()` ساعتِ دیواری را برای سوالِ بیساعت دور میریزد. سنجشِ ۶۴ مسئلهی تصادفیِ کتاب (همان مسئلهها
|
||
قبل و بعد، وارسیِ چشمی): درست ۳۹ → ۴۰، غلط ۹ → ۷، بیشکل ۷ → ۵ — در این اندازهی نمونه معنیدار نیست؛
|
||
برآوردِ ۹۵٪: درست ۵۰–۷۳٪، غلط ۵–۲۱٪. ۴۰ مسئلهی هندسیِ جدا: ۲۲ درست، ۱۲ قابلِ قبول، ۴ غلط. خرج ≈ ۰٫۷ دلار
|
||
(به اجازهی صاحبِ پروژه تا ~۱ دلار). اسکریپتِ سنجش در scratchpad بود، نه در ریپو. پرامپت `tutor-v6-diagrams`. سنجش روی ۹۶ مسئلهی واقعیِ کتاب: ۴۵ از ۴۸ شکلِ
|
||
معتبر (دورِ ۲)، ۰ از ۸ برای سوالِ بیشکل؛ وارسیِ چشمیِ ۸۳ شکل: ۶۴ درست، ۳ ضعیف، ۱۶ غلط.
|
||
اصلاحهای بعد از آن (خط از معادله، کسرِ مقایسهای، محرکِ ساعت، …) روی مدل سنجیده نشدهاند.
|
||
- **فرانت آزمونِ جزیره را بازی میکند** (`/nodes/{id}/quiz`); خانه خودِ مسیر است با «شروع».
|
||
- **امگا** (ω؛ تا ۲۰۲۶-۱۰-۱۰ «تدی»، slug هنوز `tedi`؛ شکلکِ `mobile-preview/mascot-pack`، شش حالت) با `progress.0005`/`0007`، **۵۰۰ امتیاز**
|
||
(همان فاصلهی ۲۰۰تاییِ دلتا تا پی؛ خواستِ صاحبِ پروژه).
|
||
- **محتوا:** ۸۲ سوالِ تازه، پس هیچ جزیرهای زیرِ ۴ سوال نیست (۲٬۵۴۵ سوال). `fill_lesson_texts`
|
||
(بیهزینه) `Unit.blurb` را برای ۵۸ فصل و مثال/قدمها را برای ۵۹۰ درس از مسئلههای تأییدشدهی
|
||
کتاب پر کرد، هرگز همان مسئلهی آزمونِ درس؛ `seed_content` اینها را فقط در خانهی خالی
|
||
مینشاند، پس متنِ ادمین با دیپلوی بازنویسی نمیشود.
|
||
- محلی: `PUBLIC_BASE_URL` پیشفرضش دامنهی اصلی است، پس «پرداخت» روی لپتاپ به سرورِ واقعی
|
||
میرود و ۴۰۴ میگیرد؛ باگِ سرور نیست. گزارش: `~/Desktop/گزارشات Tadrisino/گزارش-۲۵/`.
|
||
|
||
## وضعیت ۲۰۲۶-۰۹-۲۷ — دامنهی تازه و فرانتِ تازه، هر دو زنده
|
||
|
||
- ✅ **پیامکِ کدِ ورود واقعاً میرسد** (۲۱:۴۴). اسمِ الگو را سینیور داد: `SMSVerify`، با همان سه
|
||
پارامترِ `receptor`/`token`/`template` که کد از قبل میفرستاد. فقط `KAVENEGAR_OTP_TEMPLATE=SMSVerify`
|
||
به `.env` سرور اضافه شد (بکاپ: `.deploy-backups/env-before-smsverify-20260927-214351`) و سرویس
|
||
ریاستارت شد؛ کدی عوض نشد. یک دقیقه پیش از آن جلسهی دیگری کلیدِ بازار را عوض و ریاستارت کرده
|
||
بود (`env-before-bazaar-client2-…`)؛ سنجیده شد که هیچ کلیدی از آن گم نشده.
|
||
|
||
- **API روی `mathapi.tadrisino2.ir` است**؛ ادمین از ۲۰۲۶-۰۹-۲۶ ساعتِ ۲۳:۲۲ nginx را
|
||
عوض کرد و رکوردِ DNSِ `api.math.tadrisino2.ir` دیگر وجود ندارد. `.env` سرور
|
||
`PUBLIC_BASE_URL=https://mathapi.tadrisino2.ir` شد (بکاپ:
|
||
`.deploy-backups/env-before-mathapi-20260927-010751` و `db-before-mathapi-…sqlite3`)،
|
||
`1f38465` دیپلوی و سرویس واقعاً ریاستارت شد (۰۱:۰۹).
|
||
- ⛔ **زرینپال هنوز `-14` میدهد** («دامنهی callback با دامنهی ثبتشدهی ترمینال
|
||
نمیخواند»)، این بار برای `mathapi`؛ فراخوانیِ زندهی PaymentRequest در همان شب.
|
||
رفعش ثبتِ دامنه در پنلِ زرینپال است، نه کد. Redirect URIِ بازار هم با این دامنه
|
||
عوض شد؛ لینکِ «اجازه»ِ تازه با `mathapi` در ۲۰۲۶-۰۹-۲۷ ساخته شد (۷۲ ساعت، لینکهای قبلی باطل)
|
||
و برای سنیور رفت؛ پیش از کلیک باید Redirect URIِ پنلِ بازار هم `mathapi` شده باشد.
|
||
- **۲۰۲۶-۰۹-۲۷ عصر:** سنیور یک Client تازهی بازار داد (Redirect روی `mathapi`)؛ در `.env` سرور
|
||
جایگزین شد (بکاپ: `.deploy-backups/env-before-bazaar-client2-20260927-214302`)، ریاستارت ۲۱:۴۳،
|
||
و لینکِ «اجازه»ِ تازه با همین Client ساخته شد (لینکهای قبلی باطل). به گفتهی سنیور «شناسهی
|
||
توسعهدهنده» هنوز در بررسیِ بازار است و تا آن تمام نشود لینک خطا میدهد.
|
||
- **علتِ `-14`ِ زرینپال پیدا شد (فراخوانیِ زنده، ۰۹-۲۷):** مرچنتِ `.env` ما برای `tadrisino.org`
|
||
ثبت است — callback روی `tadrisino.org` کدِ ۱۰۰ میگیرد و هر آدرسِ `tadrisino2.ir` (با
|
||
`mathapi`) `-14`. بکاندِ دیگرِ شرکت (`Tadrisino/Tadrisino_backend`) callback را روی
|
||
`api.tadrisino2.ir` میفرستد و کار میکند، پس مرچنتِ آن برای `tadrisino2.ir` است. رفع:
|
||
همان مرچنت در `ZARINPAL_MERCHANT_ID`، نه تغییرِ کد. کدِ آنها همان فیلدهای ما را دارد جز
|
||
`currency` (آنها نمیفرستند، یعنی ریال)؛ verifyِ ما هنوز با پرداختِ واقعی آزموده نشده.
|
||
**۰۹-۲۷ ۲۲:۲۴ انجام شد** (به خواستِ صاحبِ پروژه): مرچنتِ `Tadrisino_backend` روی خودِ سرور از
|
||
`~/deployment/Tadrisino_backend/MOOC/.env` در `.env` ما کپی شد (مقدار هرگز چاپ نشد؛ بکاپ:
|
||
`.deploy-backups/env-before-zarinpal-merchant-20260927-222314`)، ریاستارت، و PaymentRequest با
|
||
callbackِ `mathapi` حالا **۱۰۰** میدهد. پرداختهای دو اپ حالا به یک حسابِ زرینپال میروند.
|
||
هنوز آزموده نشده: یک پرداختِ واقعی تا verify.
|
||
- **فرانتِ بازسازیشده روی `math.tadrisino2.ir` زنده است** (`main` = `2b4db90`). دو ایراد
|
||
فقط روی سایتِ زنده با حسابهای دمو پیدا شد و رفع شد: متنِ بخشِ والدین برای فرزند
|
||
غلط بود، و حسابِ والد (بیپایه) به فرمِ «کدوم پایهای؟» میافتاد.
|
||
- **دادهی دمو دستنخورده است** (۱۸ کاربر؛ ۶، ۱۰ تا ۱۵، ۱۷، ۱۸ با امتیاز، گفتگو،
|
||
تیکت و اعلانهایشان). تستِ زنده فقط نشستِ ورود ساخت، هیچ جوابی ثبت نکرد.
|
||
|
||
## وضعیت ۲۰۲۶-۰۹-۲۶ — دستیار روی gpt-4.1-mini
|
||
|
||
**روی سرور:** `11bbe1c` دیپلوی شد و سرویس واقعاً ریاستارت شد (۰۲:۵۴). `OPENAI_CHAT_MODEL`
|
||
در `.env` سرور از `gpt-4o-mini` به `gpt-4.1-mini` عوض شد؛ بکاپ:
|
||
`.deploy-backups/env-before-gpt41mini-20260926-025320`. پیشفرضِ `settings.py` هم همین است.
|
||
یک سوالِ زنده با مهمانِ تستیِ **کاربرِ ۱۸** جواب داد (پیامِ ۴۹: `gpt-4.1-mini`،
|
||
`tutor-v5-slash-in-question`، ۴ chunk، ۲٫۵ ثانیه).
|
||
|
||
- **چرا:** دو دور سنجش (گزارش ۲۳، شرح در `docs/architecture.md`). روی ۱۰۲ مسئله با تصحیحِ
|
||
دستی، 4.1-mini ۹۵–۹۶ و 4o-mini ۸۵–۹۰ گرفت. هر پاسخ ۰٫۱۲ سنت است، ۲٫۹ برابرِ 4o-mini.
|
||
ورودِ کتاب هم با همین مدل است (`OPENAI_VISION_MODEL`).
|
||
- **«/» در دبستان** هم ممیز است هم کسر. جملهی «در کتاب / ممیز است، نه کسر» فقط به
|
||
دبیرستان میرود و قاعدهی تشخیص کنارِ سوالِ دبستانی میآید. سه مسئله از ۱۷ هنوز غلط است.
|
||
- **مانده:** پاسخِ دبستانی کسر را گاهی با «/» مینویسد (۵/۶)، که در نمادِ کتاب یعنی ۵٫۶.
|
||
یک خطِ پرامپت برای «بر» (v6) آزموده شد و درستی را کمی پایین آورد، پس **منتشر نشد**
|
||
(سرور روی v5 ماند). شرح در `docs/architecture.md`؛ راهِ بعدی احتمالاً در فرانت است.
|
||
- داورِ خودکارِ `rag_model_benchmark` (gpt-4.1) حدودِ ۷٪ اشتباه میکند؛ نتیجهی هر سنجش را
|
||
پیش از تصمیم دستی وارسی کنید.
|
||
|
||
## وضعیت ۲۰۲۶-۰۹-۲۶ — اتصالِ API بازار
|
||
|
||
`7d205ca` روی سرور است (ریاستارت ۲۲:۲۱، migration `payment.0008`؛ `/api/health` → `integrations.bazaar`). Client ID/Secretِ بازار
|
||
در `.env` سرور است (بکاپِ پیشش: `.deploy-backups/env-before-bazaar-20260926-213108`).
|
||
Redirect URIِ ثبتشده در پنلِ بازار خودِ `/api/payment/bazaar/verify` است: GET کدِ بازار را
|
||
میگیرد، POST خریدِ اپ. اولین تأیید با حسابِ شخصیِ صاحبِ پروژه زده شد: توکن کار کرد ولی به هیچ اپی دسترسی نداشت (۴۰۳
|
||
«You don't have access to this app») و همین نشان داد بازار `Bearer` را نمیپذیرد. **هر حسابِ
|
||
بازاری میتواند تأیید بزند؛ فقط حسابِ شرکت به کار میآید.** توکن پاک شد و لینکِ تازه (۲۲:۱۸، ۷۲ ساعت)
|
||
برای سنیور رفت؛ تا تأییدِ حسابِ شرکت `bazaar: false` است. اسمهای حدسیِ `ir.tadrisino.app` /
|
||
`ir.tadrisino` / `com.tadrisino` روی بازار وجود ندارند. **هنوز نداریم:** اسمِ پکیجِ اپ (کد حدسِ
|
||
`ir.tadrisino.app` را دارد)، SKUِ پلنها، و اپِ اندرویدیِ دارای Poolakey. آدرسِ سنجشِ خرید با
|
||
سندِ فعلیِ بازار مقایسه شد و **هر دو آدرسِ سنجشِ خرید غلط بودند** (یعنی هیچ خریدِ واقعیای
|
||
تأیید نمیشد)؛ درست شدند، بهعلاوهی روشِ جدیدِ بازار (توکنِ ثابتِ پیشخان در
|
||
`BAZAAR_PISHKHAN_API_SECRET`، بیOAuth) که ترجیحی است. سندِ بازار JS است ولی متنش از
|
||
`developers.cafebazaar.ir/wp-json/wp/v2/document?search=...` خوانده میشود. جزئیات در `docs/operations.md`.
|
||
|
||
## وضعیت ۲۰۲۶-۰۹-۲۵ (عصر) — پیامکِ واقعی روشن شد
|
||
|
||
**روی سرور:** `0580aec` دیپلوی و سرویس واقعاً ریاستارت شد (۱۲:۳۲). `KAVENEGAR_API_KEY`
|
||
در `.env` سرور و لوکال است (حسابِ Master، ~۳۲۶ هزار ریال اعتبار در آن روز). بکاپهای پیش از آن:
|
||
`.deploy-backups/env-before-kavenegar-20260925-122959` و
|
||
`db-before-otp-hardening-20260925-122959.sqlite3`.
|
||
|
||
- **از این به بعد هر درخواستِ OTP پول است.** برای همین: شماره به `09XXXXXXXXX` نرمال
|
||
میشود و غیرِ موبایلِ ایران ۴۰۰ میگیرد؛ cooldown و سقفِ روزانهی هر شماره (۱۰) و سقفِ
|
||
روزانهی کلِ سامانه (۳۰۰، ضدِ SMS pumping) در دیتابیس شمرده میشوند نه کشِ per-worker.
|
||
تستها در `accounts/tests_otp_limits.py`.
|
||
- مسیرِ کاملِ کاربرِ تازه روی سرورِ زنده سبز شد، **بدونِ پیامکِ واقعی**: کد مستقیم در
|
||
دیتابیس ساخته شد. کاربرِ تستیِ آن **کاربرِ ۱۷ با شمارهی `09000000001`** (نام «کاربر
|
||
تست»، یک جزیره تمامشده) روی پروداکشن مانده و مثلِ دادهی دمو پیش از کاربرِ واقعی باید پاک شود.
|
||
- **بررسیِ همهی اتصالها روی سرور (همان عصر، فراخوانیِ زنده، فقط بولین/کدِ پاسخ چاپ شد):**
|
||
GapGPT دوباره اعتبار دارد (دستیار برای مهمانِ تستیِ **کاربرِ ۱۸**، `deviceId =
|
||
deploy-check-ai-20260925`، با ۴ chunk جواب داد و `aiProvider` خودش `ok` شد)؛ Qdrant سالم (۱۲
|
||
collection). **زرینپال: مرچنت معتبر ولی `-14` «دامنهی callback با دامنهی ثبتشدهی ترمینال
|
||
نمیخواند»** — ترمینال برای `api.math.tadrisino2.ir` ثبت نشده، پس هیچ پرداختی شروع نمیشود؛
|
||
رفعش در پنلِ زرینپال است. **بازار: هر سه `BAZAAR_*` خالیاند** (فقط برای خریدِ درونبرنامهایِ
|
||
اپِ اندروید لازم است). `CACHE_URL` و `INTERNAL_SERVICE_KEY` خالی (اولی کارِ ادمین، دومی فقط
|
||
مسیرهای داخلی)، کلیدِ امضا هنوز `django-insecure` (تصمیمِ صاحبِ پروژه).
|
||
- ⛔ **اولین پیامکِ واقعی (۱۲:۳۵، به شمارهی خودِ صاحبِ پروژه) نرسید.** API ۲۰۰ داد و کاوهنگار
|
||
پذیرفت، ولی وضعیت شد **۱۳ «لغو شده»**؛ یک متنِ سادهِ بیکد هم لغو شد، پس ایراد از حساب/خطِ
|
||
`10009000907070` است نه از کد یا متن. **سینیور (۰۹-۲۶): حساب سالم است، فقط اسمِ الگو را
|
||
نداشتیم** — این خط کدِ ورود را فقط با «ارسال با الگو» (`verify/lookup`) میفرستد. کدش آماده
|
||
است: با `KAVENEGAR_OTP_TEMPLATE=<نامِ الگو>` در `.env` کدِ ورود از الگو میرود، بدونِ آن همان
|
||
`sms/send`ِ قبلی (که روی این خط لغو میشود). اسمِ الگو هنوز نرسیده.
|
||
وضعیت را بدونِ SSH با `sms/statusbyreceptor.json` بسنجید (`latestoutbox` برای این کلید ۴۰۷ است).
|
||
- ⚠️ زیاد SSH زدن پشتِ سرِ هم یک بار «Permission denied» با رمزِ درست داد؛ با
|
||
`ControlMaster` (سوکت در مسیرِ کوتاهِ `/tmp`، macOS سقفِ ۱۰۴ بایت دارد) یک اتصال باز کنید.
|
||
|
||
## وضعیت ۲۰۲۶-۰۹-۲۵ — این را اول بخوان
|
||
|
||
کیفیتِ دستیار و گیرِ نقشه (جزئیات و دلیلها در `docs/architecture.md`). **۵۷۹ تست سبز.**
|
||
**روی سرور است:** `42783f9` دیپلوی و سرویس واقعاً ریاستارت شد (۲۰۲۶-۰۹-۲۵ ۰۶:۲۶)،
|
||
migrationها اعمال شدند، سهیار و `nextLessonId` از بیرون تأیید شدند. بکاپِ پیش از آن:
|
||
`.deploy-backups/db-before-audit-fixes-20260925-062458.sqlite3`. پوشهی `.bench` پاک شد.
|
||
|
||
- **پرامپت `tutor-v3-stepwise-plain`**: متنِ ساده (بی LaTeX/markdown، با تورِ ایمنیِ
|
||
`rag/services/mathtext.py`)، حلِ کامل و مرحلهبهمرحله با وارسی و «✅ جواب»، لحنِ
|
||
سهسطحی، حافظهی ۶ پیامِ آخرِ گفتگو، و حذفِ راهحلهای `disagrees` از context.
|
||
- **جزیرهها**: فقط درسهای سوالدار شمرده میشوند، `nextLessonId` + `lessonIds[0]`
|
||
همیشه درسِ بعدی است، و جزیرهی تمامشده تمام میماند. بدونِ این، فرانتِ فعلی
|
||
(که همیشه `lessonIds[0]` را باز میکند) هیچ جزیرهای را تمام نمیکرد.
|
||
- **سهیار** (`sigma`/`delta`/`pi`، باز شدن با ۰/۱۰۰/۳۰۰ امتیاز) با migration
|
||
`progress.0004`؛ کاتالوگ روی سرور خالی بود.
|
||
- **`/api/health` حالا تمام شدنِ اعتبارِ GapGPT را نشان میدهد** (`aiProvider`).
|
||
- ⛔ **اعتبارِ GapGPT در ۲۰۲۶-۰۹-۲۵ تمام شد** (وسطِ `rag_model_benchmark`؛ مانده
|
||
−$0.0016). تا شارژ، دستیار برای همه ۵۰۳ میدهد. انتخابِ مدلِ چت منتظرِ همین است:
|
||
هزینهی واقعیِ هر پاسخ با پرامپتِ تازه — gpt-4o-mini ۰٫۰۴۴، gpt-4.1-mini ۰٫۱۱۶،
|
||
gpt-5-mini ۰٫۱۷۱، gpt-5.4-mini ۰٫۲۹، o4-mini ۰٫۴۱ سنت. دقتشان هنوز اندازه نشده.
|
||
**تصمیمِ صاحبِ پروژه (۰۹-۲۵): فعلاً gpt-4o-mini میماند** (۰۹-۲۶ با سنجشِ بعدی به gpt-4.1-mini عوض شد) (ارزانترین). اولین
|
||
کاندیدا برای سنجش بعد از شارژ: `deepseek-v4-flash` (قیمتِ رسمی از 4o-mini کمتر،
|
||
ریاضیِ خیلی قویتر، ولی فارسی و حالتِ بدونِ thinkingاش اندازه نشده) و
|
||
`gpt-4.1-mini`. تیمِ Rag_backend برای جوابِ ریاضی `o3-mini` استفاده میکند.
|
||
- فرانت: PRِ ما (#3) بسته شد؛ هرچه فرانت باید بکند فقط در
|
||
`~/Desktop/کارهای-فرانت-تدریسینو.html` است (نه PR). شاخهی محلیِ
|
||
`zareian/ai-chat-readability` فقط مرجع است.
|
||
|
||
**بازبینیِ بدبینانهی همان روز و رفعها** (۵۷۹ تست سبز؛ جزئیات در `docs/architecture.md`):
|
||
پلن روی سفارش (باگِ «ماهانه بده، سالانه بگیر»)، صفحهی خرید اشتراکِ فعال را
|
||
دست نمیزند، تمدید از انقضای فعلی، یک کدِ تخفیف پیش از درگاه و کدِ ۱۰۰٪ = پرداخت،
|
||
بازسنجیِ خودکارِ پرداختِ تأییدنشده، حذفِ `/api/payment/webhook`، پورسانتِ affiliate
|
||
روی پرداختِ واقعی (مهمان بیرون)، امتیاز فقط از جوابِ ثبتشده، `HEARTS_ENFORCED`
|
||
(خاموش)، OTPِ ۶ رقمی با ۵ حدس، خروج از حساب (`tokens_valid_after`)، مهمانِ
|
||
ثبتنامکننده همان کاربر میماند، والدِ چندفرزندی، سقفِ ورودِ ادمین، پاکسازیِ نامِ
|
||
نمایشی، `questionId` در دستیار، SQLite با `transaction_mode: IMMEDIATE`.
|
||
**عمداً انجام نشد** (تصمیمِ صاحبِ پروژه: برای ارائه نقطهی شکست میسازند):
|
||
عوض کردنِ `DJANGO_SECRET_KEY`ِ سرور (همه را بیرون میاندازد) و مهاجرت به Postgres.
|
||
|
||
## وضعیت ۲۰۲۶-۰۹-۲۴
|
||
|
||
سرور روی `81348ce` به بعد است (سرویس واقعاً ریاستارت شد)، **۵۳۰ تست سبز**، ۶۰ اندپوینت. **پروژه در این تاریخ بسته شد**؛ فایلهای تحویل (گزارشِ ۲۲، راهنمای ارائه، zipِ بکاند) در `~/Desktop/گزارشات Tadrisino/گزارش-۲۲/` بهروزند. آن روز کلِ
|
||
فرانت (`MathApp_frontend`، upstream `main` = `d223336`) صفحهبهصفحه با API زنده
|
||
تطبیق داده شد و هرچه سمتِ بکاند کم بود ساخته شد. کارهای ماندهٔ فرانت در
|
||
`~/Desktop/کارهای-فرانت-تدریسینو.html` برای تیمِ فرانت فرستاده شد.
|
||
|
||
- **جزیره با آزمونش تمام میشود**: `POST /api/nodes/{id}/quiz/complete` (هر نمرهای؛
|
||
تصمیمِ صاحبِ پروژه). بدونِ آن دانشآموز بعد از جزیرهٔ اولِ هر فصل گیر میکرد.
|
||
- **«از پی بپرس» بدونِ lessonId هم از کتابِ پایهٔ کاربر جواب میدهد.**
|
||
- **استریک و جدولِ رتبهبندی زندهاند**؛ قبلاً هیچجای اپ استریک نمینوشت و جدول
|
||
فقط با اجرای دستی عوض میشد.
|
||
- **قلبها مثلِ دولینگو**: هر ۵ ساعت یکی، ۲۴ ساعت بعد از اولین قلبِ ازدسترفته همه.
|
||
- **پلنِ یکساله ۲۹۹٬۰۰۰ تومان** با migration ساخته شد؛ تنها مزیتِ واقعیِ اشتراک در
|
||
کد هنوز «قلبِ نامحدود» است (تصمیمِ محصولی).
|
||
- **زیرساخت**: `NUM_PROXIES=2` (ArvanCloud جلوی سرور است)، `gunicorn.conf.py` با
|
||
gthread ۳×۸ و timeoutِ ۱۲۰ (قبلاً سه چتِ همزمانِ دستیار کلِ API را قفل میکرد)،
|
||
`Cache-Control: private, no-store` روی همهٔ `/api/`، بازگشت از زرینپال به خودِ اپ،
|
||
refresh token برای مهمان، `PATCH /api/notifications/read-all`.
|
||
- **استریکِ حسابهای دمو از ۲۰۲۶-۰۹-۲۵ صفر نشان داده میشود** (آخرین روزِ فعالشان ۰۹-۲۳ است) و لیدربردِ هفتگی بعد از ۰۹-۳۰ از آنها خالی میشود؛ این رفتارِ درستِ استریکِ زنده است، نه باگ.
|
||
- **سرور هنوز روی کلیدِ `django-insecure-…` است** (۲۰۲۶-۰۹-۲۴ دوباره تأیید شد، فقط بهصورتِ بولین).
|
||
- **دستِ ما نیست**: GapGPT کلِ جواب را ~۵ ثانیه نگه میدارد و بعد یکجا میفرستد.
|
||
`CACHE_URL` (Redis db1) به تصمیمِ صاحبِ پروژه به ادمینِ سرور سپرده شد. پورتِ ۸۰ِ
|
||
سرور بدونِ آروان هم جواب میدهد (کارِ ادمین).
|
||
- بکاپهای پیش از هر دیپلو در `.deploy-backups/db-before-*-20260924-*.sqlite3`.
|
||
|
||
## وضعیت ۲۰۲۶-۰۹-۲۳ (اعداد کهنه)
|
||
|
||
پروژه در این تاریخ **بسته و تحویل داده شد**. سرور روی `api.math.tadrisino2.ir` زنده
|
||
است و کامیتِ `5003b8a` را سرو میکند (سرویس واقعاً ریاستارت شد، نه فقط فایلها روی
|
||
دیسک). `/api/health` میدهد: `grades: 8, lessons: 2617, questions: 2463` و هیچ
|
||
مهاجرتِ اعمالنشدهای ندارد.
|
||
|
||
- **هر نودِ پایههای هشتم تا دوازدهم آزمونِ کاملِ چهارسوالی دارد.** پایههای
|
||
سوم/ششم/هفتم کتابِ تیمِ دیگرند: ۳۹ نود از ۱۴۵ کمتر از چهار سوال دارند، ولی هیچکدام
|
||
خالی نیست، پس روی نقشه جزیرهی مرده وجود ندارد.
|
||
- ۴۸۹ تست سبز، `check` و `makemigrations --check` تمیز، و `spectacular` بدونِ خطا و
|
||
هشدار. ۵۶ اندپوینت.
|
||
- تستِ همزمانیِ campaigns که مدتها flaky بود **دیگر flaky نیست** — علتش یک باگِ
|
||
واقعی بود، نه طبیعتِ تست؛ پایینِ همین فایل توضیح داده شده.
|
||
|
||
**مستندات در ۲۰۲۶-۰۹-۲۳ بازسازی شد.** `README.md` حالا درِ ورودی است نه دفترچهی
|
||
راهنما؛ رویهها رفتند به `docs/operations.md` (دیپلوی، متغیرهای محیطی، بکاپ و
|
||
بازیابی، ورودِ توسعهدهندهها، قواعدِ سرور)، `docs/content-authoring.md`،
|
||
`docs/architecture.md` و `docs/docker.md`. **قبل از نوشتنِ هر سندِ تازه آنجا را نگاه
|
||
کن**، وگرنه همان حرف در سه جا تکرار میشود.
|
||
|
||
سه چیزِ کوچک که در همان روز اضافه شد و دانستنشان لازم است:
|
||
|
||
- **`TEST_LOGIN_PHONES`** — فهرستِ «شماره:کد» در `.env` که ورود بدونِ کاوهنگار را
|
||
ممکن میکند. از ۲۰۲۶-۰۹-۲۳ شب، به تصمیمِ صاحبِ پروژه، روی سرور **۷ شمارهی دمو**
|
||
دارد (`09120000101` تا `09120000107`). کدها فقط در راهنمای ارائهی خودِ صاحبِ پروژهاند،
|
||
نه در ریپو. جزئیات در `docs/operations.md`.
|
||
- **RAG روی سرور روشن است** (همان شب): `OPENAI_API_KEY`/`QDRANT_API_KEY` در `.env`
|
||
سرور، `/api/health` میدهد `qdrant: true, openai: true`. هر سؤالِ کاربرِ واقعی از
|
||
اعتبارِ GapGPT خرج میکند؛ سقفِ ۱۵ در روز برای هر کاربر آن را محدود نگه میدارد.
|
||
- **دیتای دمو روی دیتابیسِ پروداکشن**، همه از مسیرِ واقعیِ viewها ساخته شده: ۵ دانشآموز
|
||
(پایه ۸ تا ۱۲، کاربرهای ۱۰ تا ۱۴)، یک والد (۱۵، وصل به ۱۰)، و **حسابِ مهمانِ تستِ فرانت**
|
||
(`deviceId = frontend-test-device`، کاربرِ ۶) که پربارترین است. هرکدام درس، streak،
|
||
گفتگوی RAG، تیکت با پاسخ و زیرمجموعه دارند؛ لیدربرد با `rebuild_leaderboard` از فعالیتِ
|
||
واقعی ساخته شد. `purge_demo_data` هیچکدام را نمیگیرد و باید پیش از آمدنِ کاربرِ واقعی
|
||
پاک شوند. بکاپِ پیش از آن: `.deploy-backups/db-before-demo-20260923-213830.sqlite3`.
|
||
- **idِ هر پایه = شمارهی پایه** (migrationِ `content.0008`): هشتم=۸، دوازدهم=۱۲،
|
||
و فیلدِ `Grade.number`. پیش از آن هشتم=۵ و نهم=۱ بود. FKِ Unit، `grade_id`ِ
|
||
accounts/ai_tutor/rag و `target_filter`ِ کمپینها همراهش منتقل شدند؛ ادمین و API
|
||
به ترتیبِ کلاساند. شرح در `docs/architecture.md` بخشِ دیتابیس.
|
||
- **صفحههای `/api/docs/` و `/api/redoc/` از قالبِ خودمان رد میشوند**
|
||
(`templates/tadrisino/`) تا متنِ فارسی راستبهچپ رندر شود. قالبِ drf-spectacular
|
||
دستنخورده است و با `{{ block.super }}` گسترش داده شده.
|
||
|
||
**بکاپ:** `deploy.sh` پیش از `migrate` خودش بکاپ میگیرد (`db-before-deploy-*`، ۳ تای آخر، بیبکاپ
|
||
migrate نمیشود) و مرحلهی «نگهداریِ بکاپها» بقیهی `.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`.
|
||
|
||
بخشهای تاریخیِ زیر بهترتیبِ زمان نگه داشته شدهاند چون درسهایشان هنوز معتبر است؛
|
||
**اعدادِ محتواییشان کهنهاند و ملاک همین بخش است.**
|
||
|
||
---
|
||
|
||
### تستِ flakyِ campaigns یک باگِ واقعی بود (۲۰۲۶-۰۹-۲۲)
|
||
|
||
`ExecuteCampaignRealConcurrencyTests` ماهها بهعنوانِ «flakyِ شناختهشده» کنار گذاشته
|
||
شده بود (~۴۵٪ قرمز روی اجرای کاملِ سوئیت). علتش تصادفیبودنِ زمانبندی نبود:
|
||
|
||
`execute_campaign` با `.iterator()` روی کاربرها حلقه میزد، یعنی **کرسرِ خواندن در
|
||
تمامِ طولِ حلقه باز میماند** — حلقهای که وسطش `CampaignDelivery` مینویسد و در شاخهی
|
||
sms یک فراخوانیِ شبکهای به کاوهنگار هم میکند. روی SQLite همین کرسرِ باز جدول را برای
|
||
هر نویسندهی همزمان قفل میکند، و قفل تا پایانِ حلقه رها نمیشود — پس
|
||
`_retry_on_sqlite_lock` هیچوقت نمیتوانست نجاتش دهد، هر چقدر هم صبر میکرد.
|
||
|
||
حالا هر دو شاخه پیش از اولین نوشتن، نتیجه را با `list(...values_list(...))` مادی
|
||
میکنند. بعد از این تغییر: ۲۰ اجرای پیاپیِ خودِ تست و ۳ اجرای کاملِ سوئیت، همه سبز.
|
||
|
||
⚠️ **درسِ کلی: در این کدبیس `.iterator()` را دورِ حلقهای که مینویسد یا I/O بیرونی
|
||
میزند نگذارید.** تا وقتی روی SQLite هستیم، این الگو در پروداکشن هم همان قفل را
|
||
میسازد — آنجا بهشکلِ «database is locked» برای درخواستهای بیربطِ دیگر.
|
||
|
||
## تاریخچه — وضعیت ۲۰۲۶-۰۹-۱۷ (اعداد کهنه)
|
||
|
||
**The server is live and running current code.** `main` is at the latest commit, the
|
||
service `gunicorn-mathapp` was restarted against it, and `/api/health` reports
|
||
`grades: 6, lessons: 1928, questions: 1441`. `/api/nodes/1/quiz` returns a real
|
||
four-question quiz. 435 tests green.
|
||
|
||
What that took, and the lessons worth keeping:
|
||
|
||
1. **The server ran two-day-old code while every file on disk was current.** The pull and
|
||
`seed_content` had happened; the service had simply never been restarted, so gunicorn
|
||
still served the code it loaded on Sep 15. WhiteNoise builds its static-file index at
|
||
startup too, which is why `collectstatic` had run and the admin panel still had no CSS.
|
||
**A file on disk is not a deployed file.** Check
|
||
`systemctl show <service> -p ActiveEnterTimestamp` before believing anything.
|
||
2. **`deploy.sh` reported success while this was true.** It health-checked a public URL
|
||
that the *old* process answered with 200. It now compares the service start time
|
||
before and after, and exits non-zero when the restart did not happen.
|
||
3. **Two files had been hand-edited on the server** (`config/urls.py`, `deploy.sh`), both
|
||
only to add a trailing slash to the health route. That is why `git pull` kept
|
||
conflicting. `api/health/` is now registered upstream alongside `api/health`, so the
|
||
edits were reverted and are not needed again.
|
||
|
||
Still open, and deliberately not acted on:
|
||
|
||
- **`DJANGO_SECRET_KEY` on the server is the `django-insecure-…` fallback committed in
|
||
`config/settings.py`.** Auth tokens are signed with it, so anyone who can read this repo
|
||
can forge one. Fixing it means removing the key from `.env` *and* from the systemd unit
|
||
and letting settings generate one. The owner decided this is the server admin's call,
|
||
not ours. `manage.py check --deploy` flags it as `security.W009` on every release.
|
||
- `CACHE_URL` is unset, so rate limiting is per-worker (`LocMemCache`). (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 را کش میکند.
|