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>
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>
نقشهی فیزیکیِ کتابها (افستِ صفحه، بازهی هر فصل، درسهای داخلِ فصل از
فهرستِ خودِ کتاب) در rag/services/textbooks.py ثبت شد تا عنوانِ فصل از
کتاب بیاید نه از حدسِ مدل — رشتهی فصل باید دقیقاً بینِ ingest و retrieval
یکی باشد وگرنه نتیجه بیسروصدا خالی برمیگردد.
rag_ingest_book کتاب را فصلبهفصل ingest میکند و قابلِ ازسرگیری است:
rag.IngestedPage نشانهی «این صفحه پردازش شد» را نگه میدارد حتی وقتی
صفحه chunkی نساخته، وگرنه هر بار اجرای دوباره بابتِ صفحههای بیمسئله
دوباره پول میگرفت. --plan هزینه را رایگان برآورد میکند.
rag_import_collection نقاطِ موجودِ Qdrant را در LessonChunk میریزد —
فقط میخواند، رایگان و idempotent، برای پایههایی که تیمِ دیگر ingest
کرده بود.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>