Commit Graph

7 Commits

Author SHA1 Message Date
58c8e6d014 question packs instead of monthly/yearly plans; hearts are real
- packs (20 / 60 / 150 questions for 45,000 / 119,000 / 259,000 toman) never expire and
  give 7 / 30 / 60 days of unlimited hearts; old subscription plans stay for existing
  subscribers but are no longer on sale
- credit ledger (subscription.QuestionCredit): +N per paid pack (once per order), -1 per
  tutor question that reaches the API; the free daily questions are spent first; questions
  stopped by the server filter, cached answers and failed answers cost nothing
- unlimited hearts from packs live in HeartsPass, so buying a pack never rewrites a
  running subscription
- Message.charge records how each question was paid; GET /api/ai/quota and the ask
  responses report free-left-today and credits
- HEARTS_ENFORCED now defaults to on: zero hearts stops answering until refill or a pack

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:01:24 +03:30
52d9694583 keep ingested chunks findable and structured
Two bugs made self-ingested content useless, both silently:

- _payload wrote `chapter_number` (int) while math retrieval filters on
  `chapter` (the full "فصل ۲: کسر" string), so anything we ingest could
  never match a search — no error, just permanently invisible content.
- ingest_pdf flattened the vision extraction into one text blob and threw
  away type/keywords/generalSolution/problem/solution. LessonChunk is the
  source of truth, so rebuilding meant paying for vision again — and the
  problem/solution pairs are the input for building lesson questions.

LessonChunk now stores those fields and _payload emits them under the same
keys the other team's collections use, so ours and theirs stay one shape.

Also fixes a regression from the whitenoise commit: the Manifest storage
variant raises when staticfiles.json is absent, which broke all 23 admin
rendering tests (Django forces DEBUG=False in tests, so USE_FINDERS is off)
and would have taken the admin panel down entirely the day DEBUG=0 ships
without a collectstatic run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 16:24:18 +03:30
ac93e17305 new app — cleaned-up RAG pipeline (Qdrant retrieval + GPT), ported from Rag_backend
Self-contained app; wired into settings/urls in the next commit. Data
stays in the parallel team's live Qdrant — nothing is copied into our DB.

apps.py
  - RagConfig.
models.py
  - LessonChunk: DB-of-record for one summarized lesson chunk
    (original_text + summary + embedding_vector) mirrored as a single
    Qdrant point; loose lesson_id/grade_id ints, no cross-app FK.
    LessonChunkImage for future multimodal lessons.
migrations/0001_initial.py
  - initial tables for the two models.
services/clients.py
  - lazy, lru_cache'd OpenAI + Qdrant clients (Rag_backend builds them at
    import time and crashes without keys). RagNotConfigured raised when a
    key/lib is missing; Qdrant client uses check_compatibility=False for
    the 1.19-client / 1.17-server minor version gap.
services/config.py
  - rag_is_configured(), collection-name template, grade-title resolution
    (RAG_GRADE_TITLE_OVERRIDES -> content.Grade.title), retrieval top-k.
services/known_collections.py
  - exact live Qdrant catalog: 6 collection names, math chapter-number ->
    exact "فصل N: ..." string tables per (subject, grade), and the
    علوم/اجتماعی lesson-number -> title maps. filter_key_for_subject()
    picks `chapter` for math, `lesson_number` otherwise;
    resolve_math_chapter() maps a plain chapter number (Persian or Latin
    digits) to the stored string.
services/embedding.py
  - embed_text() pinned to OPENAI_EMBEDDING_MODEL (text-embedding-3-small,
    1536d) so vectors match the other team's existing collections.
services/chunking.py
  - split_text_into_chunks(), summarize_chunk() (gpt-4o-mini, JSON out);
    plain exceptions instead of ninja HttpError.
services/qdrant_store.py
  - ensure_collection(), upsert_points(), search(): the lesson filter
    matches the value both as str and native, since Qdrant stores
    lesson_number as int and chapter as string.
services/retrieval.py
  - resolve_target() -> (collection, filter_key, filter_value), reused by
    the cache key and debug block.
  - retrieve_context() -> (chunks, meta): embed -> Qdrant search scoped to
    one lesson/chapter -> top-k. Math payloads (no `summary`) fall back to
    generalSolution/problem/solution; a 404 collection returns empty.
services/generation.py
  - generate_answer() and stream_answer() (token iterator for SSE);
    context-grounded Persian prompt that explains from retrieved text
    rather than refusing.
services/pdf.py
  - textbook PDF -> page images -> gpt-4o vision problem-type extraction
    (adapted from Rag_backend mathapp); returns dicts, no on-disk JSON.
    pymupdf/pdf2image optional — missing libs raise RagNotConfigured.
services/ingest.py
  - ingest_text() / ingest_pdf(): chunk -> summarize -> embed -> persist
    LessonChunk + upsert Qdrant, in one transaction.
services/answer_cache.py
  - short-TTL cache of the final answer keyed by
    sha1(collection | filter_value | normalized-question).
services/tutor.py
  - thin answer_question() (retrieve -> generate) for direct callers.
serializers.py, urls.py, views.py
  - POST /api/rag/ingest/text, POST /api/rag/ingest/pdf (HasInternalServiceKey,
    throttle scope rag_ingest), GET /api/rag/chunks review list.
    RagProviderUnavailable -> clean 503.
admin.py
  - LessonChunk admin with an inline for its images.
management/commands/rag_catalog.py
  - read-only live Qdrant listing (--values, --json); never writes.
tests.py
  - mocked-client tests: ask-with-retrieval, ingest, SSE stream, answer
    cache, and rag_is_configured() gating.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 02:29:54 +03:30