Files
MathApp/CLAUDE.md
Zareian 07fab4c094 write down what the last two days changed about content and answer quality
CLAUDE.md تنها جایی است که هر جلسه‌ی تازه اول می‌خواندش، و چیزهایی که این دوره
یاد گرفتیم جایی ثبت نشده بود. چهار بخش اضافه شد و «وضعیتِ فعلی» به بالا آمد:

• محتوا حالا در content/seed/ کامیت می‌شود و deploy خودش بالا می‌آوردش — بدونِ
  Qdrant، بدونِ اعتبارِ AI، بدونِ SSH. و اینکه سوال با مسیرِ عنوان به درس وصل
  می‌شود نه با id، چون idها بینِ دو دیتابیس یکی نیستند.
• لایه‌ی راستی‌آزمایی: کتاب از روی تصویر خوانده می‌شود، پس خطا همان‌جا وارد
  می‌شود. برچسب سه‌حالته است و سوال فقط از agreesها ساخته می‌شود.
• دو درسی که گران تمام شدند: نتیجه‌ی دوحالته نرخ را سه برابر نشان می‌دهد، و
  «۳/۱۰۴» در این کتاب‌ها عددِ اعشاری است نه کسر.
• شکافِ شکل‌ها (۴۲٪ محتوا) و اینکه اصلاحش در پرامپت هست ولی اجرا نشده.
• تفاوتِ ۴۲۹ سقفِ حساب با ۴۰۳ اتمامِ اعتبار — و اینکه متنِ دومی فاصله دارد نه
  زیرخط، که یک بار ۷۷۰ فراخوانیِ بی‌فایده به بار آورد.

«وضعیت تا ۲۰۲۶-۰۹-۱۷» هم اضافه شد: بک‌اند آماده و تست‌شده است، و دو مانعِ
باقی‌مانده دستِ ما نیست — سرور روی کدِ قدیمی، و فرانتی که به هیچ APIی وصل نیست.
هر دو قبل از اینکه کسی دنبالِ باگ بگردد باید چک شوند.

هیچ فایلِ تازه‌ای اضافه نشد؛ همان فایلِ موجود به‌روز شد.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 05:59:19 +03:30

241 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
## 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 `CACHE_URL` (Redis — required whenever `DEBUG=False` since rate limiting needs a shared cache; raises `ImproperlyConfigured` otherwise).
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>`), backed by `accounts.Session`. 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). 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`** currently returns a stub; real model integration point is `generate_tutor_answer` in `ai_tutor/views.py`.
**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` to tolerate concurrent-write locks, e.g. campaigns tests); README notes 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.
**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
`./deploy.sh` is the whole release: pull, install, migrate, collectstatic, restart, then
it polls `/api/health` and prints the likely cause if the service does not come back.
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` (6 grades, 48 units, 230 nodes, 1,928 lessons) and `questions.json`
(1,441 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: 1,112 agree, 123
disagree, 881 unjudgeable. **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)
881 chunks (~42%) 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 — books ingested before that
rule predate it. 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. 172 of 230 nodes currently hold a full four-question quiz.
## 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.
- 58 of 230 nodes still hold fewer than four questions; their verified chunks ran out.
- Kavenegar is unconfigured, so `POST /api/auth/otp/request` returns 503. With `DEBUG=0`
there is no way to log into the API without creating an `OTPCode` row in the admin
(`/admin/accounts/otpcode/add/`) — this is how to hand a tester a working login.
## State as of 2026-09-17 — read this first
**Backend is done and verified; nothing is blocked on us.** The full game loop was driven
end to end against a fresh database with `DEBUG=0`: grades → login → set grade → home →
node quiz → submit answer → CORS header for the frontend origin. `main` is pushed and
clean at `16d3d9f`; 435 tests green.
Two things are blocked on other people, and both are worth checking before assuming a bug:
1. **The server still runs pre-16d3d9f code with an empty database.** `git pull &&
./deploy.sh` brings both code and content. Verify with `curl /api/health` — it should
report `questions: 1441`. A 404 there means the pull never landed.
2. **The frontend is 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. **PR #3** (`zareian/api-client-2`, 3 commits) is still open on
upstream. Until someone connects the screens, none of the 1,441 questions are visible.
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.