Kavenegar is being switched on, so every OTP request now costs credit:
- phone numbers are normalised to 09XXXXXXXXX everywhere (login, OTP,
family invite, TEST_LOGIN_PHONES keys) and existing rows are migrated;
non-Iranian and landline numbers are rejected before any SMS
- cooldown and a daily per-phone cap are counted in the database, so the
three gunicorn workers no longer triple them
- a global daily OTP SMS cap (default 300) stops SMS pumping
- per-IP rates widened for CGNAT / school Wi-Fi (env-overridable)
- provider message id is logged; SMS text warns not to share the code
Second audit round: an offer no longer shortens a longer paid plan,
Zarinpal error bodies no longer 500, island XP reaches the parent report,
a non-numeric questionId no longer 500s after the answer is generated.
596 tests green.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tutor (prompt tutor-v3-stepwise-plain): plain text instead of LaTeX/markdown,
with rag/services/mathtext.py converting whatever still slips through; full
step-by-step solutions with a check before the final answer; three tones by
grade; the last 6 messages of the conversation go to the model and follow-ups
are no longer served from another user's cache; book solutions the checker
disputes are kept out of the context; a questionId gives the tutor the
practice question and the student's wrong answer. /api/health reports when
the AI credit runs out. rag_model_benchmark compares chat models on verified
book problems (run on the server; credit ran out mid-run).
Map: only lessons that have questions count toward finishing an island,
lessonIds[0] / nextLessonId is always the next lesson to play, and a finished
island stays finished. The Seyar characters (sigma/delta/pi) are seeded.
Audit fixes, each reproduced before fixing:
- the plan is stored on the order, so paying a monthly order after starting
a yearly checkout no longer grants a year
- opening checkout no longer demotes an active subscription; renewals extend
from the current expiry; expired subscriptions are not reported active
- one discount code per order and only before the gateway; a 100% code
completes the purchase; lost Zarinpal confirmations are re-verified
- the unused /api/payment/webhook "mark paid" endpoint is removed
- XP counts only answers recorded on the server
- 6-digit OTP, each code dies after 5 wrong guesses; POST /api/auth/logout
- a guest who signs up keeps their account and progress (test phones excluded)
- affiliate commission on the first real payment; guests excluded
- parents with several children no longer get a 500
- SQLite runs transactions IMMEDIATE, since it ignores select_for_update
- admin login is rate-limited; display names drop phone numbers and links;
userQuestion is capped at 2000 characters
- server-side heart enforcement exists behind HEARTS_ENFORCED (off until the
frontend handles 403 no_hearts)
Not done on purpose: rotating the server's secret key and moving to Postgres.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The frontend plays node quizzes, but a node only completed once every one of its
8-13 lessons was completed separately, so students were stuck after the first
island of each unit. POST /api/nodes/{id}/quiz/complete now completes the node
(any score; score only sets stars/xp) and returns nextNodeId.
Also closes the other backend gaps found while auditing the frontend:
- /api/ai/ask without lessonId now picks the book from the user's grade instead
of skipping retrieval, so the "ask Pi" tab is grounded in the textbook.
- guest sessions return a refreshToken; they used to go silently 401 after 1h.
- lesson_complete throttle 10/hour -> 120/hour (a lesson here is 1-2 questions).
- progressPercent is per grade, not over all 2,617 lessons of every grade.
- a one-year plan (299,000 toman) is created by migration when no plan exists.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Swagger header was a list of features. That is what the project does, not
what a caller needs. It now opens with the three things anyone integrating
against this API asks first: how do I get a token, what does an error look like,
and when do I get rate limited -- plus the field-naming and date conventions,
and a note that the schema is the contract and clients should be generated from
it rather than written from memory. Swagger UI and Redoc both render Markdown,
so it is structured rather than one long paragraph, which also fixes how badly
the old mixed Persian/Latin line wrapped right-to-left.
Two defects surfaced while checking the rendered schema:
- /api/health was absent from the schema entirely. The trailing-slash alias was
excluded with extend_schema(exclude=True)(HealthView), but that decorator
mutates the class, and both routes pointed at the same class, so the canonical
route disappeared with it. A subclass now carries the exclusion, and the
endpoint is back, exactly once. Endpoint count is 56, not 55.
- One endpoint was tagged 'محتوا (content)' while every other content endpoint
used 'Curriculum (content)', which split the group into two sections.
Tags are now declared with descriptions and ordered by the path a real user
takes, rather than alphabetically.
Comment pass over the codebase, keeping the substance and dropping the noise:
the "⚠️ اضافه شده" prefixes dated comments against a moment no current reader
shares; "فاز ۲ / بخش ۴" referenced a plan that no longer exists and only
repeated the app name on the next line; "فاز اول - ۴ از ۱۱ اپ" had drifted (there
are fourteen). INSTALLED_APPS is now grouped by responsibility. The
TEST_LOGIN_PHONES block in settings shrank from 29 lines to 8 now that the same
explanation lives in docs/operations.md and .env.example -- a comment beside the
code should carry what a code reader needs, not a copy of the runbook.
Checked mechanically that no comment references an identifier that no longer
exists: zero. The dated measurements in rag/services/ingest.py and
generate_questions_from_chunks.py were deliberately left alone -- a measurement
carrying the date it was taken is the useful kind.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
مهمترین خواستهی سینیور این بود که بسنجیم پاسخِ AI واقعاً یاد میدهد یا نه.
برای سنجیدن، اول باید پاسخها قابلِ ردیابی میشدند.
پرامپتها پایهآگاه شدند: تا پیش از این «معلمِ دبستانی» برای پایه نهم هاردکد
بود، هم در تولیدِ پاسخ و هم در vision — یعنی دانشآموزِ نهم لحنِ ابتدایی
میگرفت. PROMPT_VERSION حالا روی هر پیام ذخیره میشود.
هر پاسخ حاشیهنویسی میشود: مدل، نسخهی پرامپت، پایه، collection، مقدارِ
فیلتر، تعداد chunk، توکن، تأخیر و from_cache. فیدبک reason اختیاری گرفت تا
لایک/دیسلایک بعداً قابلِ تحلیل باشد («بشه چک کرد کدوم بهتر بوده»).
ai_feedback_report --by prompt_version نسبتِ رضایت را کنارِ مصرف/هزینه
میگذارد. rag_eval سوالهای واقعی را از مسیرِ واقعی میپرسد، با
--retrieval-only برای اجرای ارزان.
rag/services/lesson_context.py از lessonId تنها پایه و فصل را درمیآورد؛
normalize_grade_title هم اختلافِ «پایه نهم» در دیتابیس با «ریاضی نهم» در
نامِ collection را میبندد — بدونِ آن مسیرِ gradeId بیسروصدا خالی برمیگشت.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
progress/views.py
- GuestAwareReadThrottle (same guest-by-IP pattern as the other apps);
throttle_scope='read' on UserSummaryView, StreakCalendarView,
LeaderboardView.
notifications/views.py
- GuestAwareReadThrottle + throttle_scope='read' on NotificationListView.
- list capped at NOTIFICATIONS_MAX=100 newest (Meta.ordering already
newest-first); unreadCount still counted over the full set.
notifications/tests.py
- 150 rows -> 100 returned newest-first, unreadCount = 75.
rag/views.py
- ChunkListView: explicit order_by('-id') before the existing [:200] cap.
ai_tutor/views.py
- ConversationMessagesView: return the newest 200 messages in
chronological order instead of the whole conversation.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
When RAG is configured the endpoint retrieves lesson-scoped chunks and
generates a grounded answer; otherwise the previous deterministic stub
runs unchanged, so keyless runs and existing tests are unaffected.
Quota / idempotency / user-row lock / clean-503 behaviour is preserved on
every path.
models.py
- Conversation.subject (CharField) and Conversation.lesson_number
(IntegerField): retrieval metadata sent on the first ask and reused
for the rest of the conversation.
migrations/0004_conversation_lesson_number_conversation_subject.py
- migration for the two new fields.
serializers.py
- AskRequestSerializer: optional subject / grade / lessonNumber to pin
the Qdrant collection and lesson/chapter.
- AskResponseSerializer: usedChunks (retrieved [{title, summary}]) and
retrievalDebug (collection / filterKey / chapter-or-lesson / counts,
emitted only under DEBUG).
views.py
- generate_tutor_answer(): unchanged signature; RAG-configured branch
calls rag.services generation, stub branch otherwise.
- AskAIView.post(): resolve subject/grade/lessonNumber (falling back to
the Conversation), check the answer cache via resolve_target(), run
retrieve_context + generate_answer on a miss and populate the cache,
return usedChunks (+ retrievalDebug under DEBUG). Failures still
release the reserved quota slot and surface as 503.
- AskAIView._streaming_response(): opt-in "stream": true returns a
text/event-stream of chunk / done / error events; same retrieval,
phase-3 persist, idempotency and quota accounting as the JSON path,
and it consults the answer cache first. Non-stream JSON contract is
untouched.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>