Commit Graph

88 Commits

Author SHA1 Message Date
5aa10eb7b7 give every developer a way into the API that does not depend on one person
With DEBUG=0 and Kavenegar unconfigured, POST /api/auth/otp/request returns a
hard 503, so the only way to sign in was for someone with admin access to hand-
create an OTPCode row. That made every frontend developer's login depend on one
person being available.

TEST_LOGIN_PHONES is the pattern real apps use for exactly this (Firebase Auth's
fictional numbers, Apple's App Store demo account): a short allowlist of
phone:code pairs that skip SMS delivery entirely. Unlike DEV_MASTER_OTP it is
not "any phone with one code" and it is not tied to DEBUG -- each number carries
its own code, and everything else takes the normal OTP path untouched.

The code never appears in an API response, so knowing the number is not enough
to log in, and the existing per-phone login attempt limit still applies, so the
code cannot be brute-forced. Malformed configuration raises ImproperlyConfigured
at startup rather than being skipped silently, and /api/health reports how many
test numbers are open (count only) so they cannot be forgotten on a server that
has real users.

The code is capped at six digits because LoginRequestSerializer caps otp at
max_length=6; a longer code would have been rejected before reaching the check
and looked like the feature was broken.

The guest path the frontend uses today is untouched, and leaving the variable
empty reproduces the previous behaviour exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 00:52:57 +03:30
8fce3b9548 give the leaderboard and notifications a real producer
Three gaps found while seeding test data for the frontend, all of the same
kind: a contract that looks complete from the outside and is empty underneath.

Nothing in the codebase ever wrote a LeaderboardEntry. Not a signal, not a
service — only the admin form and seed_test_data. So /api/leaderboard returned
`entries: []` forever on any real server, and any row put there was a frozen
number that never tracked activity. rebuild_leaderboard now derives both
periods from real XP: all_time from UserStats.xp, weekly from a sum over
content.UserLessonCompletion in a window. It deliberately does not count
UserAnswer.xp_earned — that field is written but never added to UserStats, so
summing both would report roughly double. deploy.sh runs it every release;
a cron is the real answer once traffic is real, and the script says so.

'lesson_completed' was declared in Notification.TYPE_CHOICES but nothing
called notify() with it, so finishing a lesson produced no notification at
all. LessonCompleteView now emits one, on first completion only (the same
condition that grants xp/coins), inside the completion transaction so a lost
notification cannot leave a silent half-done state.

The notification list serializer returned only id/type/title/isRead. body and
created_at were on the model from the start but never exposed, so the frontend
had neither the text of a notification nor its time. Both added; the change is
purely additive, so the existing client keeps working.

472 tests green, up from 458. makemigrations --check, check and spectacular
are all clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 13:30:57 +03:30
66364e0e92 fix the campaigns concurrency test by fixing what it was catching
ExecuteCampaignRealConcurrencyTests had been written off as a known flake
for weeks — about 45% red on a full-suite run. It was not timing noise.
It was reporting a real defect, and the defect is in production code.

execute_campaign looped with .iterator(), which keeps the read cursor on
the user table open for the whole loop — a loop that writes a
CampaignDelivery per user and, on the sms branch, makes a network call to
Kavenegar in between. On SQLite that open cursor locks the table against
any concurrent writer, and the lock is not released until the loop ends.
So _retry_on_sqlite_lock could never recover from it no matter how long
it waited: widening its window from 0.5s to 2s (also done here, since the
linear backoff was too tight regardless) moved the failure rate not at
all on its own.

Both branches now materialise the ids before the first write. After the
change: 20 consecutive runs of the test itself and 3 full-suite runs, all
green.

This is not only a test fix. While we are on SQLite the same pattern
produces the same lock in production, where it surfaces as "database is
locked" on unrelated requests that happen to land during a campaign run.

Also in this pass
-----------------
- CLAUDE.md carried three sections each claiming to be the current state,
  with three different question counts (1,441 / 1,783 / reality 2,463).
  There is now one current-state section and the older two are labelled
  as history, so whoever picks this up next is not reading stale numbers
  as fact. Three other figures that had drifted are corrected against the
  database: the verdict split, the figure-gap share (881/~42% is now
  508/~14% after the five books were re-read), and the node coverage.
- Dropped four genuinely unused imports. The remaining pyflakes hits are
  the signal registrations in apps.py, which are imported for effect.
- .env.example suggested gpt-4o for vision; gpt-4.1-mini measured both
  cheaper and more accurate and is what the server actually runs.

Audited all 2,463 questions across every grade: no question with the
wrong number of options, none with zero or two correct answers, no empty
or duplicated option, no orphan lesson or node, no duplicate question
inside a lesson.

458 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 04:43:44 +03:30
6a6d0200d2 complete the quiz path for grades 8 through 12, and close three gaps in the API contract
Content
-------
Every node in grades 8, 9, 10, 11 and 12 now holds a full four-question
quiz — 121 more questions, filling the two حسابان دوازدهم nodes in
فصل ۵: کاربردهای مشتق that were empty and the two ریاضی هشتم nodes that
were short. The repo's own seed guard was already failing on those two
empty nodes; it passes now.

Grades 3, 6 and 7 are the other team's books and were left alone. 39 of
their 145 nodes still hold fewer than four questions, but none is empty,
so every node on the map still plays.

An audit over all 1,593 questions in grades 8–12 found no structural
fault: no question with the wrong number of options, no question with
zero or two correct answers, no empty or duplicated option, no leftover
LaTeX, and no two identical questions inside one lesson.

Contract
--------
Three endpoints were missing or malformed in the OpenAPI schema, which
matters more than it looks: that schema is the contract the frontend
generates its client from, so a view missing there is a view the other
repo cannot call.

- NotificationMarkReadView was dropped from the schema entirely, because
  spectacular could not guess a request body for its PATCH and gives up
  on the whole view rather than the one field. It declares request=None.
- PaymentRedirectView was dropped for the same reason; it answers with a
  302 and no JSON body, which it now says.
- /api/health and /api/health/ collided on operationId and were emitted
  as health_retrieve and health_retrieve_2. The slash variant exists only
  for a monitor that calls it that way, so it is excluded from the schema.

`spectacular` now runs clean — no errors, no warnings.

Tidying
-------
Condensed the two longest comment blocks (18 and 14 lines) in
rag_verify_solutions and settings, keeping the measurement and the rule
and dropping the retelling. README gains the bulk question-authoring
workflow; the playbook's subagent section now points at the real tool
instead of describing the steps by hand.

458 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 03:49:32 +03:30
82edd0ecb0 bring حسابان 11 and 12 into the game path
Both books were already complete on Qdrant and answering /api/ai/ask, but
had no Grade/Unit/Node/Lesson rows, so none of their problems could ever
reach a quiz. The owner made the product call, so the curriculum is now
seeded from the books' own tables of contents (free, no API credit), and
559 four-option questions were authored from the verified chunks.

Coverage: all 21 nodes of grade 11 hold a full four-question quiz, and 10
of 12 in grade 12. The two that do not are both in فصل ۵: کاربردهای مشتق
and are short only because the weekly model limit landed mid-run — their
(lesson, problem) pairs are already exported and waiting.

Three supporting changes:

- generate_questions_from_chunks grows --export DIR, which writes the
  (lesson, verified problem) pairs as JSON batches and makes no paid call
  at all. The subagent route was the documented way to author questions
  but had no tool behind it; now it does, and it emits the same PROMPT
  the paid path uses so the two branches cannot drift apart in quality.

- _targets now skips as many chunks as a lesson already has questions
  for. Re-running with a higher --per-lesson used to hand back the very
  same chunk, producing a near-duplicate question — silently, because the
  prompt gets rewritten each time, so neither the run nor question_io's
  validation could catch it.

- GRADE_STYLE gains یازدهم and دوازدهم. Without them both grades fell to
  the grey fallback; they are deliberately cool colours, separate from the
  six general grades, because حسابان is a ریاضی‌فیزیک-only book.

458 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 03:35:26 +03:30
3026df0397 carry today's 312 new questions to the server
کدِ امروز پوش شد ولی محتوا جا ماند: db.sqlite3 در .gitignore است و سوال‌ها فقط
از مسیرِ content/seed/questions.json به سرور می‌رسند.

۱٬۴۴۱ → ۱٬۷۸۳ سوال. تازه‌ها از مسئله‌های تأییدشده‌ی هشتم، نهم و دهم ساخته شدند
و هر کدام سه گزینه‌ی انحرافی دارند که اشتباهِ واقعیِ دانش‌آموز را بازتاب می‌دهند
(جمعِ توان‌ها به‌جای تفریق، جاافتادنِ علامتِ منفی، محیط به‌جای مساحت).

اثرش روی مسیرِ بازی: نودهای ناقص از ۵۸ به ۴۱ رسیدند و ۱۸۹ نود از ۲۳۰ حالا
کوییزِ کامل دارند. ۴۱ موردِ باقی‌مانده همگی از کتاب‌های سوم، ششم و هفتم‌اند که
امروز دست‌نخورده ماندند.

مسئله‌های Qdrant دیپلوی نمی‌خواهند — مستقیم روی همان سرور نوشته شده‌اند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 12:50:30 +03:30
acce78bb03 write down the extraction method so another team can reuse it
docs/extraction-playbook.md: چهار تصمیمی که تفاوتِ چند برابری در هزینه و کیفیت
ساختند، با عددهای اندازه‌گیری‌شده‌ی واقعی نه تخمین.

سرفصل‌ها: انتخابِ مدل از روی اندازه‌گیری (gpt-4.1-mini هم دقیق‌تر از gpt-4o بود
هم ۳٫۵ برابر ارزان‌تر، و gemini برخلافِ جدولِ قیمت سه برابر گران‌تر)، قاعده‌ی
self-contained، تعمیرِ بک‌اسلشِ LaTeX، محافظِ شکل، و ساختِ سوال با subagent
به‌جای API.

وجهِ مشترکِ هر چهار مورد: هیچ‌کدام خطا تولید نمی‌کردند — نه در لاگ، نه در تست.
فقط دادهٔ کمتر یا بدتر می‌ساختند. برای همین توصیه‌ی محوریِ فایل این است که قبل
از اعتماد به هر ران انبوه، نمونه‌ای ۴۰-۵۰تایی با چشم خوانده شود.

تست‌های rag هم اینجا می‌آیند: بازخوانی، تعمیرِ JSON، و محافظِ شکل.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 12:36:45 +03:30
3156bcbbf3 stop counting the frontend developer's port numbers
فهرستِ ثابتِ CORS (۳۰۰۰ و ۵۱۷۳) روزی شکست که Vite دید ۵۱۷۳ اشغال است و
بی‌سروصدا رفت روی ۵۱۷۴ — رفتارِ پیش‌فرضِ خودش. فرانت‌کار خطای CORS گرفت، از
سمتِ سرور هیچ ردی در هیچ لاگی نبود (خطا فقط در کنسولِ مرورگر دیده می‌شود)، و
چون هم‌زمان با تغییراتِ بک‌اند شد به اشتباه گردنِ ما افتاد. هزینه‌اش یک ویرایشِ
.env و یک ری‌استارتِ پروداکشن بود، برای چیزی که اصلاً به پروداکشن ربطی نداشت.

به‌جای شمردنِ پورت‌ها، الگوی «هر پورتی روی خودِ ماشینِ توسعه‌دهنده» پذیرفته شد.
سطحِ حمله اضافه نمی‌کند: توکنِ ما در هدر است نه کوکی و CORS_ALLOW_CREDENTIALS
خاموش، پس صفحه‌ای روی localhostِ مهاجم هیچ اعتبارنامه‌ای از مرورگرِ قربانی
نمی‌گیرد؛ CORS هم اصلاً سدِ سرور-به-سرور نیست.

تست‌ها هم پوشش می‌دهند که localhost.evil.com و math.tadrisino2.ir.evil.com
همچنان رد شوند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 12:36:45 +03:30
d15a28799a anchor a question to its lesson by page, not by a title that keeps changing
chunkها با تطبیقِ دقیقِ رشته به درس وصل می‌شدند (problem_type == lesson.title).
عنوانِ درس از یک استخراجِ قدیمی آمده بود و هر بازخوانیِ کتاب عنوانِ تازه می‌سازد،
پس بعد از بازخوانیِ امروز فقط ۵ تا ۸ درصدِ عنوان‌ها می‌خوردند — یعنی ۹۵٪ درس‌ها
بی‌هیچ خطایی هیچ نامزدی پیدا نمی‌کردند و ساختِ سوال صفر برمی‌گرداند.

لنگرِ پایدار صفحه است: هر Node دقیقاً یک بخشِ فهرستِ کتاب با بازه‌ی صفحه‌ی مشخص
است (تطبیق ۱۰۰٪: ۳۴/۳۴، ۲۸/۲۸، ۲۳/۲۳) و هر chunk هم source_page دارد. وقتی
عنوان نخورد، از روی بازه‌ی صفحه سراغ chunk می‌رویم — بدونِ هیچ فراخوانیِ مدلی.

نتیجه: هشتم ۰→۲۰۱، نهم ۰→۴۹، دهم ۰→۱۳۴.

رفتارِ قدیم با --strict-title حفظ شد، و یک محافظ گذاشته شد که یک chunk به دو
درس داده نشود.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 12:36:45 +03:30
96bda905db stop the figure guard from throwing away four fifths of what it caught
محافظِ «وابسته به شکل» یک regexِ تخت بود (شکل|جدول|روبه‌رو|مقابل|زیر را) و
مسئله‌ها را بدونِ هیچ فراخوانی کنار می‌گذاشت. فاجعه‌بار گشاد بود:

    «زاویه‌های روبه‌رو در لوزی برابرند؟»   ← «روبه‌رو» یعنی مقابل، نه شکل
    «کسرهای زیر را گویا کنید: ۲/√۳»       ← داده همان‌جاست
    «ظرفی به شکل مخروطی با شعاع ۴»        ← «به شکلِ» یعنی شبیهِ

اندازه‌گیری روی نمونه‌ی ۵۰تایی از کتاب‌های بازخوانی‌شده: ۸۴٪ از رد‌شده‌ها واقعاً
قابلِ قضاوت بودند — یعنی ~۷۸۴ مسئله‌ی سالم بی‌صدا از چرخه‌ی ساختِ سوال بیرون
مانده بود، بدونِ هیچ خطایی در هیچ لاگی.

قاعده‌ی تازه دو شرط دارد و هر دو باید برقرار باشند: ارجاعِ صریح به چیزی بیرونِ
متن، و نیامدنِ هیچ توصیفی بعد از آن. شرطِ دوم همان چیزی را می‌گیرد که قاعده‌ی
self-contained تولید می‌کند: «با توجه به شکل مقابل که شامل {a,1} است».

اثرش روی دادهٔ واقعی: unjudgeable از ۱٬۴۴۱ به ۵۰۸ و agrees از ۷۹۱ به ۲٬۴۶۶.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 12:36:16 +03:30
fa347915c4 let a book be re-read without duplicating everything in it
ingestِ دوباره جایگزین نمی‌کرد: qdrant_point_id یک uuid4 تازه به ازای هر ردیف
است، پس خواندنِ دوباره‌ی یک صفحه نسخه‌ی تکراری کنارِ نسخه‌ی قدیمی می‌گذاشت و
جستجو نمی‌توانست تشخیص بدهد کدام کدام است.

--refresh {figures,unjudgeable,both,all} و --refresh-pages اضافه شدند.

سه تصمیمِ طراحی که عمدی‌اند:

۱. پاک‌سازی صفحه‌به‌صفحه است و درست پیش از خواندنِ همان صفحه، نه یک‌جا از اول.
   وسطِ تست یک crash همین را ثابت کرد: فقط یک صفحه خالی ماند (۲۷۹→۲۷۶) و بقیه‌ی
   کتاب دست‌نخورده. پاک‌سازیِ یک‌جا همان crash را به ۸۱ صفحه‌ی خالی تبدیل می‌کرد.

۲. اول Qdrant، بعد دیتابیس. شناسه‌ی نقطه فقط در ردیفِ DB است، پس ترتیبِ برعکس
   یعنی شکستِ Qdrant نقاط را برای همیشه بی‌صاحب می‌گذارد — نه قابلِ یافتن، نه
   قابلِ پاک کردن.

۳. order از Max گرفته می‌شود نه count: بازخوانی ردیف‌ها را از وسط پاک می‌کند و
   count عددی می‌داد که با orderهای موجود تصادم داشت.

تخمینِ هزینه حالا از اندازه‌گیریِ واقعیِ پنلِ صورتحساب می‌آید نه از جدولِ قیمت،
و وعده‌ی «با Batch API نصف» حذف شد چون GapGPT اصلاً /v1/batches ندارد.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 12:36:16 +03:30
b115ccd37a stop losing the pages that matter most to a stray backslash
مدلِ vision در متنِ ریاضی LaTeX می‌نویسد (\frac، \sqrt، \vec) و بک‌اسلشِ تنها
در JSON نامعتبر است، پس json.loads کلِ صفحه را دور می‌ریخت — دقیقاً صفحه‌های
پرفرمول، یعنی باارزش‌ترین‌ها. روی حسابان یازدهم/دوازدهم چهار صفحه هرچه تلاش
شد «خطادار» ماندند تا همین‌جا پیدا شد.

⚠️ نسخه‌ی اولِ تعمیر خودش تله داشت: \f، \t، \b، \r و \n از نظرِ JSON معتبرند،
پس «سالم» رد می‌شدند و \frac به formfeed+«rac» و \theta به tab+«heta» تبدیل
می‌شد — خرابیِ بی‌صدا به‌جای خطای بلند. قاعده‌ی تفکیک: دستورِ LaTeX همیشه با
حرفِ ASCII ادامه می‌یابد، ولی escapeِ واقعی با رقم، فارسی یا پایانِ رشته.

کنارش: پایه‌های یازدهم و دوازدهم به persona و تشخیصِ پایه اضافه شدند («دوازدهم»
پیش از «دهم» بررسی می‌شود چون زیررشته‌اش را دارد)، سقفِ max_tokens از ۳۰۰۰ به
۶۰۰۰ رفت چون قاعده‌ی self-contained خروجی را بلندتر کرده، و حسابان یازدهم و
دوازدهم با نگاشتِ فصل‌به‌صفحه ثبت شدند.

«حسابان» به MATH_SUBJECTS اضافه شد: بدونِ آن retrieval این کتاب‌ها را «درسِ
متنی» می‌دید و به‌جای chapter روی lesson_number فیلتر می‌کرد — بی‌هیچ خطایی
همیشه صفر نتیجه.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 12:35:46 +03:30
8eedbdeac0 let a deploy repair the server instead of stopping at it
تا حالا اگر کسی روی سرور فایلی را دستی عوض کرده بود، `git pull --ff-only`
شکست می‌خورد و کلِ اسکریپت همان‌جا می‌ایستاد. چون هیچ‌کدام از مراحلِ بعدی
اجرا نمی‌شد، سرور با کدِ قدیمی می‌ماند — بی‌صدا. یک بار همین دو روز طول کشید.

حالا مرحله‌ی اول fetch می‌کند، اگر تغییرِ دستی (یا کامیتِ محلی) باشد آن را در
.deploy-backups/*.patch نگه می‌دارد، و با `git reset --hard origin/<branch>`
کنارش می‌گذارد و ادامه می‌دهد. سرور در هر وضعیتی باشد، یک اجرا دقیقاً همان
چیزی می‌شود که روی gitea است.

`git clean` عمداً زده نمی‌شود و نباید اضافه شود: db.sqlite3 و .env و
.django_secret_key ردیابی‌نشده و داخلِ همین پوشه‌اند.

با مخزنِ ساختگی هر سه حالت آزموده شد: تغییرِ دستی، درختِ تمیز، و کامیتِ محلیِ
واگرا. در هر سه، .env و db.sqlite3 دست‌نخورده ماندند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 00:54:06 +03:30
6ec82081b5 stop deploy.sh from pulling a new copy of itself out from under bash
bash اسکریپت را یک‌جا نمی‌خواند؛ حین اجرا از روی دیسک ادامه می‌دهد. پس وقتی
`git pull` همین فایل را عوض می‌کند، ادامه‌ی اجرا مخلوطی از نسخه‌ی قدیم و جدید
است. یک بار همین اتفاق افتاد و گران تمام شد: نسخه‌ی قدیمی که در حال اجرا بود
سرویسِ تیمِ دیگری را ری‌استارت کرد، در حالی که نسخه‌ی اصلاح‌شده همان لحظه روی
دیسک نشسته بود.

حالا امضای فایل قبل و بعدِ pull مقایسه می‌شود و اگر عوض شده باشد، نسخه‌ی تازه
از اول اجرا می‌شود (با SKIP_PULL=1 و یک نگهبان در برابرِ حلقه‌ی بی‌پایان).

و یک حفره‌ی کوچک‌ترِ هم‌خانواده: اگر زمانِ بالا آمدنِ سرویس اصلاً خوانده نشود،
دیگر موفقیت فرض نمی‌شود. «نمی‌دانم» همان چیزی بود که دو روز پنهانش کرد.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 00:41:11 +03:30
700b2e4c7f give our service a name nobody can confuse with someone else's
روی این سرور یک `gunicorn.service` هست که مالِ تیمِ دیگری است. هر کسی که
عجله داشته باشد و `systemctl restart gunicorn` بزند، سرویسِ آن‌ها را
می‌خواباند و سرویسِ ما اصلاً ری‌استارت نمی‌شود — یعنی دیپلوی بی‌صدا اعمال
نمی‌شود. همین دیشب یک بار اتفاق افتاد.

حالا همان یونیت با نامِ `zareian-mathapp` هم صدا زده می‌شود (symlink در
/etc/systemd/system). نامِ قبلی هم کار می‌کند، پس چیزی نمی‌شکند.

خط تیره و نه نقطه: systemd آخرین نقطه را جداکننده‌ی نوعِ یونیت می‌داند، پس
`zareian.mathapp` را باید هر بار کامل با `.service` نوشت.

deploy.sh هم اول دنبالِ همین نام می‌گردد.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 00:14:46 +03:30
0132235026 take out a comment that was rendering as page text
`{# ... #}` در جنگو فقط تک‌خطی است. کامنتی که دیشب اضافه کردم سه خط بود، پس
جنگو کامنت حسابش نکرد و کلِ متنش بالای صفحه‌ی مرورِ محتوا چاپ شد.

کلاً برداشته شد. برای توضیحِ چندخطی در قالب باید {% comment %} استفاده شود،
ولی اینجا اصلاً توضیحی لازم نبود.

همه‌ی قالب‌های پروژه برای همین الگو بررسی شدند؛ جای دیگری نبود.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 00:05:18 +03:30
ae9c33c5de write down how to handle the server password
روشی که این جلسه استفاده شد حالا نوشته شده تا هر کسی (آدم یا ایجنت) همان را
تکرار کند: رمز در یک فایلِ chmod 600 در پوشه‌ی موقت، ورود با sshpass -f و
sudo -S از ورودی، و shred -u بلافاصله بعدِ کار.

دلیلِ هر قدم هم نوشته شد، چون بدونِ دلیل قدم‌ها تشریفات به‌نظر می‌رسند و کنار
گذاشته می‌شوند. مهم‌ترینش: رمز هیچ‌وقت در خطِ فرمان نیاید — روی این سرور چند
تیمِ دیگر حساب دارند و ps دستورِ هر پروسه را به همه نشان می‌دهد.

و صریح نوشته شد که رمز فقط از خودِ کاربر در همان جلسه می‌آید؛ جایی ذخیره
نمی‌شود و نباید بشود. اگر در گفتگو نبود، باید پرسید نه گشت.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 00:00:03 +03:30
3179ad0b3b stop printing the content browser's title twice
ویو `title` را به کانتکست می‌دهد و admin/base.html خودش آن را هم در مسیرِ
راهنما و هم به‌عنوان <h1> صفحه رندر می‌کند. قالب یک <h1> دستی هم داشت، پس
عنوان دو بار پشتِ هم چاپ می‌شد. دستی برداشته شد؛ عنوانِ تبِ مرورگر و مسیرِ
راهنما دست‌نخورده می‌مانند چون هر دو از همان `title` می‌آیند.

بخشِ وضعیت در CLAUDE.md هم به‌روز شد: می‌گفت سرور کدِ قدیمی و دیتابیسِ خالی
دارد، که دیگر درست نیست. به‌جایش چیزی نوشته شد که دفعه‌ی بعد به درد بخورد —
اینکه فایلِ روی دیسک یعنی فایلِ مستقر‌شده نیست، و چه چیزهایی عمداً باز مانده.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 23:49:05 +03:30
fecba2011b make the content browser readable in dark mode
صفحه‌ی مرورِ محتوا در پنلِ ادمین عملاً نامرئی بود: هر پنل زمینه‌ی روشنِ ثابت
داشت (#fff، #f6f6f6، #f1f5f9) ولی رنگِ متن تعیین نشده بود. مرورگری که روی
حالتِ تیره است، رنگِ متنِ ادمین را روشن می‌کند — و نتیجه متنِ سفید روی زمینه‌ی
سفید بود.

حالا پنل‌ها از متغیرهای خودِ ادمین استفاده می‌کنند تا با هر دو تم بروند، و
چیپ‌های رنگی که عمداً زمینه‌ی روشنِ ثابت دارند رنگِ متنِ تیره‌ی صریح گرفتند.
همین اشکال در صفحه‌ی واردکردنِ سوال هم بود و درست شد.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 23:11:44 +03:30
de118db58f make a deploy fail loudly when the service never restarted
سرور دو روز کدِ قدیمی را سرو کرد و هیچ‌کس نفهمید. علتش این بود که
`systemctl restart` شکست خورده بود ولی پروسه‌ی قبلی هنوز بالا بود و همان
۲۰۰ را می‌داد — پس اسکریپت «✓ سرویس سالم بالا آمد» چاپ کرد و با کدِ صفر
تمام شد. محتوا و فایل‌های استاتیک هم روی دیسک بودند و سرو نمی‌شدند.

حالا زمانِ بالا آمدنِ سرویس قبل و بعد مقایسه می‌شود. اگر عوض نشده باشد،
۲۰۰ گرفتن دیگر موفقیت حساب نمی‌شود و اسکریپت با خطا تمام می‌کند.

نامِ عامِ `gunicorn` هم از فهرستِ حدسِ سرویس برداشته شد: روی سرورِ واقعی یک
`gunicorn.service` هست که مالِ تیمِ دیگری است، و حدس زدنش یعنی ری‌استارت
کردنِ سرویسِ یک نفرِ دیگر. نامِ درستِ ما `gunicorn-mathapp` است.

`api/health/` هم کنارِ `api/health` ثبت شد. پایشِ سرور شکلِ اسلش‌دار را صدا
می‌زد و چون ثبت نبود، کسی روی سرور `config/urls.py` را دستی ویرایش کرده
بود — و از آن به بعد هر `git pull` روی همان فایل گیر می‌کرد.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 22:56:51 +03:30
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
16d3d9f149 add the last 81 quiz questions
دستهٔ c5. ۱٬۳۶۰ → ۱٬۴۴۱.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 02:36:34 +03:30
e89de084e7 add 161 more quiz questions from verified textbook solutions
دسته‌ی c2 و باقی‌ماندهٔ دیشب. ۱٬۱۹۹ → ۱٬۳۶۰.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 02:25:10 +03:30
b26aca0f63 label every textbook solution, and build quizzes at the level the app plays
سه کارِ به‌هم‌پیوسته که از یک کشف شروع شدند: راه‌حل‌هایی که vision از کتاب
درآورده گاهی غلط‌اند، و این خطا در بازیابی دیده نمی‌شود — فقط وقتی کسی به
`solution` اعتماد کند خودش را نشان می‌دهد.

۱) بازبینیِ راه‌حل‌ها (rag_verify_solutions)

هر chunk دوباره حل می‌شود و با کتاب مقایسه. نتیجه سه‌حالته است، نه بله/خیر:
اجرای اول با بولی ۱۹٪ خطا گزارش کرد؛ خواندنِ دستیِ نمونه‌ها نشان داد ۳۵٪ از
آن‌ها مسئله‌هایی بودند که به شکلِ کتاب ارجاع می‌دادند و اصلاً قابلِ قضاوت
نبودند، و ۲۴٪ موردی که «۳/۱۰۴» را کسرِ ۳÷۱۰۴ خوانده بود نه عددِ ۳٫۱۰۴. با دو
حالت، «نمی‌دانم» ناچار به «غلط است» ترجمه می‌شد.

با سه حالت و پرامپتی که این دو تله را می‌شناسد: ۲٬۱۱۹ chunk بررسی شد،
۱٬۱۱۲ درست، ۱۲۳ غلط (۵٫۸٪)، ۸۸۱ غیرقابلِ قضاوت. آن ۸۸۱ رایگان برچسب خوردند
چون پیش‌فیلتر قبل از هر فراخوانی کنارشان گذاشت.

`solution` هرگز بازنویسی نمی‌شود — کتاب باید کتاب بماند. برچسب در payloadِ
Qdrant هم می‌نشیند، ولی با نامی که `retrieval._payload_text` نمی‌خواند: یک
نویسه هم به متنی که به مدل می‌رسد اضافه نشد.

نمونه‌ی خطاهای واقعیِ کتاب که پیدا شد:
    BMI = ۱۰۰/(۲٫۷۴)²          کتاب: ۳۳٫۲      درست: ۱۳٫۳
    ۱۶ آبی + ۲۴ صورتی          کتاب: «کل ۵۰»   درست: ۴۰
    بزرگ‌ترین عددِ ۴رقمیِ بی‌تکرار کتاب: ۸۵۳۲     درست: ۹۸۷۶
    g(x) = x³ − ۲              کتاب: «نمایی»   درست: چندجمله‌ای

۲) سوال‌ها فقط از راه‌حل‌های تأییدشده

۱٬۱۹۹ سوال در content/seed/questions.json، همه از chunkهایی که برچسبِ
«درست» گرفته‌اند. هر شش پایه برنامه‌ی درسی دارند (۱٬۹۲۸ درس) — هشتم و دهم
تازه ساخته شدند.

۳) GET /api/nodes/{id}/quiz

واحدِ بازی در اپ «مبحث» است ولی سوال‌ها به «درس» بسته‌اند، و درس اینجا
ریزترین دانه است: یک نوعِ مسئله، نه یک جلسه. نتیجه‌اش این بود که اپ برای یک
آزمونِ چهارسوالی باید ده‌ها درخواست می‌زد. این اندپوینت از همه‌ی درس‌های مبحث
نمونه برمی‌دارد، حداکثر یکی از هر درس تا آزمون روی یک مهارت گیر نکند.

درهم‌ریزی با بذرِ nodeId:userId پایدار است: رفرشِ صفحه همان آزمون را می‌دهد،
نه مجموعه‌ی تازه‌ای که پیشرفت را بی‌معنا کند. `questionCount` جدا از
`requested` می‌آید تا مبحثِ کم‌سوال وانمود نکند کامل است.

──────────
اصلاحاتِ جانبی که سرِ راه لازم شدند:

• QDRANT_TIMEOUT_SECONDS: کلاینت بدونِ timeout ساخته می‌شد و پیش‌فرضِ ۵
  ثانیه‌ایِ کتابخانه ingestِ کتابِ هشتم را روی اولین فراخوانی کشت.
• پاک‌سازیِ نقاطِ نیمه‌نوشته: upsert با wait=True ممکن است نوشته باشد و فقط
  جوابش تایم‌اوت شده باشد؛ مسیرِ جبران فقط ردیف‌های DB را پس می‌گرفت و هر
  retry یک نسخه‌ی تکراری در Qdrant جا می‌گذاشت (۸ مورد در پایه نهم، پاک شد).
• تشخیصِ سقفِ حساب: «insufficient user quota» با فاصله می‌آید نه زیرخط، و
  محافظِ اول نمی‌گرفتش — یک اجرا ۷۷۰ فراخوانیِ محکوم‌به‌شکست زد.
• پرامپتِ vision حالا می‌خواهد محتوای شکل داخلِ متنِ مسئله نوشته شود. ۴۲٪ از
  محتوای موجود به شکلی ارجاع می‌دهد که در متن نیست؛ مدل آن را دیده بود ولی
  از او خواسته نشده بود بنویسدش. کتاب‌های موجود قبل از این قاعده وارد شده‌اند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 02:11:15 +03:30
92dc2242f8 take the points back too when an upsert fails
بررسیِ collectionها بعد از ingestِ پایه نهم نشان داد Qdrant ۲۸۷ نقطه دارد ولی
دیتابیس ۲۷۹ ردیف. هشت نقطه‌ی اضافه، همه از صفحه‌ی ۱۲۵ — همان صفحه‌ای که دو بار
تایم‌اوت داد و بار سوم گرفت. سه مسئله سه نسخه داشتند و دو نقطه هم بی‌صاحب بود.

علت: `upsert` با `wait=True` ممکن است نوشته باشد و فقط جوابش تایم‌اوت شده
باشد. مسیرِ جبران فقط ردیف‌های DB را پس می‌گرفت، نه نقطه‌ها؛ و چون هر اجرای
دوباره `qdrant_point_id` تازه می‌سازد، هر retry یک نسخه‌ی تکراری اضافه می‌کرد.

اثرش در بازیابی دیده می‌شود نه در لاگ: top-k با سه نسخه از یک مسئله پر می‌شود
و بقیه‌ی متنِ مرتبط بیرون می‌ماند — یعنی دستیار بافتِ کمتری می‌بیند و هیچ‌کس
هم متوجه نمی‌شود.

حالا در صورتِ شکست، نقطه‌ها هم پاک می‌شوند. اگر خودِ پاک‌سازی هم شکست بخورد
لاگ می‌شود ولی خطای اصلی پوشانده نمی‌شود، چون صفحه باید ناتمام بماند تا اجرای
بعدی دوباره سراغش برود.

داده‌ی موجود هم تمیز شد: ۸ نقطه‌ی بی‌صاحب پاک شدند و حالا ۲۷۹ = ۲۷۹.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 23:00:09 +03:30
1131569b76 ground grade 9's questions in the textbook, and catch the book's own errors
پایه نهم تا دیروز ناقص بود: ۲۸ صفحه از کتاب ingest نشده بود و فصل ۸ (حجم و
مساحت) اصلاً وجود نداشت. حالا ۱۴۳ از ۱۴۳ صفحه رفته و برنامه‌ی درسی ۸ فصل،
۲۸ مبحث و ۲۵۸ درس دارد.

۱۷۱ سوالِ تازه از روی مسئله‌های واقعیِ همان کتاب ساخته شد (جمعِ فایل: ۳۳۹).
برخلافِ سوال‌های نمونه‌ی قبلی که از عنوانِ مبحث ساخته شده بودند، این‌ها به
مسئله‌ی مشخصی در کتاب وصل‌اند.

مهم‌تر از خودِ سوال‌ها، چیزی که وسطِ کار معلوم شد: **بخشی از راه‌حل‌هایی که
vision از PDF استخراج کرده غلط است.** ۱۷ مورد پیدا شد، از جمله:

    شمارنده‌های ۶۰ که نه اول‌اند و نه مرکب → کتاب: {۱, ۶۰}   (۶۰ مرکب است)
    x−y=۵ و y=x−۳ → کتاب: (۸,۵)            (خطوط موازی‌اند، بی‌جواب)
    نور در ۱۰۰ ساعت → کتاب: ۱۰۸×۱۰¹³        (ده برابر؛ درست ۱٫۰۸×۱۰¹⁴)
    |۱−√۳| → کتاب: ۱−√۳                     (منفی است؛ درست √۳−۱)
    استوانه ۶۰cm×۱m → کتاب: ۲۸۲٫۷ m³        (خطای واحد؛ درست ۰٫۲۸۳)

اجرای قبلی با gpt-4o-mini همین جواب‌های غلط را بی‌صدا به‌عنوانِ گزینه‌ی درست
علامت می‌زد، چون فقط بازنویسی می‌کرد. مسیرِ تازه اول مسئله را مستقل حل می‌کند
و بعد با راه‌حلِ کتاب مقایسه می‌کند؛ هر اختلاف علامت می‌خورد و دستی بررسی شد.

۷۵ مسئله عمداً سوال نشدند — به شکل یا جدولی در کتاب ارجاع می‌دادند که در متن
نیست. حدس زدن در این موارد یعنی سوالِ بی‌جواب.

`seed_content --export` هم دیگر نمی‌تواند سوال‌ها را بی‌صدا پاک کند: فایل
دست‌نویس است و در دیتابیسِ هر ماشینی نیست، و یک export ساده آن را با آرایه‌ی
خالی بازنویسی می‌کرد. حالا خطا می‌دهد و `--only curriculum` را پیشنهاد می‌کند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 20:40:09 +03:30
0c374fed99 default the env template to DEBUG off
`.env.example` روی هر دو ماشین کپی می‌شود — لپ‌تاپِ توسعه‌دهنده و سرور — و
مقدارش 1 بود. همان فایل روی سرورِ عمومی نشست و ماه‌ها آنجا ماند، با کدِ
ورودِ 0000 باز برای هر شماره‌ای.

پیش‌فرض باید حالتی باشد که فراموش کردنش بی‌خطر بماند: 0 روی لپ‌تاپ فقط یعنی
خطاها خلاصه‌ترند، ولی 1 روی سرور یعنی هر کسی می‌تواند به‌جای هر کاربری وارد
شود. هزینهٔ این دو اشتباه قابلِ مقایسه نیست.

DEBUG جای دیگری هم تعریف نشده: `DJANGO_DEBUG` فقط از محیط خوانده می‌شود و
`.env` در gitignore است، پس هیچ کامیتی حالتِ هیچ ماشینی را عوض نمی‌کند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:58:33 +03:30
4857f49f92 collect static files even when DEBUG is on
پنلِ ادمین روی سرور بدونِ CSS بالا آمد و هیچ خطایی هم در لاگ نبود.

علت ترتیبِ کارها بود: این مرحله وقتی DEBUG روشن بود رد می‌شد، چون در آن حالت
whitenoise از finders می‌خواند و جمع‌آوری لازم نیست. کسی با DEBUG روشن دیپلوی
کرد و بعد DEBUG را خاموش کرد — کارِ درستی هم بود — و از آن لحظه جنگو دنبالِ
فایل‌هایی می‌گشت که هیچ‌وقت جمع نشده بودند.

یعنی درستیِ دیپلوی به ترتیبِ کارِ یک آدم گره خورده بود. چند ثانیه صرفه‌جویی،
به قیمتِ شکستی که فقط در مرورگر دیده می‌شود. حالا بی‌قید و شرط اجرا می‌شود؛
idempotent است و در هر دو حالت درست کار می‌کند.

staticfiles/ هم به gitignore اضافه شد: حالا که این مرحله همیشه اجرا می‌شود،
روی ماشینِ هر توسعه‌دهنده‌ای ساخته می‌شود و ۴٫۴ مگابایت فایلِ تولیدی نباید
با یک `git add .` واردِ مخزن شود.

بررسی شد که با DEBUG=False کلِ مسیر کار می‌کند: استاتیک ۲۰۰، صفحه‌ی ادمین
۲۰۰، API ۲۰۰، و ورود با OTP از پنل. کدِ 0000 همان‌طور که باید، رد می‌شود.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:53:55 +03:30
ca983d237f give every topic a starter question so the app can be clicked through
تا امروز کوریکولوم بود و سوال نبود: هر ۱۴ سوالِ دیتابیسِ لوکال روی ردیف‌های
[DEMO] نشسته بودند و در export کنار گذاشته می‌شدند. یعنی سینیور می‌توانست در
مرورگرِ محتوا پایین برود ولی هیچ درسی آزمونی نداشت — که از «محتوا نداریم»
بدتر است، چون شبیهِ خرابی به‌نظر می‌رسد.

حالا هر ۱۶۸ مبحث دستِ‌کم یک سوالِ چهارگزینه‌ای دارد: ۲۳ برای پایه نهم، ۴۶ ششم،
۴۶ هفتم، ۵۳ سوم. هر سوال به درسی وصل است که عنوانش همان موضوع را می‌گوید، نه
به یک درسِ دلخواه.

جای گزینهٔ درست با seed ثابت جابه‌جا شده (۴۴/۴۹/۳۵/۴۰ در چهار موقعیت). اگر
جوابِ درست همیشه گزینهٔ اول باشد، آزمون دیگر چیزی را نمی‌سنجد و آمارِ
«درصدِ غلط» هم بی‌معنا می‌شود. یک تست همین را نگه می‌دارد.

این‌ها نمونه‌اند نه محتوای نهایی: یک سوال برای هر مبحث فقط نشان می‌دهد مسیرِ
درس → آزمون → XP کار می‌کند. سوال‌های واقعیِ هر درس کارِ
`generate_questions_from_chunks` است که به اعتبارِ AI نیاز دارد، و از پنلِ
ادمین هم می‌شود دستی اضافه کرد.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:42:14 +03:30
9c3db68cbb carry the curriculum in the repo so a fresh server is not empty
سرورِ production از روزِ اول `{"grades": []}` برگردانده: دیتابیس gitignore است
(درست هم هست) و تنها راهِ پر کردنش اجرای `rag_import_collection` و بعد
`seed_curriculum_from_textbook` روی همان سرور بود — یعنی وابستگی به Qdrant، به
اعتبارِ AI که تمام شده، و به کسی که SSH دارد. نتیجه این شد که هیچ‌کس نتوانست
چیزی را تست کند.

حالا برنامه‌ی درسی در `content/seed/curriculum.json` کامیت می‌شود (۴ پایه، ۳۱
فصل، ۱۶۸ مبحث، ۱۳۰۰ درس، ۵۲۷ کیلوبایت متن) و `deploy.sh` خودش بالا می‌آوردش.
سرورِ تازه نه Qdrant می‌خواهد نه ترمینال.

`db.sqlite3` عمداً کامیت نمی‌شود: باینری است، دیف نمی‌شود، و هر دیپلوی محتوای
سرور — و فردا پاسخ‌های کاربران — را با نسخه‌ی لوکالِ یک نفر جایگزین می‌کند.

سوال‌ها با مسیرِ عنوان ذخیره می‌شوند نه با lessonId. این تصمیمِ اصلیِ کامیت
است: لوکالِ ما ۱۳۰۷ درس دارد با idهای ۳ تا ۲۰۰۵ (جای خالی، از پاک شدنِ دیتای
دمو)، و همان کوریکولوم روی دیتابیسِ خالی idهای ۱ تا ۱۳۰۷ می‌گیرد. با id،
سوالِ درسِ ۳ روی سرور به درسِ بی‌ربطی می‌چسبید — بدونِ هیچ خطایی. تست
`test_question_follows_the_title_path_not_the_id` همین را نگه می‌دارد.

`seed_content` ایدمپوتنت است و هیچ‌چیز پاک نمی‌کند: کوریکولوم با عنوان
هم‌سان‌سازی می‌شود و سوالِ تکراری رد می‌شود (`skip_existing`). روی سروری که
کاربرِ واقعی دارد، دیپلوی نباید بتواند محتوا یا پاسخی را از بین ببرد — و
ادمین باید بتواند سوالِ سیدشده را اصلاح کند بی‌آنکه دیپلویِ بعدی برش گرداند.

ردیف‌های `[DEMO]`/`[SEED]` موقعِ export کنار گذاشته می‌شوند؛ آن‌ها همان‌هایی‌اند
که `purge_demo_data` برای پاک کردنشان نوشته شد و جایشان در فایلی که مستقیم روی
production لود می‌شود نیست.

هنوز صفر سوالِ واقعی داریم: هر ۱۴ سوالِ لوکال رویِ ردیف‌های دمو بودند. مسیرِ
سوال‌ها ساخته و تست شده، ولی تا وقتی اعتبارِ AI شارژ نشود فایلش خالی می‌ماند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:20:26 +03:30
f3be99e4e2 record where this project is heading, not just how it works
هر ایجنتِ تازه ساختارِ کد را می‌بیند ولی نمی‌داند صاحبِ پروژه دنبالِ چیست:
دو ریپو و یک قراردادِ API (نه ادغامِ ریپوها)، حذفِ هر قدمِ دستی از انتشار،
محتوای قابلِ ویرایش بدونِ برنامه‌نویس، و سنجش‌پذیر بودنِ کیفیتِ پاسخِ AI.

بدونِ این، پیشنهادها به سمتی می‌روند که با مسیرِ پروژه نمی‌خواند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 02:44:03 +03:30
8b2a92dd3c write down how the two halves connect
هر بار کسی تازه به این پروژه می‌رسد، همان سه چیز را از نو کشف می‌کند: اینکه
فرانت مسیرهای نسبی صدا می‌زند و nginx به‌جای ۴۰۴ صفحه‌ی اپ را برمی‌گرداند،
اینکه ترتیبِ بالا آوردن مهم است، و اینکه کلاینت را نباید از روی حافظه نوشت.

همه در CLAUDE.md نوشته شد، نه در فایلِ تازه — همان‌جایی که خوانده می‌شود.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 02:39:40 +03:30
2218f82938 give topic cards a colour before anyone picks one
رنگ و آیکونِ مبحث تازه اضافه شده بودند و هنوز هیچ‌کس پرشان نکرده، یعنی
کارت‌ها تا وقتی کسی از پنلِ ادمین وقت بگذارد بی‌رنگ می‌ماندند.

حالا وقتی فیلد خالی است، مقدارِ پیش‌فرض از پالت و آیکون‌های خودِ اپ می‌آید
(همان رنگ‌های طرح و همان آیکون‌هایی که از lucide وارد می‌شوند)، تا بومی به
نظر برسد نه وصله.

چرخشی بر اساسِ جایگاهِ مبحث در پایه است، نه تصادفی: رنگی که بینِ دو بارگذاری
عوض شود بدتر از بی‌رنگ بودن است، و دو مبحثِ کنارِ هم نباید یک‌رنگ دربیایند.
مقدارِ صریحِ ادمین همیشه برنده است.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 02:10:50 +03:30
10ad1e232c let the subscription card come from the server
کارتِ اشتراک زیرِ هر پلن فهرستِ امکانات و یک برچسب («محبوب‌ترین»، «بهترین
ارزش») نشان می‌دهد، و هر دو در کدِ فرانت هاردکد بودند — یعنی عوض کردنِ یک
بندِ متنی به دیپلویِ تازه‌ی اپ نیاز داشت.

Plan حالا features و badge دارد. `popular` سرِ جایش ماند: بولین است و متنِ
برچسب را نمی‌دهد، پس این جایگزینش نیست، کنارش است. هیچ‌کدام اجباری نیستند و
پلن‌های موجود بدونشان همچنان سرو می‌شوند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 02:03:15 +03:30
99a0cafe24 serve what the frontend screens actually need
صفحه‌های فرانت خوانده شدند و با API مقایسه شدند. ساختارِ ما از کتابِ واقعی
درآمده و درست‌تر است، پس مدل عوض نشد — ولی چند چیز کم داشت.

مهم‌ترینش یک باگ بود: /api/home پارامتر را `grade` می‌خواند و کلاینتی که به
فرانت فرستادیم `gradeId` می‌فرستد. پارامترِ ناشناخته بی‌صدا نادیده گرفته
می‌شد، یعنی کاربر پایه‌ای را انتخاب می‌کرد و نقشه‌ی پایه‌ی دیگری می‌دید
بدونِ هیچ خطایی. حالا هر دو نام پذیرفته می‌شوند (`gradeId` نامِ درست است،
با بقیه‌ی API یکی). تستِ سرتاسریِ قبلی این را نگرفته بود چون home را بدونِ
آرگومان صدا زده بود.

/api/grades حالا lessonCount می‌دهد. فهرستِ پایه‌های فرانت تا دوازدهم است
ولی محتوای ما تا نهم؛ بدونِ این عدد، کاربر پایه‌ی خالی را انتخاب می‌کند و
صفحه‌ی خالی می‌بیند بی‌آنکه بفهمد چرا.

GET /api/nodes/{id}/lessons اضافه شد. /api/home فقط شناسه‌ی درس‌ها را
می‌دهد، ولی نقشه‌ی مهارت برای هر قدم عنوان، نوع، زیرنویس و «انجام شد» لازم
دارد — بدونش فرانت باید برای هر درس یک intro جدا می‌گرفت.

Lesson حالا kind (آزمون/ویدیو) و متنِ سه‌تکه دارد: توضیح، مثال، حلِ
گام‌به‌گام. ویدیو از قبل سوالِ بازِ محصول بود و فرانت آیتمش را دارد؛ حالا
API می‌تواند بیانش کند، حتی اگر محتوایش بعداً بیاید. Unit یک blurb گرفت و
Node آیکون و رنگ، تا فرانت رنگ‌ها را هاردکد نکند. هیچ‌کدام اجباری نیست، پس
محتوای موجود دست‌نخورده کار می‌کند.

PATCH /api/user/profile حالا age را هم می‌پذیرد (صفحه‌ی ثبت‌نامِ فرانت سن
می‌پرسد). ذخیره‌سازی همچنان birthYear است چون کهنه نمی‌شود؛ این فقط ورودی
را ترجمه می‌کند تا دو طرف حسابِ متفاوت درنیاورند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 01:42:24 +03:30
8effe5b60b secure the admin session and make traffic visible from outside
دو شکاف که وقتی آدم‌های دیگر به پروژه وصل می‌شوند مهم‌تر می‌شوند.

کوکیِ نشستِ پنلِ ادمین و توکنِ CSRF بدونِ پرچمِ secure رد و بدل می‌شدند.
احرازِ هویتِ خودِ API کوکی نیست و این‌ها فقط برای ادمین‌اند — یعنی دقیقاً
همان‌جایی که اعتبارنامه‌ی واقعی وارد می‌شود. حالا خارج از DEBUG امن‌اند، و
SECURE_PROXY_SSL_HEADER هم اضافه شد چون TLS روی nginx تمام می‌شود و بدونش
جنگو درخواست را HTTP می‌بیند و کوکیِ امن اصلاً ست نمی‌شود.

SECURE_SSL_REDIRECT و HSTS عمداً روشن نشدند: اولی پشتِ پراکسیِ بدتنظیم
حلقه‌ی ریدایرکت می‌سازد و کلِ سایت را می‌خواباند، دومی در مرورگرِ کاربر کش
می‌شود و عملاً برگشت‌ناپذیر است. هر دو تصمیمِ زیرساختی‌اند نه کد.

/api/health حالا ترافیک را هم گزارش می‌دهد: تعدادِ کاربر، فعال‌های ۲۴ ساعتِ
اخیر، و زمانِ آخرین فعالیت. تا امروز هیچ راهی نبود از بیرون بفهمیم فرانت
واقعاً به سرور رسیده یا نه — باید منتظر می‌ماندیم کسی بگوید. فقط شمارش و
زمان؛ تست جداگانه بررسی می‌کند هیچ شماره یا نامی بیرون نرود.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 01:20:02 +03:30
39a9f435ac make the deploy script survive an unfamiliar server
دو حالتی که فقط روی سرورِ واقعی پیش می‌آیند، نه اینجا.

اسکریپت‌های قدیمیِ activate به PS1 بدونِ محافظ دست می‌زنند و زیرِ set -u با
«unbound variable» کلِ دیپلوی را می‌خوابانند — venv اینجا سالم است ولی venv
سرور لزوماً نه، پس set -u دورِ همان یک خط موقتاً خاموش می‌شود.

بررسیِ سلامت فقط 127.0.0.1:8000 را می‌زد. اگر gunicorn روی سوکت یا پورتِ
دیگری باشد، دیپلویِ کاملاً موفق هم با پیامِ خطا تمام می‌شد. حالا آدرسِ
عمومی هم امتحان می‌شود و در پایان می‌گوید کدام آدرس‌ها را زده.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 00:53:43 +03:30
a3a47b459d stop an existing .env from cancelling the defaults meant to replace it
پیش‌فرض‌های دیروز روی یک سرورِ تازه کار می‌کردند ولی روی سرورِ واقعی نه، و
تستِ با .env واقعی نشانش داد: هر دو حالتِ شایع خنثی‌شان می‌کرد.

مقدارِ موجود ولی ناقص: .env سرور از قبل
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 دارد. چون مقدار خالی نبود، کاملاً
جایگزینِ پیش‌فرض می‌شد و دامنه‌ی api.math.tadrisino2.ir باز هم غایب می‌ماند —
یعنی همان «Disallowed Host» قبلی، و باز هم ویرایشِ دستیِ فایل. حالا
دامنه‌های خودمان به مقدارِ محیطی اضافه می‌شوند نه جایگزینش؛ همین برای
CSRF_TRUSTED_ORIGINS و CORS_ALLOWED_ORIGINS هم اعمال شد.

مقدارِ موجود ولی خالی: PUBLIC_BASE_URL در .env هست و خالی است، و
os.environ.get فقط وقتی کلید *نباشد* پیش‌فرض می‌دهد. نتیجه‌اش paymentUrl
نسبی و callbackِ خالیِ زرین‌پال بود — خرابی‌ای که فقط موقعِ اولین پرداختِ
واقعی معلوم می‌شود. حالا مقدارِ خالی هم به پیش‌فرض می‌افتد.

این سه‌تا همان‌هایی‌اند که تا امروز مسئولِ سرور باید دستی به .env اضافه
می‌کرد و چون در .env.example هم نبودند، هر بار از نو کشف می‌شدند. حالا در
همان فایل مستند شده‌اند و تست نگهبانشان است.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 00:00:35 +03:30
3dacf3a50f fix two checks that only broke in the case they exist for
بازبینیِ کارِ امروز، سه مورد پیدا کرد.

/api/health وقتی مهاجرتِ اعمال‌نشده وجود داشت می‌شکست: کدِ فهرست‌کردنِ
مهاجرت‌ها به migration.key تکیه می‌کرد که اصلاً وجود ندارد (فیلدها app_label
و name هستند). یعنی دقیقاً در همان حالتی که این بررسی برایش ساخته شده،
به‌جای نامِ مهاجرت‌ها فقط AttributeError برمی‌گرداند. حالا تستی دارد که
مهاجرتِ معلق را شبیه‌سازی می‌کند — چیزی که نبودش باعث شد اولین بار غلط بماند.

وارد کردنِ سوال با replace عددِ غلط گزارش می‌کرد: delete() در جنگو مجموعِ
همه‌ی ردیف‌های آبشاری را می‌دهد، پس «۵ سوال پاک شد» در واقع گزینه‌ها و
پاسخ‌ها را هم می‌شمرد.

deploy.sh بررسیِ متغیرهای محیطی‌اش با تنظیماتِ جدید ناسازگار شده بود:
متغیرهایی را اجباری می‌دانست که حالا پیش‌فرض دارند، و فقط شل را می‌خواند نه
.env — یعنی روی سرورِ درست هم دیپلوی را رد می‌کرد. حالا به‌جای تکرارِ منطقِ
تنظیمات، از خودِ جنگو می‌پرسد چه چیزی مؤثر است و گزارش می‌دهد.

کوچک‌تر: بررسیِ سلامت یک درخواست می‌زند نه دو تا، و شکستِ ری‌استارت دیگر
اسکریپت را بی‌صدا تمام نمی‌کند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 21:42:36 +03:30
e5d2427560 keep one env example instead of two
دو فایلِ نمونه یعنی دو جا که باید هم‌زمان به‌روز بمانند، و همان چیزی بود که
قبلاً باعث شد نمونه عقب بماند و کسی که سرور را بالا می‌آورد نداند چه بنویسد.
حالا یک فایل، هم برای ماشینِ محلی هم برای سرور.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 21:15:42 +03:30
3e31fea42e decide in the repo what the server should not have to guess
فایلِ نمونه‌ی قبلی هنوز چند جای خالی داشت که کسی باید پرشان می‌کرد، و دو بار
همین باعثِ رفت‌وبرگشت شد. حالا فقط چیزهایی جای خالی‌اند که واقعاً فقط آن طرف
می‌داند: کلیدهای سرویس‌های بیرونی و نامِ سرویسِ systemd.

بقیه از قبل تصمیم گرفته شده‌اند و اگر فایل خالی هم باشد سرویس بالا می‌آید.
حالتِ پیش‌فرض عمداً مرحله‌ی توسعه است (DEBUG=1) با ریسک‌هایش نوشته، نه
پروداکشن — چون وضعیتِ امروزِ پروژه همین است و فایلی که وضعیتِ واقعی را
توصیف نکند دوباره حدس‌زدنی می‌شود.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 21:09:12 +03:30
37d66a4a4c give content editors a drill-down browser and per-question signals
پیدا کردنِ «سوال‌های فلان مبحث» در فهرستِ پیش‌فرضِ جنگو عملاً ممکن نبود: باید
بینِ چند صفحه‌ی جدا با دراپ‌داون‌های هزارتایی بالا و پایین می‌رفتی.

/admin/content/question/browse/ چهار ستونِ آبشاری دارد — پایه → فصل → درس →
مبحث — و هر ستون فقط وقتی پر می‌شود که قبلی انتخاب شده باشد، پس هیچ‌وقت
فهرستِ هزارتایی جلوی چشم نیست. ته‌اش سوال‌های همان مبحث با گزینه‌هایشان
می‌آیند و از همان‌جا ویرایش یا اضافه می‌شوند.

کنارِ هر سوال دو عددِ تصمیم‌ساز: گزارش‌های بازِ کاربران، و درصدِ پاسخِ غلط.
درصد زیرِ ۵ پاسخ نشان داده نمی‌شود — یک نمونه‌ی کوچک سوالِ سالم را
«مشکل‌دار» جلوه می‌دهد. همین دو ستون به فهرستِ سوال‌ها هم اضافه شدند.

content.QuestionReport و POST /api/questions/{id}/report تازه‌اند: تنها
بازخوردی که تا امروز جمع می‌شد مالِ چتِ دستیار بود، و روی سوال‌های مسیرِ
آموزشی هیچ راهی نبود که کاربر بگوید گزینه‌ی درست غلط است. گزارشِ بازِ تکراریِ
همان کاربر ردیفِ تازه نمی‌سازد تا دابل‌کلیک آمار را به‌هم نزند.

وابستگیِ تازه‌ای اضافه نشد: چند اندپوینتِ کوچکِ JSON و JavaScript ساده.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 21:08:00 +03:30
67361f6711 boot with sane defaults instead of refusing to start
سه بار پشتِ سر هم همین الگو تکرار شد: یک متغیرِ محیطی ست نبود، جنگو موقعِ
بالا آمدن مرد، و از بیرون فقط «502 Bad Gateway» دیده شد. کسی که سرور را بالا
می‌آورد هیچ سرنخی نداشت، و تشخیص هر بار یک رفت‌وبرگشتِ یک‌روزه بود.

ALLOWED_HOSTS و CSRF_TRUSTED_ORIGINS حالا دامنه‌های شناخته‌شده‌ی خودمان را
پیش‌فرض دارند. CSRF مخصوصاً مهم است: بدونش ورود به پنلِ ادمین از پشتِ پراکسیِ
HTTPS «CSRF verification failed» می‌دهد و علتش از پیام معلوم نیست.

SECRET_KEY اگر ست نباشد، در پروداکشن یک کلیدِ تصادفیِ واقعی ساخته و در
.django_secret_key نگه داشته می‌شود (۰۶۰۰، خارج از گیت). همان‌قدر
غیرقابلِ حدس، بدونِ یک قدمِ دستیِ دیگر؛ و چون روی دیسک می‌ماند، ری‌استارت
نشست‌های کاربران را باطل نمی‌کند.

CACHE_URL نبودن دیگر مانعِ بالا آمدن نیست. مشترک نبودنِ کش بینِ workerها یک
ضعفِ واقعی‌ست ولی دلیلِ خوبی برای خوابیدنِ کلِ سرویس نیست؛ حالا بالا می‌آید و
/api/health صریحاً هشدار می‌دهد. تستِ مربوطه هم از «باید بالا نیاید» به
«باید بالا بیاید ولی بی‌صدا نماند» تغییر کرد.

متغیرِ محیطی همچنان بر همه‌ی این پیش‌فرض‌ها اولویت دارد.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 21:08:00 +03:30
9cee574fa9 put the whole content pipeline behind the admin panel
سوال‌ها از قبل فله‌ای وارد می‌شدند ولی دو شکاف مانده بود: افزودنِ تکی عملاً
غیرقابلِ استفاده بود، و خودِ برنامه‌ی درسی هیچ راهی جز ترمینالِ سرور نداشت.

افزودنِ تکیِ سوال: انتخابِ درس یک دراپ‌داونِ ۱۳۰۷تایی بود. حالا autocomplete
است و بر اساسِ عنوانِ درس، فصل و پایه جستجو می‌شود.

برنامه‌ی درسی (Grade → Unit → Node → Lesson) حالا از /admin/content/grade/
وارد و خارج می‌شود. تا امروز رساندنش به سرور یعنی اجرای rag_import_collection
و بعد seed_curriculum_from_textbook روی همان سرور — یعنی وابستگی به Qdrant و
به آدمی با دسترسیِ SSH. حالا یک‌بار لوکال ساخته می‌شود و فایلش بالا می‌رود.

هم‌سان‌سازی با عنوان است نه با id: idها بینِ دو دیتابیس یکی نیستند و تکیه بر
آن‌ها یعنی وارد کردن روی سرور محتوای بی‌ربط را بازنویسی می‌کند.

وارد کردنِ برنامه‌ی درسی عمداً چیزی پاک نمی‌کند و idempotent است — یک فایلِ
ناقص نباید محتوای سرور را از بین ببرد، و اجرای دوباره نباید چیزی را دو بار
بسازد. (وارد کردنِ سوال‌ها اما replace دارد، چون جایگزینیِ سوال‌های یک درس
کارِ رایجی است.)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:46:50 +03:30
eaa4b761dd stop a deploy from silently blocking the frontend or leaving 0000 open
سه حالتِ خراب که هیچ‌کدام در لاگِ سرور ردی نمی‌گذارند و فقط وقتی کشف می‌شدند
که یک آدم از بیرون اپ را دستی امتحان کند.

CORS_ALLOWED_ORIGINS فقط از env خوانده می‌شد و با DEBUG روشن به localhost
محدود می‌ماند. یعنی بک‌اندِ کاملاً سالم، ولی مرورگر همه‌ی درخواست‌های
math.tadrisino2.ir را بلاک می‌کند — خطایی که سمتِ سرور دیده نمی‌شود و فقط در
کنسولِ مرورگر پیداست. origin های شناخته‌شده حالا پیش‌فرضِ داخلِ کدند تا یک
متغیرِ فراموش‌شده اتصال را نشکند؛ env همچنان بازنویسی‌شان می‌کند.

/api/health حالا warnings هم می‌دهد. مهم‌ترینش: با DEBUG روشن روی سرورِ عمومی،
DEV_MASTER_OTP یعنی هر کسی با کدِ 0000 می‌تواند به‌جای هر شماره‌ای وارد شود.
این تا امروز فقط با خواندنِ تنظیماتِ سرور معلوم می‌شد، و ما به سرور دسترسی
نداریم. allowedOrigins هم برگردانده می‌شود تا مشکلِ CORS از بیرون قابلِ
تشخیص باشد.

deploy.sh قبل از دست زدن به دیتابیس متغیرهای لازم را بررسی می‌کند و با پیامِ
روشن می‌ایستد، به‌جای اینکه وسطِ کار بشکند و 502 بدهد.
.env.production.example همان‌ها را یک‌جا مستند می‌کند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:46:23 +03:30
190bff3d0f manage questions from the admin panel, not the server terminal
تا الان تنها راهِ گذاشتنِ سوال روی یک سرور، زدنِ دستور در ترمینالِ همان سرور
بود. یعنی هر تغییرِ محتوایی به کسی که SSH دارد وابسته می‌شد، تاریخچه نداشت و
برگشت‌پذیر نبود. محتوا داده است نه کد؛ باید با رابطِ کاربری ویرایش شود.

در /admin/content/question/ دکمه‌ی «وارد کردنِ سوال‌ها از فایل» اضافه شد، و
اکشنِ «دریافتِ فایلِ سوال‌های انتخاب‌شده» خروجی می‌دهد. خروجی دقیقاً همان قالبی
است که ورودی می‌پذیرد، پس چرخه‌ی «بگیر → ویرایش کن → برگردان» بسته است و برای
گرفتنِ نمونه هم لازم نیست کسی مستندات بخواند.

قالب عمداً ساده است (lessonId، prompt، choices، correct) تا کسی که برنامه‌نویس
نیست هم بتواند در ویرایشگرِ متن درستش کند.

اعتبارسنجی قبل از هر نوشتنی کامل انجام می‌شود و کلِ فایل در یک تراکنش است:
فایلِ نامعتبر هیچ چیزی نمی‌نویسد. وارد کردنِ نیمه‌کاره بدترین حالت بود، چون
کسی که فایل را داده فکر می‌کند کار شده ولی درس نصفه پر است.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:29:24 +03:30
cbe9b63dc4 make a deploy observable and one command instead of a checklist
بالا آوردنِ نسخه‌ی جدید تا الان چند کارِ دستی به ترتیبِ درست بود (pull، نصبِ
وابستگی‌ها، مهاجرت، استاتیک، ری‌استارت) و فراموش شدنِ هر کدام اپ را می‌خواباند
با خطایی که از بیرون فقط «502 Bad Gateway» دیده می‌شد. همین الان هم سرور
دقیقاً به همین دلیل خوابیده: کامیتِ ac1b1af پکیجِ django-cors-headers را اضافه
کرد، و بدونِ pip install جنگو با ModuleNotFoundError بالا نمی‌آید.

deploy.sh همه را به ترتیب و idempotent انجام می‌دهد. pip install همیشه اجرا
می‌شود، نه فقط وقتی requirements عوض شده — تشخیصِ «عوض شده یا نه» خودش یک جای
فراموش‌شدنیِ دیگر است. آخرِ کار خودش سلامت را بررسی می‌کند و اگر بالا نیامده
باشد علت‌های رایج را چاپ می‌کند، به‌جای اینکه کسی بعداً از بیرون کشفش کند.

GET /api/health وضعیت را بدونِ احراز و بدونِ SSH بیرون می‌دهد: دیتابیس،
مهاجرت‌های اعمال‌نشده، کش، و شمارشِ پایه/درس/سوال روی همان سرور. مهم‌ترینش
مهاجرتِ اعمال‌نشده است — حالتی که اپ ظاهراً بالا می‌آید و بعد زیرِ بارِ واقعی
با خطای ستونِ ناموجود می‌شکند. عمداً هیچ سکرتی برنمی‌گرداند، فقط بله/خیرِ
«تنظیم شده».

۵۰۳ برمی‌گرداند وقتی دیتابیس/کش/مهاجرت خراب است تا پایشِ خودکار بتواند رویش
حساب کند؛ نبودِ محتوا خرابی حساب نمی‌شود ولی گزارش می‌شود.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:28:59 +03:30
5f3aae0756 cover the new paths, especially the ones that fail silently
خطاهای این پروژه خاموش‌اند: نامِ اشتباهِ collection یا فیلترِ نخورده نتیجه‌ی
خالی می‌دهد نه استثنا، و دستیار فقط بدتر جواب می‌دهد. این تست‌ها نگهبانِ
همان‌جاهایند.

TextbookLayoutTests بررسی می‌کند رشته‌ی هر فصل بینِ ingest و retrieval یکی
باشد و بازه‌ی صفحه‌ها پیوسته بماند. IngestBookResumeTests مطمئن می‌شود
صفحه‌ی بی‌مسئله دوبار پول نمی‌گیرد، صفحه‌ی خطادار در اجرای بعد دوباره
سراغش می‌روند، و upsertِ شکست‌خورده rollback می‌شود.
LessonContextResolutionTests نگهبانِ نرمال‌سازیِ «پایه نهم» → «ریاضی نهم»
است و اینکه مسیرِ gradeId و مسیرِ lessonId به یک collection برسند.
GradeAwarePromptTests جلوی برگشتنِ لحنِ دبستانی برای پایه‌های بالا را
می‌گیرد. FollowUpStaysGroundedTests پیامِ دومِ یک گفتگو را هم مستند نگه
می‌دارد.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:01:24 +03:30
ac1b1af398 open the API up to the frontend
سینیور خواست زودتر به فرانت‌اند وصل شویم تا از آنجا هم تست شود. این‌ها
سمتِ ما لازم بودند:

CORS با allowlist صریح (نه ALLOW_ALL): احرازِ هویتِ ما توکنِ Bearer در هدر
است، پس origin ناشناس نباید از مرورگرِ کاربر به API ما بزند.
CORS_ALLOW_CREDENTIALS عمداً خاموش می‌ماند — کوکی‌ای در کار نیست و روشن
کردنش فقط سطحِ حمله اضافه می‌کند. CorsMiddleware بالاتر از CommonMiddleware
گذاشته شد وگرنه ریدایرکتِ APPEND_SLASH و خطای ۴۰۰ هدرِ CORS نمی‌گیرند و در
مرورگر به‌شکلِ خطای مبهمِ شبکه دیده می‌شوند.

PATCH /api/user/profile اضافه شد؛ تا پیش از این هیچ راهی برای ثبتِ نامِ
کاربر نبود. GET /api/home بدونِ پارامتر حالا پایه‌ی ذخیره‌شده‌ی کاربر را
برمی‌گرداند و POST /api/ai/ask فقط با lessonId کار می‌کند — هر دو برای
اینکه فرانت لازم نباشد پایه را خودش نگه دارد. questionCount به
lessons/{id}/intro اضافه شد.

docs/frontend/api.ts و README.md کلاینتِ تایپ‌دار و راهنمای اتصال‌اند، از
روی OpenAPI واقعیِ همین سرویس تولید شده‌اند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:01:06 +03:30
fa56308a35 make the tutor grade-aware and its answers measurable
مهم‌ترین خواسته‌ی سینیور این بود که بسنجیم پاسخِ 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>
2026-09-13 19:00:40 +03:30