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>
This commit is contained in:
2026-09-17 02:11:15 +03:30
parent 92dc2242f8
commit b26aca0f63
17 changed files with 18319 additions and 41 deletions

View File

@@ -46,6 +46,7 @@ def get_qdrant_client():
url=settings.QDRANT_URL,
api_key=settings.QDRANT_API_KEY,
check_compatibility=False,
timeout=settings.QDRANT_TIMEOUT_SECONDS,
)

View File

@@ -76,6 +76,14 @@ def _payload(chunk: LessonChunk) -> dict:
'chapter_number': chunk.chapter,
'chunk_order': chunk.order,
}
# نتیجه‌ی بازبینیِ راه‌حل. عمداً فقط همین یک کلیدِ کوتاه می‌رود و نه
# `checked_answer`/`check_note`: آن دو در دیتابیس در دسترس‌اند و اینجا فقط
# حجم اضافه می‌کردند. نامش هم عمداً با هیچ‌کدام از کلیدهایی که
# `retrieval._payload_text`/`_payload_title` می‌خوانند یکی نیست، پس یک
# نویسه هم به متنی که به مدل می‌رسد اضافه نمی‌کند — همان اشتباهی که یک بار
# با `summary` رخ داد و context را خراب کرد.
if chunk.solution_verdict:
payload['solution_verdict'] = chunk.solution_verdict
if chunk.lesson_id is not None:
payload['lesson_id'] = chunk.lesson_id
if chunk.source_page is not None:

View File

@@ -103,6 +103,16 @@ def extract_problem_types(image, grade: str, chapter_name: str) -> dict:
"""
یک عکسِ صفحه‌ی کتاب → ساختارِ JSONِ انواعِ مسئله (فارسی)، با مدلِ vision.
خروجی همون شکلی‌ست که uploadِ ریاضیِ تیمِ دیگه هم تولید می‌کرد.
⚠️ قاعده‌ی «self-contained» در پرامپت تازه اضافه شده و کتاب‌هایی که تا امروز
وارد شده‌اند بدونِ آن استخراج شده‌اند. اثرش با عدد: از ۲٬۱۱۹ مسئله‌ی موجود،
۸۸۱ تا (۴۲٪) به «شکلِ زیر» یا «جدولِ روبه‌رو» ارجاع می‌دهند و بدونِ آن تصویر
قابلِ حل نیستند — نه برای دانش‌آموز، نه برای دستیار، نه برای ساختِ سؤال.
نکته این است که مدل آن شکل را **دیده بود**؛ محدودیتِ vision نبود، فقط از او
خواسته نشده بود آنچه می‌بیند را بنویسد. برای همین با ingestِ دوباره‌ی همان
صفحه‌ها قابلِ جبران است، ولی چون هزینه دارد فعلاً انجام نشده. هر کتابِ
تازه‌ای که از این پس وارد شود این مشکل را ندارد.
"""
prompt = f"""
You are {teacher_persona(grade)}.
@@ -121,6 +131,15 @@ Rules:
- exampleFromImage must be a real problem from the image (not invented).
- Keep the mathematical notation of the textbook; do not simplify the concept
to a lower grade level than "{grade}".
- SELF-CONTAINED PROBLEMS. You can see the figures, tables and diagrams on this
page, but the reader of your output cannot. Whenever a problem refers to one
("the shape below", "the table opposite", "the given diagram"), write what it
shows INTO the problem text itself: the measurements, the vertex labels, the
angles, the table's values, what is shaded. Someone with only your text must
be able to solve it. If a figure genuinely cannot be put into words (a freehand
drawing task, a colouring exercise), skip that problem instead of writing a
reference to something the reader will never see.
- The same applies to the solution: no step may depend on looking at the page.
- If the page has no math problem at all (cover, foreword, table of contents,
a page of pure text), return an empty "problemTypes" list instead of inventing one.
- Output ONLY the JSON object. No markdown, no backticks.

View File

@@ -54,6 +54,25 @@ def delete_points(collection: str, point_ids: list[str]) -> int:
return len(point_ids)
def set_payload_fields(collection: str, point_ids: list[str], fields: dict) -> int:
"""
فقط چند کلیدِ payload را روی نقاطِ موجود می‌نویسد، بدونِ دست زدن به بردار.
برای همگام‌سازیِ برچسب‌ها همین کافی است و از upsertِ کامل خیلی ارزان‌تر:
نه بردار دوباره فرستاده می‌شود نه embedding دوباره خریده می‌شود.
"""
if not point_ids or not fields:
return 0
client = clients.get_qdrant_client()
client.set_payload(
collection_name=collection,
payload=fields,
points=[str(i) for i in point_ids],
wait=True,
)
return len(point_ids)
def search(collection: str, query_vector: list[float], *, lesson_filter_value=None,
lesson_filter_key: str | None = None, limit: int = 4) -> list[dict]:
"""