- content: POST nodes/{id}/quiz/complete and lessons/{id}/complete refuse a locked island
(403 node_locked, same rule as /api/home) and a user who has not answered every question
(409 answers_missing). Before, only a user with zero hearts and no answers was refused, so
anyone else finished any island, locked or not, and finishing a locked island's lessons
unlocked it. A node or lesson with no questions still passes when unlocked, with no XP.
The client can no longer understate totalQuestions to buy stars.
- throttle_scope on 15 routes that had none (question bank without login, intro, hearts,
notifications, tickets, plans, subscription status, orders, offers, affiliate, assistant
history and feedback, logout).
- admin: OTPCode and Session can only be added/changed by a superuser.
- deploy.sh: integrity-checked DB backup before migrate (keeps the last 2); no migrate without it.
- docs and CLAUDE.md updated to say what is verified and what is not.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
accounts.0008 drops the columns and refuses to run while guest rows exist.
Docs describe the current state; tests that only proved guest behaviour are gone.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
POST /api/auth/session is gone (404). Existing is_guest rows keep refresh and
upgrade-by-phone until the demo/guest rows are purged; the code that serves
them is listed in docs/security-before-production.md §10. Tests that used the
endpoint as a fixture now use accounts/testing.guest_session.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- accounts admin: phone/is_guest read-only for non-superusers, device_id filter lookups refused,
auth.User/Group admin writable only by superusers
- campaigns: delivery log needs view permission
- phone/OTP/birth date: any Unicode digit normalised or rejected with 400; unknown gradeId is 400
- shared demo guest is never turned into a person's account by logging in
- /api/home with unknown gradeId is no longer cached (LocMemCache flood reset throttle counters)
- /api/progress/nodes: one query, huge unit is 400
- BazaarCredential token filter lookups refused
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- Admin: refund/cancel/mark-resolved/Bazaar-link actions need change permission; question and
curriculum import need add/delete; content browser needs view_question; guest device ids are
shown to superusers only.
- Accounts: registering a guest's phone revokes the guest's earlier sessions; self-deletion keeps
Redemption, UserOffer and the phone's OTP rows; the public demo guest cannot be deleted
(DEMO_GUEST_DEVICE_IDS); display-name contact filter covers any separator and social handles.
- Payment: callback without Authority is 400; a second Bazaar purchase on a paid order is 409;
orders snapshot credits/hearts/days at creation (payment.0011).
- Assistant: a broken diagram no longer makes a streamed answer free; a late off-topic marker
refuses the answer (not stored, not cached); a disconnect saves the partial answer; only
KNOWN_COLLECTIONS are searched.
- Infra: secret-key file is created atomically (workers no longer split keys); no default
INTERNAL_SERVICE_KEY; Kavenegar key and Bazaar token kept out of error logs; Swagger/ReDoc
pinned; schema throttled; docker compose bound to 127.0.0.1; non-ASCII digit ids are 400;
ticket text capped at 5000.
- docs/security-before-production.md: remaining open items 24-31 and the guard tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- seed_content no longer undoes admin work: a SeedRecord ledger (content/seed_sync.py)
stops deleted/renamed/re-worded content from being recreated and keeps admin-edited
lesson text; new rows in the seed files still arrive (migration content.0010)
- plan list cache is invalidated on Plan save (receiver used a stale 'v1' key)
- token refresh per-IP cap 30/hour -> 600/hour (TOKEN_REFRESH_IP_RATE)
- a failed gateway result only changes a pending order (forged NOK no longer
overwrites refunded/canceled)
- tutor gets the correct answer only after the student has answered
- support reply notification moved to a TicketMessage post_save signal
- global daily cap on paid AI questions (AI_GLOBAL_DAILY_MAX, default 3000)
- Bazaar verify uses only the order's SKU; bazaar orders require a SKU
- /api/health warns on the committed signing key; deploy.sh saves local commits;
400 for bad ?unit= / ?period=; account delete keeps the referrer's Conversion
- earlier today: no stub answers outside DEBUG, ?grade= removed, logged fallbacks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- /api/ai/ask: the stub answer is reachable only under DEBUG or
RAG_FORCE_MOCK. Without RAG keys on the server it used to return 200 with
"this is a stub, set OPENAI_API_KEY" and spend the student's quota; now 503.
- /api/home: the `grade` alias is gone and sending it is a 400 instead of a
silent fallback to the user's own grade. No client sent it.
- activate_subscription logs ERROR when the paid order's plan no longer exists
(it silently became 30 days).
- resolve_grade_title logs instead of `except: pass`; curriculum_io no longer
reads an `intro_text` key no file contains.
- CLAUDE.md: "No silent fallbacks" rules for future agents.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The zero-hearts check moved inside the answer transaction: two wrong
answers sent at once with one heart left were both accepted (reproduced
with two threads).
- Finishing a lesson or an island without hearts and without having
answered its questions is now 403 no_hearts; before, quiz/complete let
a student move on island after island with zero hearts. Losing the last
heart on the last question still finishes the island.
- A heart that came back while waiting is now saved before answering, so
the reply no longer says 0 hearts left (the app then showed the
no-hearts page to someone who had a heart).
- The 403 also carries fullRefillAt for the waiting screen.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- remove the family app from the root (separate parent accounts, invite codes, parental
settings); progress.0006 drops its tables, permissions and migration history, and
Profile.parent_phone, the two family notification types and ?userId= on
/api/progress/nodes go with it
- GET /api/user/weekly-report: last 7 days next to the 7 before (estimated minutes,
questions, accuracy, islands, tutor questions) for the "parent report" page
- profile birthDate in Jalali (accounts/jalali.py), ages 5-99, birthYear derived from it
- GET /api/payment/invoices; orders now snapshot list price and plan title
- tutor answers only math: server pre-filter, scope rule in the prompt (tutor-v11),
refusal marker replaced with fixed text; self-harm gets a help line instead
- streamed answers now record token usage (it was always 0)
- daily tutor quota: AI_DAILY_QUOTA_FREE (default 3) vs Plan.ai_daily_quota
- delete_users management command for wiping test accounts across every app
- docs/ai-cost-and-guardrails.md: measured cost per question and guardrail results
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GET /api/support/tickets returns the caller's last 20 tickets with their
support-reply count, on the read throttle so listing never spends the
30/hour create quota. Before it the app kept ticket ids only in one
browser's localStorage, so a ticket and its answer vanished with the device.
Completion notifications now use Persian digits and drop the
'with 0 stars ... 0 points' sentence when nothing was earned.
.env.example: NUM_PROXIES=2 (ArvanCloud + nginx), KAVENEGAR_OTP_TEMPLATE,
BAZAAR_PISHKHAN_API_SECRET, sandbox off by default; removed variables the
code no longer reads.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diagrams: 14 kinds drawn by the app (plane with constructed points, graphs with
computed roots/extrema/tangents, number lines with intervals, solids, charts,
tables, Venn, mapping, trees, fractions, grids, groups, clock). The catalog is
split by topic and only the matching sections go next to a question (200-1073
tokens instead of 1854). parse() repairs slips seen on 96 real book problems:
Persian digits in JSON numbers, 2*pi, // comments, bare items, Venn with
Persian set names. Measured: 45/48 valid on the second round, 0/8 on plain
arithmetic; visual review of 83: 64 right, 3 weak, 16 wrong.
Content: 82 new questions so no island has fewer than four; fill_lesson_texts
writes Unit.blurb (58) and Lesson.example/steps (590) from verified book
problems, never the lesson's own quiz problem. seed_content only fills empty
fields, so admin edits survive a deploy. Tedi unlocks at 500 xp.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Kavenegar is being switched on, so every OTP request now costs credit:
- phone numbers are normalised to 09XXXXXXXXX everywhere (login, OTP,
family invite, TEST_LOGIN_PHONES keys) and existing rows are migrated;
non-Iranian and landline numbers are rejected before any SMS
- cooldown and a daily per-phone cap are counted in the database, so the
three gunicorn workers no longer triple them
- a global daily OTP SMS cap (default 300) stops SMS pumping
- per-IP rates widened for CGNAT / school Wi-Fi (env-overridable)
- provider message id is logged; SMS text warns not to share the code
Second audit round: an offer no longer shortens a longer paid plan,
Zarinpal error bodies no longer 500, island XP reaches the parent report,
a non-numeric questionId no longer 500s after the answer is generated.
596 tests green.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tutor (prompt tutor-v3-stepwise-plain): plain text instead of LaTeX/markdown,
with rag/services/mathtext.py converting whatever still slips through; full
step-by-step solutions with a check before the final answer; three tones by
grade; the last 6 messages of the conversation go to the model and follow-ups
are no longer served from another user's cache; book solutions the checker
disputes are kept out of the context; a questionId gives the tutor the
practice question and the student's wrong answer. /api/health reports when
the AI credit runs out. rag_model_benchmark compares chat models on verified
book problems (run on the server; credit ran out mid-run).
Map: only lessons that have questions count toward finishing an island,
lessonIds[0] / nextLessonId is always the next lesson to play, and a finished
island stays finished. The Seyar characters (sigma/delta/pi) are seeded.
Audit fixes, each reproduced before fixing:
- the plan is stored on the order, so paying a monthly order after starting
a yearly checkout no longer grants a year
- opening checkout no longer demotes an active subscription; renewals extend
from the current expiry; expired subscriptions are not reported active
- one discount code per order and only before the gateway; a 100% code
completes the purchase; lost Zarinpal confirmations are re-verified
- the unused /api/payment/webhook "mark paid" endpoint is removed
- XP counts only answers recorded on the server
- 6-digit OTP, each code dies after 5 wrong guesses; POST /api/auth/logout
- a guest who signs up keeps their account and progress (test phones excluded)
- affiliate commission on the first real payment; guests excluded
- parents with several children no longer get a 500
- SQLite runs transactions IMMEDIATE, since it ignores select_for_update
- admin login is rate-limited; display names drop phone numbers and links;
userQuestion is capped at 2000 characters
- server-side heart enforcement exists behind HEARTS_ENFORCED (off until the
frontend handles 403 no_hearts)
Not done on purpose: rotating the server's secret key and moving to Postgres.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each heart used to take 24 hours, so a student who lost all five waited up to
five days. Now one heart returns every 5 hours, and 24 hours after the first
heart was lost they all come back at once (the owner's call). /api/user/hearts
reports both times (nextRefillAt, fullRefillAt); an existing timer longer than
5 hours is shortened the first time it is read.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found in a deeper pass over what the frontend's flow actually touches:
- Nothing in the app ever wrote StreakRecord: "days in a row" was 0 forever
for every real user. Answers and lesson/island completions now record the
day (Tehran time); a broken chain shows 0. A streak error never fails the
answer itself.
- /api/leaderboard only changed when someone ran rebuild_leaderboard by
hand. It is now computed live (60s cache); weekly counts island XP too.
- gunicorn ran 3 sync workers with a 30s timeout, so every "ask Pi" stream
pinned a whole worker (~10s, GapGPT buffers the answer) and three at once
queued the entire API. gunicorn.conf.py (read automatically from the
working directory) switches to gthread, 8 threads, 120s timeout.
- Every /api/ response now says Cache-Control: private, no-store, so turning
on caching in the ArvanCloud panel can never serve one student another's data.
- /api/user/summary named no active plan ever ("subscription app not
implemented yet" had long stopped being true).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The frontend plays node quizzes, but a node only completed once every one of its
8-13 lessons was completed separately, so students were stuck after the first
island of each unit. POST /api/nodes/{id}/quiz/complete now completes the node
(any score; score only sets stars/xp) and returns nextNodeId.
Also closes the other backend gaps found while auditing the frontend:
- /api/ai/ask without lessonId now picks the book from the user's grade instead
of skipping retrieval, so the "ask Pi" tab is grounded in the textbook.
- guest sessions return a refreshToken; they used to go silently 401 after 1h.
- lesson_complete throttle 10/hour -> 120/hour (a lesson here is 1-2 questions).
- progressPercent is per grade, not over all 2,617 lessons of every grade.
- a one-year plan (299,000 toman) is created by migration when no plan exists.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
کدِ امروز پوش شد ولی محتوا جا ماند: db.sqlite3 در .gitignore است و سوالها فقط
از مسیرِ content/seed/questions.json به سرور میرسند.
۱٬۴۴۱ → ۱٬۷۸۳ سوال. تازهها از مسئلههای تأییدشدهی هشتم، نهم و دهم ساخته شدند
و هر کدام سه گزینهی انحرافی دارند که اشتباهِ واقعیِ دانشآموز را بازتاب میدهند
(جمعِ توانها بهجای تفریق، جاافتادنِ علامتِ منفی، محیط بهجای مساحت).
اثرش روی مسیرِ بازی: نودهای ناقص از ۵۸ به ۴۱ رسیدند و ۱۸۹ نود از ۲۳۰ حالا
کوییزِ کامل دارند. ۴۱ موردِ باقیمانده همگی از کتابهای سوم، ششم و هفتماند که
امروز دستنخورده ماندند.
مسئلههای Qdrant دیپلوی نمیخواهند — مستقیم روی همان سرور نوشته شدهاند.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
chunkها با تطبیقِ دقیقِ رشته به درس وصل میشدند (problem_type == lesson.title).
عنوانِ درس از یک استخراجِ قدیمی آمده بود و هر بازخوانیِ کتاب عنوانِ تازه میسازد،
پس بعد از بازخوانیِ امروز فقط ۵ تا ۸ درصدِ عنوانها میخوردند — یعنی ۹۵٪ درسها
بیهیچ خطایی هیچ نامزدی پیدا نمیکردند و ساختِ سوال صفر برمیگرداند.
لنگرِ پایدار صفحه است: هر Node دقیقاً یک بخشِ فهرستِ کتاب با بازهی صفحهی مشخص
است (تطبیق ۱۰۰٪: ۳۴/۳۴، ۲۸/۲۸، ۲۳/۲۳) و هر chunk هم source_page دارد. وقتی
عنوان نخورد، از روی بازهی صفحه سراغ chunk میرویم — بدونِ هیچ فراخوانیِ مدلی.
نتیجه: هشتم ۰→۲۰۱، نهم ۰→۴۹، دهم ۰→۱۳۴.
رفتارِ قدیم با --strict-title حفظ شد، و یک محافظ گذاشته شد که یک chunk به دو
درس داده نشود.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`{# ... #}` در جنگو فقط تکخطی است. کامنتی که دیشب اضافه کردم سه خط بود، پس
جنگو کامنت حسابش نکرد و کلِ متنش بالای صفحهی مرورِ محتوا چاپ شد.
کلاً برداشته شد. برای توضیحِ چندخطی در قالب باید {% comment %} استفاده شود،
ولی اینجا اصلاً توضیحی لازم نبود.
همهی قالبهای پروژه برای همین الگو بررسی شدند؛ جای دیگری نبود.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ویو `title` را به کانتکست میدهد و admin/base.html خودش آن را هم در مسیرِ
راهنما و هم بهعنوان <h1> صفحه رندر میکند. قالب یک <h1> دستی هم داشت، پس
عنوان دو بار پشتِ هم چاپ میشد. دستی برداشته شد؛ عنوانِ تبِ مرورگر و مسیرِ
راهنما دستنخورده میمانند چون هر دو از همان `title` میآیند.
بخشِ وضعیت در CLAUDE.md هم بهروز شد: میگفت سرور کدِ قدیمی و دیتابیسِ خالی
دارد، که دیگر درست نیست. بهجایش چیزی نوشته شد که دفعهی بعد به درد بخورد —
اینکه فایلِ روی دیسک یعنی فایلِ مستقرشده نیست، و چه چیزهایی عمداً باز مانده.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
صفحهی مرورِ محتوا در پنلِ ادمین عملاً نامرئی بود: هر پنل زمینهی روشنِ ثابت
داشت (#fff، #f6f6f6، #f1f5f9) ولی رنگِ متن تعیین نشده بود. مرورگری که روی
حالتِ تیره است، رنگِ متنِ ادمین را روشن میکند — و نتیجه متنِ سفید روی زمینهی
سفید بود.
حالا پنلها از متغیرهای خودِ ادمین استفاده میکنند تا با هر دو تم بروند، و
چیپهای رنگی که عمداً زمینهی روشنِ ثابت دارند رنگِ متنِ تیرهی صریح گرفتند.
همین اشکال در صفحهی واردکردنِ سوال هم بود و درست شد.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
سه کارِ بههمپیوسته که از یک کشف شروع شدند: راهحلهایی که 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>
پایه نهم تا دیروز ناقص بود: ۲۸ صفحه از کتاب 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>
تا امروز کوریکولوم بود و سوال نبود: هر ۱۴ سوالِ دیتابیسِ لوکال روی ردیفهای
[DEMO] نشسته بودند و در export کنار گذاشته میشدند. یعنی سینیور میتوانست در
مرورگرِ محتوا پایین برود ولی هیچ درسی آزمونی نداشت — که از «محتوا نداریم»
بدتر است، چون شبیهِ خرابی بهنظر میرسد.
حالا هر ۱۶۸ مبحث دستِکم یک سوالِ چهارگزینهای دارد: ۲۳ برای پایه نهم، ۴۶ ششم،
۴۶ هفتم، ۵۳ سوم. هر سوال به درسی وصل است که عنوانش همان موضوع را میگوید، نه
به یک درسِ دلخواه.
جای گزینهٔ درست با seed ثابت جابهجا شده (۴۴/۴۹/۳۵/۴۰ در چهار موقعیت). اگر
جوابِ درست همیشه گزینهٔ اول باشد، آزمون دیگر چیزی را نمیسنجد و آمارِ
«درصدِ غلط» هم بیمعنا میشود. یک تست همین را نگه میدارد.
اینها نمونهاند نه محتوای نهایی: یک سوال برای هر مبحث فقط نشان میدهد مسیرِ
درس → آزمون → XP کار میکند. سوالهای واقعیِ هر درس کارِ
`generate_questions_from_chunks` است که به اعتبارِ AI نیاز دارد، و از پنلِ
ادمین هم میشود دستی اضافه کرد.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
سرورِ 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>
رنگ و آیکونِ مبحث تازه اضافه شده بودند و هنوز هیچکس پرشان نکرده، یعنی
کارتها تا وقتی کسی از پنلِ ادمین وقت بگذارد بیرنگ میماندند.
حالا وقتی فیلد خالی است، مقدارِ پیشفرض از پالت و آیکونهای خودِ اپ میآید
(همان رنگهای طرح و همان آیکونهایی که از lucide وارد میشوند)، تا بومی به
نظر برسد نه وصله.
چرخشی بر اساسِ جایگاهِ مبحث در پایه است، نه تصادفی: رنگی که بینِ دو بارگذاری
عوض شود بدتر از بیرنگ بودن است، و دو مبحثِ کنارِ هم نباید یکرنگ دربیایند.
مقدارِ صریحِ ادمین همیشه برنده است.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
صفحههای فرانت خوانده شدند و با 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>
دو شکاف که وقتی آدمهای دیگر به پروژه وصل میشوند مهمتر میشوند.
کوکیِ نشستِ پنلِ ادمین و توکنِ 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>
بازبینیِ کارِ امروز، سه مورد پیدا کرد.
/api/health وقتی مهاجرتِ اعمالنشده وجود داشت میشکست: کدِ فهرستکردنِ
مهاجرتها به migration.key تکیه میکرد که اصلاً وجود ندارد (فیلدها app_label
و name هستند). یعنی دقیقاً در همان حالتی که این بررسی برایش ساخته شده،
بهجای نامِ مهاجرتها فقط AttributeError برمیگرداند. حالا تستی دارد که
مهاجرتِ معلق را شبیهسازی میکند — چیزی که نبودش باعث شد اولین بار غلط بماند.
وارد کردنِ سوال با replace عددِ غلط گزارش میکرد: delete() در جنگو مجموعِ
همهی ردیفهای آبشاری را میدهد، پس «۵ سوال پاک شد» در واقع گزینهها و
پاسخها را هم میشمرد.
deploy.sh بررسیِ متغیرهای محیطیاش با تنظیماتِ جدید ناسازگار شده بود:
متغیرهایی را اجباری میدانست که حالا پیشفرض دارند، و فقط شل را میخواند نه
.env — یعنی روی سرورِ درست هم دیپلوی را رد میکرد. حالا بهجای تکرارِ منطقِ
تنظیمات، از خودِ جنگو میپرسد چه چیزی مؤثر است و گزارش میدهد.
کوچکتر: بررسیِ سلامت یک درخواست میزند نه دو تا، و شکستِ ریاستارت دیگر
اسکریپت را بیصدا تمام نمیکند.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
پیدا کردنِ «سوالهای فلان مبحث» در فهرستِ پیشفرضِ جنگو عملاً ممکن نبود: باید
بینِ چند صفحهی جدا با دراپداونهای هزارتایی بالا و پایین میرفتی.
/admin/content/question/browse/ چهار ستونِ آبشاری دارد — پایه → فصل → درس →
مبحث — و هر ستون فقط وقتی پر میشود که قبلی انتخاب شده باشد، پس هیچوقت
فهرستِ هزارتایی جلوی چشم نیست. تهاش سوالهای همان مبحث با گزینههایشان
میآیند و از همانجا ویرایش یا اضافه میشوند.
کنارِ هر سوال دو عددِ تصمیمساز: گزارشهای بازِ کاربران، و درصدِ پاسخِ غلط.
درصد زیرِ ۵ پاسخ نشان داده نمیشود — یک نمونهی کوچک سوالِ سالم را
«مشکلدار» جلوه میدهد. همین دو ستون به فهرستِ سوالها هم اضافه شدند.
content.QuestionReport و POST /api/questions/{id}/report تازهاند: تنها
بازخوردی که تا امروز جمع میشد مالِ چتِ دستیار بود، و روی سوالهای مسیرِ
آموزشی هیچ راهی نبود که کاربر بگوید گزینهی درست غلط است. گزارشِ بازِ تکراریِ
همان کاربر ردیفِ تازه نمیسازد تا دابلکلیک آمار را بههم نزند.
وابستگیِ تازهای اضافه نشد: چند اندپوینتِ کوچکِ JSON و JavaScript ساده.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
سوالها از قبل فلهای وارد میشدند ولی دو شکاف مانده بود: افزودنِ تکی عملاً
غیرقابلِ استفاده بود، و خودِ برنامهی درسی هیچ راهی جز ترمینالِ سرور نداشت.
افزودنِ تکیِ سوال: انتخابِ درس یک دراپداونِ ۱۳۰۷تایی بود. حالا 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>
تا الان تنها راهِ گذاشتنِ سوال روی یک سرور، زدنِ دستور در ترمینالِ همان سرور
بود. یعنی هر تغییرِ محتوایی به کسی که SSH دارد وابسته میشد، تاریخچه نداشت و
برگشتپذیر نبود. محتوا داده است نه کد؛ باید با رابطِ کاربری ویرایش شود.
در /admin/content/question/ دکمهی «وارد کردنِ سوالها از فایل» اضافه شد، و
اکشنِ «دریافتِ فایلِ سوالهای انتخابشده» خروجی میدهد. خروجی دقیقاً همان قالبی
است که ورودی میپذیرد، پس چرخهی «بگیر → ویرایش کن → برگردان» بسته است و برای
گرفتنِ نمونه هم لازم نیست کسی مستندات بخواند.
قالب عمداً ساده است (lessonId، prompt، choices، correct) تا کسی که برنامهنویس
نیست هم بتواند در ویرایشگرِ متن درستش کند.
اعتبارسنجی قبل از هر نوشتنی کامل انجام میشود و کلِ فایل در یک تراکنش است:
فایلِ نامعتبر هیچ چیزی نمینویسد. وارد کردنِ نیمهکاره بدترین حالت بود، چون
کسی که فایل را داده فکر میکند کار شده ولی درس نصفه پر است.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
خطاهای این پروژه خاموشاند: نامِ اشتباهِ collection یا فیلترِ نخورده نتیجهی
خالی میدهد نه استثنا، و دستیار فقط بدتر جواب میدهد. این تستها نگهبانِ
همانجاهایند.
TextbookLayoutTests بررسی میکند رشتهی هر فصل بینِ ingest و retrieval یکی
باشد و بازهی صفحهها پیوسته بماند. IngestBookResumeTests مطمئن میشود
صفحهی بیمسئله دوبار پول نمیگیرد، صفحهی خطادار در اجرای بعد دوباره
سراغش میروند، و upsertِ شکستخورده rollback میشود.
LessonContextResolutionTests نگهبانِ نرمالسازیِ «پایه نهم» → «ریاضی نهم»
است و اینکه مسیرِ gradeId و مسیرِ lessonId به یک collection برسند.
GradeAwarePromptTests جلوی برگشتنِ لحنِ دبستانی برای پایههای بالا را
میگیرد. FollowUpStaysGroundedTests پیامِ دومِ یک گفتگو را هم مستند نگه
میدارد.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
سینیور خواست زودتر به فرانتاند وصل شویم تا از آنجا هم تست شود. اینها
سمتِ ما لازم بودند:
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>
تا پیش از این کلِ محتوای دیتابیس ردیفهای [DEMO] و [SEED] بود — دانشآموزِ
پایه نهم در دمو «جمع تا ۱۰» میدید. سینیور «یک بیسیکِ کامل با دیتای واقعی»
خواسته بود.
seed_curriculum_from_textbook (رایگان، هیچ فراخوانیِ پولی ندارد):
Grade ← پایهی کتاب
Unit ← فصلِ کتاب
Node ← درسِ داخلِ فصل، از فهرستِ خودِ کتاب
Lesson ← نوعِ مسئلهی استخراجشده در بازهی صفحهی همان درس
intro_text مستقیم از generalSolution کتاب میآید.
generate_questions_from_chunks سوالِ چهارگزینهای با گزینههای انحرافیِ
باورپذیر میسازد (هزینهدار، اعتبارسنجیِ سختگیرانه). سوال اینجا جدا شد
چون ساختنِ گزینهی انحرافیِ درست به مدل نیاز دارد.
purge_demo_data ردیفهای [SEED]/[DEMO] را گزارش و — فقط با --apply — پاک
میکند؛ بدونِ آن این ردیفها در دمو داخلِ API دیده میشوند.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
next_refill_at was set on UserHearts but never read or recalculated,
so a heart lost to zero never came back. UserHearts.apply_regen() now
catches up missed 24h intervals on read/deduct, and lose_heart() starts
the timer only when it isn't already running.
Any user with an active (non-expired) subscription now skips heart
deduction entirely, regardless of which plan they're on — good enough
for now until per-plan perks are needed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The skill-map response gave units -> nodes with id/title/status but no way
to map a node to its lessons, and no other endpoint fills that gap
(/api/progress/nodes doesn't either). Any client rendering the map needs
this to know which lesson to open when a node is tapped.
The ids are already computed in the cached _grade_tree; this just surfaces
them. Additive and backward-compatible; content tests pass.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
GET /api/home is the most-hit authenticated endpoint (every app open) and
was running one COUNT per node. Now it is a flat number of queries
regardless of grade size, and the shared curriculum skeleton is served
from cache; per-user progress/stats stay live.
content/views.py
- _node_status(): takes a precomputed set of completed lesson_ids and a
node's lesson_ids instead of firing its own COUNT query.
- HomeView.get(): one query for all completed lesson_ids of the user in
the grade, status computed in Python; grade tree (units -> nodes ->
lesson_ids, no per-user data) read from cache
(content:tree:grade:{id}:v{N}); missing grade -> 404 (was an
unhandled 500); grade with no units -> empty units, not 404.
- GradesListView: served from cache (content:grades:v{N}).
- curriculum_version() / bump_curriculum_version(): one shared version
key; bumping it invalidates every content cache key at once.
- both GET views get throttle_scope='read' + GuestAwareContentThrottle.
content/receivers.py
- post_save/post_delete on Grade/Unit/Node/Lesson -> bump_curriculum_version().
content/tests.py
- HomeViewScalabilityTests: query count flat for 2 vs 20 nodes; 2nd call
hits cache (no content_unit/node SQL); a new Node invalidates the tree;
missing grade 404; empty grade 200.
- GradesListCacheTests: list cached, invalidated on new Grade.
- ReadThrottleTests: /api/home returns 429 past the limit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The math-app frontend prototype drives a RobotMascot with a fixed set of
moods; this gives that animation an explicit backend signal. Complements
the existing Lesson.mascot_animation / LessonIntro.mascotAnimation, which
are unchanged.
serializers.py
- AnswerResponseSerializer: new mascotMood field.
views.py
- QuestionAnswerView: mascotMood = 'happy' for a correct answer else
'encourage', on both the fresh response and the idempotent-replay
response.
tests.py
- QuestionAnswerMascotMoodTests: correct -> happy, wrong -> encourage,
and the mood is preserved across an idempotent replay.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>