Commit Graph

145 Commits

Author SHA1 Message Date
0c6b08c9ff make every grade's id its class number and sort grades by it everywhere
Grade ids were the order the books were imported in (ninth=1, eighth=5), so
gradeId: 5 meant eighth grade and the admin, the question filter and the API
all listed grades out of order. Grade.number (1-12) is now read from the title,
new grades take it as their id, and migration 0008 moves existing ids plus every
reference to them: Unit, the loose grade_id in accounts/ai_tutor/rag, and the
grade target inside campaign JSON. Unnumbered demo grades move above 100 and the
id sequence is raised past 12 so they never collide with a real grade.

The frontend needs no change: it always reads gradeId from the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:47:02 +03:30
d70737dddf record that the server now has RAG on, seven demo logins and demo data
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:07:34 +03:30
7d6abab15c document the twenty environment variables the code reads but never mentioned
A mechanical cross-check of os.environ.get() in settings against .env.example
found twenty variables the app reads that were written down nowhere. Most have
harmless defaults, but three would have cost someone real time:

- NUM_PROXIES decides how many X-Forwarded-For hops are trusted, and guest rate
  limiting is keyed on the resulting IP. Set too high, a client can forge the
  header and walk past the limit; too low and everyone collapses onto the proxy's
  own IP. CLAUDE.md warns about it; the file an operator actually edits did not.
- KAVENEGAR_SENDER is read alongside the API key, so someone configuring SMS from
  the README alone would have set only half of it.
- INTERNAL_SERVICE_KEY and PAYMENT_WEBHOOK_SECRET are empty under DEBUG=0 by
  design, which quietly keeps the ingest and webhook routes shut. That is the
  right behaviour and exactly the kind of thing that reads as a bug when it is
  not written down.

The whole Cafe Bazaar block was undocumented too.

Three variables in .env.example are not read by settings and were left alone:
GRAFANA_ADMIN_PASSWORD is consumed by docker-compose, TADRISINO_SERVICE by
deploy.sh, and OPENAI_API_KEY_OPENAI_DIRECT is a documented parking spot.

CLAUDE.md's current-state section was a day stale (458 tests, now 483) and said
nothing about the documentation restructure, the RTL docs shell, the test-login
allowlist or the frontend test account, so anyone picking this up would have
rediscovered all four. It now also records that backups are still manual and
that sqlite3 is not installed on the server.

Also verified mechanically, with no findings: no docstring references an
endpoint absent from the OpenAPI schema, no docstring names a setting or
constant that does not exist, and every internal documentation link resolves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 02:11:29 +03:30
5003b8a852 write down the frontend test account, which existed only in one person's notes
A shared guest account was hand-seeded into the production DB on 2026-09-22 so
the frontend developer could see the notification and leaderboard screens before
any real traffic exists: deviceId "frontend-test-device", user 6, with 24
Notification rows and 20 LeaderboardEntry rows across both periods, ordered
differently per period so the ?period= parameter is visibly doing something.

None of that was recorded in the repo. db.sqlite3 is gitignored, so the account
existed only in the head of whoever set it up — exactly the kind of thing that
disappears at a handoff. It is now in docs/frontend/README.md, where the
frontend developer looks, and in docs/operations.md, where whoever runs the
server looks.

Verified against production before writing it down: the account still resolves
to user 6 with isNewUser false, notifications return rows, and both leaderboard
periods return six entries. The notifications payload is keyed "items", not
"results", which is noted because it is easy to read as an empty response.

The operations entry carries the warning that matters: this is hand-written data
in a production database, purge_demo_data will not catch it (it only matches
[SEED]/[DEMO] prefixes on Grade/Plan/Character/User), and it has to go before
real users arrive or they will see invented notifications.

The Swagger description now says a deviceId can be any string and that
pre-seeded ones exist on request, but deliberately does not print the specific
value: /api/docs/ is reachable from the public internet, and a public API
reference should not advertise a shared account that anyone can sign into.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 01:55:16 +03:30
ac3999ea92 render the Persian API docs right-to-left
Swagger UI and Redoc lay every description out left-to-right, because they were
built for English. Our text is Persian, and in an LTR block the sentence-final
full stop jumps to the start of the line, table columns come out reversed, and
every English word inside a Persian sentence lands in the wrong place. The
screenshot of /api/docs/ showed all three.

This is not fixable by rewriting the text, because the base direction of the
block is what is wrong. Both views now render through a thin template of ours
that extends the drf-spectacular one via {{ block.super }} and adds scoped CSS:
right-to-left for the blocks that carry rendered Markdown, and an explicit
left-to-right override for code and pre, so that "Authorization: Bearer <token>"
and every JSON sample stay readable. Table cells align right, list bullets move
to the right margin, and the API title keeps its LTR treatment since it is
English.

Writing the description in English instead would have fixed only the header. The
per-endpoint descriptions are Persian across the whole codebase and were broken
the same way, so the shell fixes the entire page rather than one block of it,
and keeps the repo's convention of Persian documentation.

The upstream templates are untouched, so a drf-spectacular upgrade cannot be
broken by this.

Two tests cover it, because this failure is invisible to every other kind of
check: nothing errors, nothing 500s, the page simply reads wrong to a human.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 01:45:37 +03:30
ae0375c26c make the API docs page answer a consumer's first questions, and fix a missing endpoint
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>
2026-09-23 01:28:32 +03:30
5492788bf8 make the README an entry point and move procedures into docs/
The README had grown into a 295-line mixture of onboarding, an ops runbook, a
content-authoring workflow, bulk question generation and a Docker log stack. A
reader arriving at the repo could not tell in ten seconds what this service is,
whether it is live, or where to start, and five of its claims no longer matched
the code:

- it said to export .env values by hand; settings.py has loaded .env through
  python-dotenv for a while now
- it listed six variables as "everything the app actually reads"; .env.example
  has carried many more than that for some time
- it called DJANGO_SECRET_KEY, DJANGO_ALLOWED_HOSTS and CACHE_URL required in
  production; all three now have working defaults, and the app boots without
  them on purpose so a partial .env cannot take the service down
- it claimed there is no insecure SECRET_KEY fallback; there is one, under DEBUG
- it required Python 3.12 while the project runs on 3.11 (Docker uses 3.12)

The README now states what the service is, where it runs, how healthy it is and
how to get it running in six commands, then links onward. Procedures moved to
docs/operations.md, docs/content-authoring.md, docs/architecture.md and
docs/docker.md, which is where someone follows a procedure rather than skims it.
Nothing was dropped; the deploy one-liner now includes the separate sudo restart
that deploy.sh cannot perform itself, and backup/restore is written down for the
first time.

Every number in the README was measured rather than quoted: content counts from
the live /api/health and content/seed/curriculum.json, 55 endpoints from the
generated OpenAPI schema, 481 tests from a full run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 01:12:04 +03:30
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