Two benchmark rounds (14 models on 30 problems, then gpt-4o-mini vs gpt-4.1-mini
on 102 problems under three prompt versions, hand-graded): gpt-4.1-mini scored
95-96/102 against 85-90 for gpt-4o-mini, at 0.12 vs 0.04 cents per answer.
The default now lives in settings so the deploy needs no .env edit.
The context note "in the book / is a decimal point, not a fraction" was written
for the upper-grade books but went to every grade, so gpt-4.1-mini computed
1/7 + 1/8 as 3.5. For primary grades the note now says / can be either, and a
reading rule sits next to the question itself (the same rule at the end of
the system prompt had no effect). Slash problems went from 11 to 14 of 17;
three are still wrong.
generate_answer now reports the provider's exact per-call cost, reasoning
tokens and whether the raw output broke the plain-text rule. The benchmark
fixes primary grades being sent with no book context, caches retrieval so
runs compare on identical context, and retries Qdrant timeouts.
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>
docs/security-before-production.md records, item by item, what is exposed
on purpose while this is an internship demo: the committed token-signing key
still in use on the server, seven fixed-code demo logins, demo users in the
production database, a public /api/health that reports internal state, a
public API schema, a public leaderboard with children's names, /admin on the
public domain, the server answering on port 80 around ArvanCloud, per-worker
rate limits without Redis, guest login by deviceId alone, unhashed refresh
tokens and OTP codes, AI cost exposure through throwaway guests, hearts not
enforced, SQLite without off-box backups, and shared root access to the box.
Each has its risk, why it was left, the fix and how to verify it; the
exposure checks were run against the live server on 2026-09-25.
CLAUDE.md, README and docs/operations.md point to it so no agent mistakes
this deployment for production-ready.
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>
Production is ArvanCloud -> nginx -> gunicorn, but NUM_PROXIES was 1, so DRF
took ArvanCloud's edge IP as the client: every guest and every OTP request
behind one edge shared a single throttle bucket (20 guest sessions an hour for
everyone). Default is now 2, and /api/health reports clientIp so the proxy
count can be checked from outside.
PATCH /api/notifications/read-all replaces the frontend's one-PATCH-per-item
loop, which spent the shared "read" budget 75 requests at a time.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ZARINPAL_REDIRECT_URL was empty on the server, so after a real (non-sandbox)
payment the callback answered with {"orderId","status"} on the API domain and
the student was stranded there. It now defaults to FRONTEND_URL
(https://math.tadrisino2.ir/) with ?orderId=&status=; reopening a finished
order's pay link goes back to the app the same way. "-" keeps the old JSON.
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>
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>
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>
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>
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>
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>
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>
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>
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>
فهرستِ ثابتِ 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>
chunkها با تطبیقِ دقیقِ رشته به درس وصل میشدند (problem_type == lesson.title).
عنوانِ درس از یک استخراجِ قدیمی آمده بود و هر بازخوانیِ کتاب عنوانِ تازه میسازد،
پس بعد از بازخوانیِ امروز فقط ۵ تا ۸ درصدِ عنوانها میخوردند — یعنی ۹۵٪ درسها
بیهیچ خطایی هیچ نامزدی پیدا نمیکردند و ساختِ سوال صفر برمیگرداند.
لنگرِ پایدار صفحه است: هر Node دقیقاً یک بخشِ فهرستِ کتاب با بازهی صفحهی مشخص
است (تطبیق ۱۰۰٪: ۳۴/۳۴، ۲۸/۲۸، ۲۳/۲۳) و هر chunk هم source_page دارد. وقتی
عنوان نخورد، از روی بازهی صفحه سراغ chunk میرویم — بدونِ هیچ فراخوانیِ مدلی.
نتیجه: هشتم ۰→۲۰۱، نهم ۰→۴۹، دهم ۰→۱۳۴.
رفتارِ قدیم با --strict-title حفظ شد، و یک محافظ گذاشته شد که یک chunk به دو
درس داده نشود.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
محافظِ «وابسته به شکل» یک regexِ تخت بود (شکل|جدول|روبهرو|مقابل|زیر را) و
مسئلهها را بدونِ هیچ فراخوانی کنار میگذاشت. فاجعهبار گشاد بود:
«زاویههای روبهرو در لوزی برابرند؟» ← «روبهرو» یعنی مقابل، نه شکل
«کسرهای زیر را گویا کنید: ۲/√۳» ← داده همانجاست
«ظرفی به شکل مخروطی با شعاع ۴» ← «به شکلِ» یعنی شبیهِ
اندازهگیری روی نمونهی ۵۰تایی از کتابهای بازخوانیشده: ۸۴٪ از ردشدهها واقعاً
قابلِ قضاوت بودند — یعنی ~۷۸۴ مسئلهی سالم بیصدا از چرخهی ساختِ سوال بیرون
مانده بود، بدونِ هیچ خطایی در هیچ لاگی.
قاعدهی تازه دو شرط دارد و هر دو باید برقرار باشند: ارجاعِ صریح به چیزی بیرونِ
متن، و نیامدنِ هیچ توصیفی بعد از آن. شرطِ دوم همان چیزی را میگیرد که قاعدهی
self-contained تولید میکند: «با توجه به شکل مقابل که شامل {a,1} است».
اثرش روی دادهٔ واقعی: unjudgeable از ۱٬۴۴۱ به ۵۰۸ و agrees از ۷۹۱ به ۲٬۴۶۶.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
مدلِ 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>
تا حالا اگر کسی روی سرور فایلی را دستی عوض کرده بود، `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>
bash اسکریپت را یکجا نمیخواند؛ حین اجرا از روی دیسک ادامه میدهد. پس وقتی
`git pull` همین فایل را عوض میکند، ادامهی اجرا مخلوطی از نسخهی قدیم و جدید
است. یک بار همین اتفاق افتاد و گران تمام شد: نسخهی قدیمی که در حال اجرا بود
سرویسِ تیمِ دیگری را ریاستارت کرد، در حالی که نسخهی اصلاحشده همان لحظه روی
دیسک نشسته بود.
حالا امضای فایل قبل و بعدِ pull مقایسه میشود و اگر عوض شده باشد، نسخهی تازه
از اول اجرا میشود (با SKIP_PULL=1 و یک نگهبان در برابرِ حلقهی بیپایان).
و یک حفرهی کوچکترِ همخانواده: اگر زمانِ بالا آمدنِ سرویس اصلاً خوانده نشود،
دیگر موفقیت فرض نمیشود. «نمیدانم» همان چیزی بود که دو روز پنهانش کرد.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
روی این سرور یک `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>
`{# ... #}` در جنگو فقط تکخطی است. کامنتی که دیشب اضافه کردم سه خط بود، پس
جنگو کامنت حسابش نکرد و کلِ متنش بالای صفحهی مرورِ محتوا چاپ شد.
کلاً برداشته شد. برای توضیحِ چندخطی در قالب باید {% comment %} استفاده شود،
ولی اینجا اصلاً توضیحی لازم نبود.
همهی قالبهای پروژه برای همین الگو بررسی شدند؛ جای دیگری نبود.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
روشی که این جلسه استفاده شد حالا نوشته شده تا هر کسی (آدم یا ایجنت) همان را
تکرار کند: رمز در یک فایلِ chmod 600 در پوشهی موقت، ورود با sshpass -f و
sudo -S از ورودی، و shred -u بلافاصله بعدِ کار.
دلیلِ هر قدم هم نوشته شد، چون بدونِ دلیل قدمها تشریفات بهنظر میرسند و کنار
گذاشته میشوند. مهمترینش: رمز هیچوقت در خطِ فرمان نیاید — روی این سرور چند
تیمِ دیگر حساب دارند و ps دستورِ هر پروسه را به همه نشان میدهد.
و صریح نوشته شد که رمز فقط از خودِ کاربر در همان جلسه میآید؛ جایی ذخیره
نمیشود و نباید بشود. اگر در گفتگو نبود، باید پرسید نه گشت.
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>
سرور دو روز کدِ قدیمی را سرو کرد و هیچکس نفهمید. علتش این بود که
`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>
CLAUDE.md تنها جایی است که هر جلسهی تازه اول میخواندش، و چیزهایی که این دوره
یاد گرفتیم جایی ثبت نشده بود. چهار بخش اضافه شد و «وضعیتِ فعلی» به بالا آمد:
• محتوا حالا در content/seed/ کامیت میشود و deploy خودش بالا میآوردش — بدونِ
Qdrant، بدونِ اعتبارِ AI، بدونِ SSH. و اینکه سوال با مسیرِ عنوان به درس وصل
میشود نه با id، چون idها بینِ دو دیتابیس یکی نیستند.
• لایهی راستیآزمایی: کتاب از روی تصویر خوانده میشود، پس خطا همانجا وارد
میشود. برچسب سهحالته است و سوال فقط از agreesها ساخته میشود.
• دو درسی که گران تمام شدند: نتیجهی دوحالته نرخ را سه برابر نشان میدهد، و
«۳/۱۰۴» در این کتابها عددِ اعشاری است نه کسر.
• شکافِ شکلها (۴۲٪ محتوا) و اینکه اصلاحش در پرامپت هست ولی اجرا نشده.
• تفاوتِ ۴۲۹ سقفِ حساب با ۴۰۳ اتمامِ اعتبار — و اینکه متنِ دومی فاصله دارد نه
زیرخط، که یک بار ۷۷۰ فراخوانیِ بیفایده به بار آورد.
«وضعیت تا ۲۰۲۶-۰۹-۱۷» هم اضافه شد: بکاند آماده و تستشده است، و دو مانعِ
باقیمانده دستِ ما نیست — سرور روی کدِ قدیمی، و فرانتی که به هیچ APIی وصل نیست.
هر دو قبل از اینکه کسی دنبالِ باگ بگردد باید چک شوند.
هیچ فایلِ تازهای اضافه نشد؛ همان فایلِ موجود بهروز شد.
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>
بررسیِ collectionها بعد از ingestِ پایه نهم نشان داد Qdrant ۲۸۷ نقطه دارد ولی
دیتابیس ۲۷۹ ردیف. هشت نقطهی اضافه، همه از صفحهی ۱۲۵ — همان صفحهای که دو بار
تایماوت داد و بار سوم گرفت. سه مسئله سه نسخه داشتند و دو نقطه هم بیصاحب بود.
علت: `upsert` با `wait=True` ممکن است نوشته باشد و فقط جوابش تایماوت شده
باشد. مسیرِ جبران فقط ردیفهای DB را پس میگرفت، نه نقطهها؛ و چون هر اجرای
دوباره `qdrant_point_id` تازه میسازد، هر retry یک نسخهی تکراری اضافه میکرد.
اثرش در بازیابی دیده میشود نه در لاگ: top-k با سه نسخه از یک مسئله پر میشود
و بقیهی متنِ مرتبط بیرون میماند — یعنی دستیار بافتِ کمتری میبیند و هیچکس
هم متوجه نمیشود.
حالا در صورتِ شکست، نقطهها هم پاک میشوند. اگر خودِ پاکسازی هم شکست بخورد
لاگ میشود ولی خطای اصلی پوشانده نمیشود، چون صفحه باید ناتمام بماند تا اجرای
بعدی دوباره سراغش برود.
دادهی موجود هم تمیز شد: ۸ نقطهی بیصاحب پاک شدند و حالا ۲۷۹ = ۲۷۹.
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>