669 lines
51 KiB
Markdown
669 lines
51 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.
|
||
|
||
## Before any real user: read `docs/security-before-production.md`
|
||
|
||
The server is deliberately left in demo mode for the internship presentation: the token
|
||
signing key is the one committed in this repo, seven fixed-code demo logins exist, demo
|
||
users sit in the production DB, and `/api/health`, the API schema, the leaderboard and
|
||
`/admin/` are all public. 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 record the date and commit of each fix in that file rather than deleting the item.
|
||
|
||
## 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 it is still unset (left to the server admin, 2026-09-24).
|
||
|
||
No pytest; uses Django's built-in test runner. No linter/formatter configured.
|
||
|
||
Docker (`docker compose up --build`) adds a local Loki+Grafana Alloy+Grafana log stack; not a production setup (still `runserver`, not gunicorn). See comments in `docker-compose.yml`, `docker/alloy/config.alloy`, and `LOG_DIR` in `config/settings.py`.
|
||
|
||
API docs: Swagger `/api/docs/`, Redoc `/api/redoc/`, admin `/admin/`.
|
||
|
||
## Architecture
|
||
|
||
**Apps** (each owns its own models, one responsibility):
|
||
|
||
| app | responsibility | key models |
|
||
|---|---|---|
|
||
| `accounts` | identity, sessions, OTP auth | `User`, `Profile`, `Session`, `OTPCode` |
|
||
| `content` | lessons/curriculum | `Grade`, `Unit`, `Node`, `Lesson`, `Question`, `Choice`, `NodeProgress` |
|
||
| `progress` | stats, leaderboard, streaks, gamification characters | `UserStats`, `LeaderboardEntry`, `StreakRecord`, `UserHearts`, `Character` |
|
||
| `ai_tutor` | AI assistant chat | `Conversation`, `Message`, `MessageFeedback` |
|
||
| `subscription` | plans | `Plan`, `Subscription` |
|
||
| `payment` | orders/transactions (Zarinpal gateway + Cafe Bazaar IAP) | `Order`, `Transaction` |
|
||
| `discount` | discount codes/offers | `DiscountCode`, `Redemption`, `Offer`, `UserOffer` |
|
||
| `notifications` | notifications | `Notification` |
|
||
| `affiliate` | referral program | `AffiliateAccount`, `Conversion` |
|
||
| `support` | tickets/bug reports | `BugReport`, `Ticket`, `TicketMessage` |
|
||
| `family` | parental control | `FamilyLink`, `ParentalSettings`, `WeeklyReportLog` |
|
||
| `statuslog` | generic cross-app status-change log | `StatusChangeLog` |
|
||
| `campaigns` | messaging campaign storage only, no send logic yet | `MessageCampaign` |
|
||
| `config` | project settings, root urls, middleware | — |
|
||
|
||
**Auth is custom, not Django's.** `accounts.User` is deliberately *not* `AUTH_USER_MODEL` — it's an independent business model. Auth is a stateless signed bearer token (`django.core.signing`, no JWT library) issued/verified in `accounts/authentication.py` (`TadrisinoTokenAuthentication`, header `Authorization: Bearer <token>`). The access token is only a signed `user_id` (1 h); `accounts.Session` holds refresh tokens, and `POST /api/auth/logout` revokes older access tokens via `User.tokens_valid_after`. Missing/invalid header → anonymous request, not a hard failure; each view's permission class decides if auth is required.
|
||
|
||
**Apps are decoupled by convention**: instead of real FKs across app boundaries, code uses loose refs (`user_id`, `object_id` + `label`) so any app can be split out later. Don't add cross-app FKs — follow the existing loose-reference pattern.
|
||
|
||
**Throttling**: DRF `ScopedRateThrottle` with per-endpoint `throttle_scope` strings, rates centralized in `REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']` in `config/settings.py`. Guest users are throttled by IP (`NUM_PROXIES` setting controls how many XFF hops are trusted — must match real proxy count in prod or IP spoofing/throttle-bypass is possible). Prod is ArvanCloud → nginx → gunicorn, so the default is **2**; it was 1 until 2026-09-24, which made every guest share ArvanCloud's IP bucket. `/api/health` returns `clientIp` — it should be your own public IP. Adding a new sensitive endpoint means adding both a `throttle_scope` on the view and a matching rate entry in settings.
|
||
|
||
**`POST /api/ai/ask`** is real RAG (Qdrant retrieval filtered by chapter → GapGPT generation) whenever `OPENAI_API_KEY` and `QDRANT_API_KEY` are set, which they are on the server since 2026-09-23; without them it falls back to a deterministic stub. Entry point is `generate_tutor_answer` in `ai_tutor/views.py`; `usedChunks` in the response names the book chunks the answer was grounded on.
|
||
|
||
**Structured logging**: `config.middleware.APIRequestLoggingMiddleware` (last in `MIDDLEWARE`, closest to the view) logs every `/api/` request as JSON (method/path/status/duration/user, no body/headers/secrets) via `config/log_formatters.py`.
|
||
|
||
**SQLite** is current DB (`OPTIONS.timeout=20`, and `transaction_mode: IMMEDIATE` so every `atomic()` takes the write lock up front — SQLite ignores `select_for_update`, so without it those guards locked nothing). Tests run on a file DB (`.test-db.sqlite3`). Postgres migration is still pending.
|
||
|
||
**SMS** goes through Kavenegar (`KAVENEGAR_API_KEY`/`KAVENEGAR_SENDER`); failures return explicit 503, not a fake success.
|
||
|
||
**Gamification characters** (`progress.Character`, Duolingo-mascot-style): an admin-managed catalog (like `subscription.Plan` — created via Django admin or `seed_test_data`, not by users). Unlocking is currently just `UserStats.xp >= Character.unlock_xp` (no separate ownership table); `UserStats.selected_character_id` is the user's active pick, set via `POST /api/characters/select` after that check. First real content beyond the model/plumbing (art assets, more characters, purchase-with-coins, etc.) is expected to land incrementally later.
|
||
|
||
**Payment (Zarinpal)**: `create_order`/`CreateOrderView` never call Zarinpal directly — `paymentUrl` points at our own `GET /api/payment/orders/{id}/pay` (`PaymentRedirectView`), which calls Zarinpal's PaymentRequest API on first visit (storing `Order.gateway_authority`) and redirects to StartPay; this keeps order creation from depending on Zarinpal's uptime. Zarinpal redirects the user back to `GET /api/payment/zarinpal/callback` (`ZarinpalCallbackView`), which always re-verifies server-to-server via `payment/zarinpal.py::verify_payment` before marking an order paid — a forged `Status=OK` in the query string alone does nothing. Both `payment/zarinpal.py` and `payment/bazaar.py` fail closed (503 / invalid) when their credentials aren't configured, never a fake success.
|
||
|
||
## Where this is heading (the owner's intent)
|
||
|
||
The owner writes this backend; another developer writes the frontend; a third person
|
||
administers the server. None of them share a machine, and until recently the two halves
|
||
were built in parallel without a shared contract — which is why reconciling them cost
|
||
rework. The goal is not to merge the repos. **Two repos, one API contract** is the
|
||
intended shape, and the contract lives in this repo's OpenAPI schema (`/api/docs/`), not
|
||
in anyone's head.
|
||
|
||
What the owner is steering toward, in rough order:
|
||
|
||
1. **Both halves running against each other**, on real content, with nobody hand-editing
|
||
files on the server. Most of the plumbing for this now exists.
|
||
2. **Nothing manual in a release.** Adding a dependency, a setting or a service must not
|
||
add a step someone has to remember. When a change would, fold it into `deploy.sh` or
|
||
make startup fail loudly and legibly instead.
|
||
3. **Content editable without a developer** — questions, curriculum, plan copy, all from
|
||
the admin panel. A management command run over SSH is a last resort, not a workflow.
|
||
4. **Answer quality measurable**, not assumed: per-question reports, wrong-answer rates,
|
||
prompt-version feedback. The senior's stated priority is proving the AI actually
|
||
teaches, especially where students get stuck.
|
||
5. **Scale without drama** — thousands of concurrent users should be a non-event. Prefer
|
||
the boring fix (an index, a cache, a cheap guard) over new infrastructure.
|
||
|
||
The owner is early in their career and learning the collaboration workflow as they go.
|
||
Explain the *why* alongside the *what*, flag when something is a product decision rather
|
||
than a coding one, and never assume a step was done just because it was described.
|
||
|
||
## Frontend ↔ backend (read before wiring the two together)
|
||
|
||
The app is two repos and two domains: the React frontend lives in
|
||
`Tadrisino/MathApp_frontend` (deployed at `math.tadrisino2.ir`, owned by another
|
||
developer) and this backend serves `api.math.tadrisino2.ir`. Our typed client for them
|
||
is `src/api/` inside their repo, delivered by pull request — we have read-only Gitea
|
||
access there, so changes go via a fork + PR, never a direct push.
|
||
|
||
**The one trap that keeps biting.** Their `App.tsx` calls relative paths
|
||
(`fetch('/api/auth/login')`). Those resolve against the *frontend* origin, where nginx
|
||
answers any unknown path with `index.html` — so the response is `200` with an HTML body
|
||
and the code dies on `res.json()` with `Unexpected token '<'`. Verify with
|
||
`curl https://math.tadrisino2.ir/api/grades`: HTTP 200, HTML. Two fixes exist, both
|
||
shipped in PR #3: a `vite` dev proxy (dev works unchanged) and an exported `apiUrl()`
|
||
for builds. **The better long-term fix is infrastructure, not code** — proxy `/api` on
|
||
the frontend domain to this backend, which makes everything same-origin and removes CORS
|
||
entirely. That needs the server admin, not us.
|
||
|
||
**State on 2026-09-23 (checked against upstream `main`, last commit 2026-09-21):** the
|
||
screens are wired to the API (PR #4/#5 upstream), but `App.tsx` builds the client with
|
||
`env.VITE_API_URL ?? "/api"` — while their own `.env.example` and `client.ts` name the
|
||
variable `VITE_API_BASE_URL`. The deployed bundle has neither, so it calls the relative
|
||
`/api` and gets HTML back: **math.tadrisino2.ir cannot reach the backend** until either
|
||
that line falls back to `https://api.math.tadrisino2.ir/api` or nginx proxies `/api`.
|
||
Our PR #3 fixed the same trap and is still open, now likely conflicting with their
|
||
rewrite. Every path their client calls exists and was driven live against the server.
|
||
The app also generates a random `deviceId` per browser, so the seeded
|
||
`frontend-test-device` account only loads after
|
||
`localStorage.setItem("deviceId", "frontend-test-device")`.
|
||
|
||
**Order matters when bringing the stack up:** backend first, then frontend. If the
|
||
frontend is tested while `api.math.tadrisino2.ir` is down, every screen fails and it
|
||
looks like the client is broken.
|
||
|
||
**Never hand-write the client from memory.** Three of fifteen calls were silently wrong
|
||
the first time. Dump the real contract with
|
||
`python manage.py spectacular --file schema.yaml --format openapi`, then drive the actual
|
||
client module against a running server (`runserver` on a spare port + `npx tsx`) before
|
||
sending anything. `content/tests_frontend_contract.py` is the backend-side guard.
|
||
|
||
**Content the frontend expects but nobody has filled in yet:** `Unit.blurb`,
|
||
`Plan.features`/`badge`, `Lesson.example`/`steps`. All optional, all editable from the
|
||
admin panel. `Node.icon`/`color` fall back to a deterministic palette so cards are never
|
||
colourless.
|
||
|
||
## Operations
|
||
|
||
**The whole release, as one line:**
|
||
|
||
```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. Questions bind to lessons **by title path**
|
||
(grade › unit › node › lesson), never by primary key — ids differ between any two
|
||
databases, and binding by id silently attaches questions to the wrong lesson.
|
||
|
||
`seed_content --export` writes the files back from the local DB. It refuses to overwrite a
|
||
non-empty `questions.json` from a DB that has no questions (that used to wipe the file
|
||
silently); use `--export --only curriculum` when you only mean the curriculum.
|
||
|
||
## Answer quality: the verification layer
|
||
|
||
Books are read by a **vision model from page images**, so extraction errors enter there —
|
||
a misread digit, a dropped decimal separator, a solution attached to the neighbouring
|
||
problem. These are invisible in retrieval and only surface when something trusts the text.
|
||
|
||
`rag_verify_solutions` re-solves every extracted problem independently and compares.
|
||
`LessonChunk.solution_verdict` is three-state — `agrees` / `disagrees` / `unjudgeable` —
|
||
and the verdict also rides in the Qdrant payload under a key `retrieval._payload_text`
|
||
does not read, so the tutor's context is unchanged. Current state (2026-09-22, across
|
||
all 3,517 chunks): 2,466 agree, 354 disagree, 508 unjudgeable, 189 not yet checked —
|
||
those 189 are the other team's علوم/مطالعات collections, never run through the checker.
|
||
**Question generation only ever reads `agrees` chunks.**
|
||
|
||
Two things that were learned the expensive way and are already encoded in the prompts:
|
||
|
||
- A boolean verdict is wrong. With two states every "I can't tell" becomes "wrong", which
|
||
reported ~3× the real rate. Read samples before trusting any bulk run.
|
||
- `۳/۱۰۴` in these books is the decimal 3.104, not the fraction 3÷104.
|
||
|
||
`solution` itself is never rewritten — the extracted text stays as the record, and the
|
||
independent answer is stored separately in `checked_answer`.
|
||
|
||
## The figure gap (largest open data issue)
|
||
|
||
508 chunks (~14%) reference a figure or table that is not in the text, so they cannot be
|
||
answered or turned into questions. **The vision model saw the figure; it was simply never
|
||
asked to write it down.** `rag/services/pdf.py` now carries a self-contained rule telling
|
||
it to inline measurements and labels into the problem text, and re-reading the five math
|
||
books under that rule is what brought this down from 881 (~42%). What is left is
|
||
concentrated in the three books that predate the rule (سوم/ششم/هفتم, the other team's);
|
||
recovering them means re-ingesting those pages (~$2.7 of API credit).
|
||
|
||
## Quiz assembly
|
||
|
||
The app plays at **node** level (the island on the map) but questions hang off **lessons**,
|
||
and a lesson here is the finest grain — one problem type from the book, not a class
|
||
session. `GET /api/nodes/{id}/quiz` assembles a quiz from a node's lessons, at most one
|
||
question per lesson. Shuffle is seeded on `nodeId:userId`, so a refresh returns the same
|
||
quiz; `questionCount` is reported separately from `requested` so a thin node cannot look
|
||
like an error. 224 of 263 nodes hold a full four-question quiz, and every node in
|
||
grades 8–12 does; the 39 that fall short are all in grades 3/6/7.
|
||
|
||
Finishing the quiz finishes the island: `POST /api/nodes/{id}/quiz/complete` marks the node
|
||
completed whatever the score (the owner's call on 2026-09-24: keep it simple) and returns
|
||
`nextNodeId`. Before it existed, a node only completed when every one of its 8–13 lessons
|
||
was completed one by one, so a student playing node quizzes was stuck after the first
|
||
island of every unit. Details in `docs/architecture.md`.
|
||
|
||
## AI provider notes
|
||
|
||
`OPENAI_BASE_URL` points at GapGPT (an OpenAI proxy reachable from Iran) and it exposes
|
||
~127 models, including reasoning ones. Two failures look alike and must not be treated
|
||
alike — `is_account_limit()` in `rag_verify_solutions` is the shared guard:
|
||
|
||
- `429 initial_usage_limit_exceeded` — account cap, hours long. Retrying **extends** it.
|
||
- `403 insufficient user quota` — credit exhausted. Note the spaces: an earlier guard
|
||
matched `insufficient_quota` with an underscore, missed it, and burned 770 doomed calls.
|
||
|
||
`/dashboard/billing/usage` returns cents and is the only reliable balance signal; the
|
||
`hard_limit_usd` in `/dashboard/billing/subscription` is a placeholder, not a balance.
|
||
|
||
**The key is currently out of credit.** Bulk work since then runs on Claude Code subagents
|
||
instead. Sonnet handles question authoring well (~88% yield); Haiku does not — it cannot
|
||
reliably pull the final answer out of a solution and produced ~40% broken output.
|
||
|
||
## Known gaps (per README pre-deploy checklist)
|
||
|
||
- Still on SQLite, not Postgres.
|
||
- Throttle rates need periodic review against real traffic.
|
||
- `DEV_MASTER_OTP` (`0000`) must stay gated behind `DEBUG` — never enabled in prod.
|
||
- No video content exists yet, though `Lesson.kind`/`video_url` can express it.
|
||
- Grades 8–12 are fully covered: every node holds a four-question quiz. 39 of the 145
|
||
nodes in grades 3/6/7 (the other team's books) still hold fewer than four; none is
|
||
empty, so every node still plays.
|
||
- Kavenegar is live on the server since 2026-09-25 (`/api/health` → `sms: true`), so every
|
||
OTP request costs real credit. Real SMS *delivery* to a phone was not yet observed by us;
|
||
sending is plain `sms/send` from the account's default line, not a `verify/lookup`
|
||
template, so numbers that block promotional SMS may never get the code. 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
|
||
|
||
`api.math.tadrisino2.ir` runs on a **shared company machine** (`administrator@78.38.35.249`,
|
||
hostname `haghzad`), not a box that belongs to this project. Our app is one tenant among
|
||
many. Also on it, all in production use by other people:
|
||
|
||
- **Gitea itself** (`gitea.service`) — the company's git server, including this repo's
|
||
origin. Breaking it means nobody can push or pull anything, anywhere.
|
||
- Mail (`postfix`, `dovecot`), DNS (`named`), `mariadb`, `postgresql@14`, `rabbitmq`,
|
||
`metabase`, `redis-server`, `nginx`.
|
||
- Other apps: `apirag`, `artinafront`, `metaverse`, `celerybeat`, and a **generic
|
||
`gunicorn.service` that is not ours**.
|
||
- Other people's home directories: `aria`, `artina`, `behdad`, `zahra`, `git`, `gitea`.
|
||
They are mode `0750` — unreadable as `administrator`, trivially readable under `sudo`.
|
||
Do not read them. Nothing in this project needs them.
|
||
|
||
**Our account is `administrator`, and `sudo -l` reports `(ALL : ALL) ALL`.** It is also in
|
||
the `docker` and `lxd` groups, each of which is root by another route. There is no
|
||
technical guard rail between a careless command and the whole company's data. The only
|
||
guard rail is the operator.
|
||
|
||
Ours is exactly one directory: **`/home/administrator/mathapp-v2`**, served by
|
||
**`gunicorn-mathapp.service`** (unix socket, proxied by nginx). Note `~/mathapp` also
|
||
exists, is not a git repo, and is not what runs — do not touch it.
|
||
|
||
The same unit also answers to **`zareian-mathapp`** (a symlink alias in
|
||
`/etc/systemd/system/`, added 2026-09-18). Prefer that name in anything a human will type
|
||
or copy: `gunicorn` alone is a *different team's* service on this box, and `tadrisino` is
|
||
too generic for a machine where other people's projects may claim it later.
|
||
|
||
```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.
|
||
|
||
### 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.**
|
||
|
||
## وضعیت ۲۰۲۶-۰۹-۲۵ (عصر) — پیامکِ واقعی روشن شد
|
||
|
||
**روی سرور:** `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.
|
||
جزئیات در بندِ ۱۷ِ `docs/security-before-production.md`، تستها در `accounts/tests_otp_limits.py`.
|
||
- مسیرِ کاملِ کاربرِ تازه روی سرورِ زنده سبز شد، **بدونِ پیامکِ واقعی**: کد مستقیم در
|
||
دیتابیس ساخته شد. کاربرِ تستیِ آن **کاربرِ ۱۷ با شمارهی `09000000001`** (نام «کاربر
|
||
تست»، یک جزیره تمامشده) روی پروداکشن مانده و مثلِ دادهی دمو پیش از کاربرِ واقعی باید پاک شود.
|
||
- رسیدنِ پیامک به یک گوشیِ واقعی را هنوز ما ندیدهایم؛ اولین تستِ سینیور همین است.
|
||
- ⚠️ زیاد 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 میماند** (ارزانترین). اولین
|
||
کاندیدا برای سنجش بعد از شارژ: `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` بکاپ نمیگیرد؛ روشِ دستی در
|
||
`docs/operations.md` است و در سه دیپلویِ آن روز دستی گرفته شد. خودکار کردنش
|
||
ارزشمندترین کارِ بعدیست. `sqlite3` روی سرور نصب نیست — با `venv/bin/python` و
|
||
`sqlite3.Connection.backup()` بکاپ بگیر، نه با `cp`.
|
||
|
||
بخشهای تاریخیِ زیر بهترتیبِ زمان نگه داشته شدهاند چون درسهایشان هنوز معتبر است؛
|
||
**اعدادِ محتواییشان کهنهاند و ملاک همین بخش است.**
|
||
|
||
---
|
||
|
||
### تستِ flakyِ campaigns یک باگِ واقعی بود (۲۰۲۶-۰۹-۲۲)
|
||
|
||
`ExecuteCampaignRealConcurrencyTests` ماهها بهعنوانِ «flakyِ شناختهشده» کنار گذاشته
|
||
شده بود (~۴۵٪ قرمز روی اجرای کاملِ سوئیت). علتش تصادفیبودنِ زمانبندی نبود:
|
||
|
||
`execute_campaign` با `.iterator()` روی کاربرها حلقه میزد، یعنی **کرسرِ خواندن در
|
||
تمامِ طولِ حلقه باز میماند** — حلقهای که وسطش `CampaignDelivery` مینویسد و در شاخهی
|
||
sms یک فراخوانیِ شبکهای به کاوهنگار هم میکند. روی SQLite همین کرسرِ باز جدول را برای
|
||
هر نویسندهی همزمان قفل میکند، و قفل تا پایانِ حلقه رها نمیشود — پس
|
||
`_retry_on_sqlite_lock` هیچوقت نمیتوانست نجاتش دهد، هر چقدر هم صبر میکرد.
|
||
|
||
حالا هر دو شاخه پیش از اولین نوشتن، نتیجه را با `list(...values_list(...))` مادی
|
||
میکنند. بعد از این تغییر: ۲۰ اجرای پیاپیِ خودِ تست و ۳ اجرای کاملِ سوئیت، همه سبز.
|
||
|
||
⚠️ **درسِ کلی: در این کدبیس `.iterator()` را دورِ حلقهای که مینویسد یا I/O بیرونی
|
||
میزند نگذارید.** تا وقتی روی SQLite هستیم، این الگو در پروداکشن هم همان قفل را
|
||
میسازد — آنجا بهشکلِ «database is locked» برای درخواستهای بیربطِ دیگر.
|
||
|
||
## تاریخچه — وضعیت ۲۰۲۶-۰۹-۱۷ (اعداد کهنه)
|
||
|
||
**The server is live and running current code.** `main` is at the latest commit, the
|
||
service `gunicorn-mathapp` was restarted against it, and `/api/health` reports
|
||
`grades: 6, lessons: 1928, questions: 1441`. `/api/nodes/1/quiz` returns a real
|
||
four-question quiz. 435 tests green.
|
||
|
||
What that took, and the lessons worth keeping:
|
||
|
||
1. **The server ran two-day-old code while every file on disk was current.** The pull and
|
||
`seed_content` had happened; the service had simply never been restarted, so gunicorn
|
||
still served the code it loaded on Sep 15. WhiteNoise builds its static-file index at
|
||
startup too, which is why `collectstatic` had run and the admin panel still had no CSS.
|
||
**A file on disk is not a deployed file.** Check
|
||
`systemctl show <service> -p ActiveEnterTimestamp` before believing anything.
|
||
2. **`deploy.sh` reported success while this was true.** It health-checked a public URL
|
||
that the *old* process answered with 200. It now compares the service start time
|
||
before and after, and exits non-zero when the restart did not happen.
|
||
3. **Two files had been hand-edited on the server** (`config/urls.py`, `deploy.sh`), both
|
||
only to add a trailing slash to the health route. That is why `git pull` kept
|
||
conflicting. `api/health/` is now registered upstream alongside `api/health`, so the
|
||
edits were reverted and are not needed again.
|
||
|
||
Still open, and deliberately not acted on:
|
||
|
||
- **`DJANGO_SECRET_KEY` on the server is the `django-insecure-…` fallback committed in
|
||
`config/settings.py`.** Auth tokens are signed with it, so anyone who can read this repo
|
||
can forge one. Fixing it means removing the key from `.env` *and* from the systemd unit
|
||
and letting settings generate one. The owner decided this is the server admin's call,
|
||
not ours. `manage.py check --deploy` flags it as `security.W009` on every release.
|
||
- `CACHE_URL` is unset, so rate limiting is per-worker (`LocMemCache`). Redis is already
|
||
running on the box at `127.0.0.1:6379`; `db0` belongs to another app, `db1` is free.
|
||
- The frontend is still not wired to any API. `src/api/` was merged (PR #1) but is
|
||
imported nowhere; `QuestionScreen` in `App.tsx` still renders four hardcoded
|
||
multiplication questions, and **PR #3** (`zareian/api-client-2`) is still open upstream.
|
||
Until someone connects the screens, none of the 1,441 questions are visible to a user.
|
||
|
||
The frontend's `Question` type carries a `type` field that nothing reads, and
|
||
`count_objects` in `Question.TYPE_CHOICES` is an empty shell — no rows, no serializer
|
||
branch, no rendering anywhere. Do not build content for it without asking first.
|
||
|
||
## تاریخچه — وضعیت ۱۴۰۵/۰۶/۲۹ (۲۰۲۶-۰۹-۲۰) (اعداد کهنه)
|
||
|
||
سرور روی `3026df0` است، کد و محتوا هر دو منتشر شدند، سرویس واقعاً ریاستارت شد، و
|
||
`/api/health` عدد `questions: 1783` میدهد. `/api/nodes/1/quiz` چهار سوالِ واقعی برمیگرداند.
|
||
|
||
**۵ کتاب روی Qdrant، همه با دیتابیس همگام:** ریاضی هشتم (۵۱۸)، نهم (۴۵۰)، دهم (۴۵۸)،
|
||
حسابان یازدهم (۳۷۶)، حسابان دوازدهم (۳۲۶). صفحهی گمشده: صفر. سه کتابِ تیمِ دیگر
|
||
(سوم/ششم/هفتم، مجموعاً ۱٬۲۵۲) عمداً دستنخورده ماندند.
|
||
|
||
خرجِ کلِ آن روز ۳٫۴۴ دلار بود. روشِ ارزان و دقیقِ استخراج در `docs/extraction-playbook.md`
|
||
نوشته شده — **قبل از هر ingest تازه آن را بخوان**، مخصوصاً بخشِ انتخابِ مدل.
|
||
|
||
### حسابان یازدهم و دوازدهم: وارد مسیرِ بازی شد (۲۰۲۶-۰۹-۲۱)
|
||
|
||
کاربر تصمیمِ محصولی را گرفت و هر دو پایه ساخته شدند. `seed_curriculum_from_textbook`
|
||
اسکلت را رایگان ساخت — یازدهم ۵ فصل/۲۱ نود/۳۶۹ درس، دوازدهم ۵ فصل/۱۲ نود/۳۲۰ درس — و
|
||
سوالها با subagentهای Sonnet نوشته شدند: **۵۵۹ سوال** (یازدهم ۳۲۲، دوازدهم ۲۳۷).
|
||
هر ۳۳ نودِ هر دو پایه آزمونِ کاملِ چهارسوالی دارند.
|
||
|
||
`GRADE_STYLE` حالا برای یازدهم/دوازدهم رنگ دارد (فیروزهای و نیلی، عمداً جدا از شش
|
||
پایهی عمومی چون حسابان فقط کتابِ رشتهی ریاضیفیزیک است).
|
||
|
||
### روالِ ساختِ سوال با subagent، حالا ابزار دارد
|
||
|
||
`generate_questions_from_chunks --export DIR` جفتهای (درس، مسئلهی تأییدشده) را در
|
||
فایلهای JSON میریزد و **هیچ فراخوانیِ پولی نمیکند**؛ کنارشان یک `*-RULES.txt` با
|
||
عینِ همان PROMPTی که به مدلِ پولی میرفت مینویسد، تا کیفیتِ دو شاخه از هم جدا نیفتد.
|
||
خروجیِ subagent با `content.question_io` وارد میشود (فایلِ بد هیچچیز نمینویسد).
|
||
|
||
بازده روی ۷۷۸ ورودی: **۶۸۰ سوال، ۸۷٪** — بقیه عمداً رد شدند، بیشترشان مسئلههایی که
|
||
بدونِ شکلِ کتاب خودکفا نمیشوند. چند subagent خطایِ حسابِ خودِ کتاب را هم گرفتند
|
||
(راهحلی که √ را ۶ نوشته بود ولی ۶٫۵ بود؛ راهحلی که میگفت حد وجود ندارد درحالیکه
|
||
داشت) — یعنی `solution_verdict='agrees'` تضمینِ کامل نیست، فقط فیلترِ اول است.
|
||
|
||
**باگی که همانجا پیدا و اصلاح شد:** اجرای دوم با `--per-lesson` بالاتر همان chunkِ
|
||
قبلی را دوباره تحویل میداد و سوالِ تقریباً تکراری میساخت. حالا `_targets` به اندازهی
|
||
سوالهایی که درس از قبل دارد از ترتیبِ قطعیِ chunkها میپرد. این خطا نه لاگ میداد، نه
|
||
در import گیر میافتاد (چون صورتِ سوال بازنویسی میشود و رشتهاش یکی نیست).
|
||
|
||
⚠️ **سقفِ هفتگیِ Sonnet واقعی است و ناگهانی میرسد.** ۲۰ subagentِ همزمان (هرکدام
|
||
~۹۵ هزار توکن برای ۳۰ سوال) آن را در یک نشست پر کرد. اگر کارِ انبوه داری، دستهها را
|
||
کوچکتر بگیر و بینشان نتیجه را وارد کن — خوشبختانه بیشترِ agentها فایلشان را نوشته
|
||
بودند و فقط در تماسِ آخر خوردند، پس چیزی از دست نرفت.
|
||
|
||
|
||
### چیزهایی که آن روز کشف شد و تکرارشان گران است
|
||
|
||
- **مدل را اندازه بگیر، از جدولِ قیمت نخوان.** قیمتِ GapGPT با قیمتِ رسمی یکی نیست:
|
||
`gpt-4.1-mini` هم از `gpt-4o` ارزانتر بود هم دقیقتر، و `gemini-3.6-flash` برخلافِ
|
||
انتظار سه برابرِ `gpt-4o` گران درآمد. `OPENAI_VISION_MODEL=gpt-4.1-mini` الان در `.env`
|
||
است و هر پنج کتاب با همان خوانده شد.
|
||
- **ساختِ سوال را با subagent انجام بده، نه با API.** `generate_questions_from_chunks`
|
||
پیشفرضش `o4-mini` است و ~۰٫۷۷ سنت برای هر سوال میگیرد (چهار برابرِ تخمینِ خودش، چون
|
||
مدلِ ریزنینگ است). همان کار با subagentهای Sonnet رایگان است و بازدهاش ۹۲٪ بود.
|
||
روالش: جفتهای (درس، مسئلهی تأییدشده) را به JSON بده، subagent خروجی بنویسد، و با
|
||
`content.question_io` واردش کن.
|
||
- **`--recheck` در `rag_verify_solutions` همهچیز را دوباره میفرستد**، نه فقط موارد تازه.
|
||
برای بازبینیِ یک زیرمجموعه، رأیشان را خالی کن و اجرای عادی بزن.
|
||
- **شمارندهی صفحات حینِ `--refresh` معنی ندارد** — هر صفحه اول پاک و بعد خوانده میشود،
|
||
پس عدد دورِ کل نوسان میکند. پیشرفت را از `IngestedPage.created_at` بسنج.
|
||
- **در `until`ِ انتظار، `ps | grep` خودش را میشمارد** و حلقه هرگز تمام نمیشود. از
|
||
`pgrep -f` استفاده کن.
|
||
- **کد و محتوا دو مسیرِ جدا دارند.** `db.sqlite3` در gitignore است؛ سوالها فقط از راهِ
|
||
`seed_content --export --only questions` و کامیتِ `content/seed/questions.json` به سرور
|
||
میرسند. پوش کردنِ کد بهتنهایی محتوا را نمیبرد.
|
||
|
||
### CORS
|
||
|
||
پورتهای توسعه دیگر شمرده نمیشوند؛ `CORS_ALLOWED_ORIGIN_REGEXES` هر پورتی روی
|
||
`localhost`/`127.0.0.1` را میپذیرد. اگر باز هم گزارشِ CORS رسید، اول بپرس **Hard Refresh**
|
||
زدهاند یا نه — مرورگر نتیجهی preflight را کش میکند.
|