Subjects per grade (math, calculus, geometry, stats, discrete), 5 new textbooks, 202 new islands, long islands split, hand-written figures and lesson texts for the new books

- content.Subject + Unit.subject, /api/home?subject=, tutor grounded in the chosen book
- 5 new books ingested; ~1150 hand-written questions, islands named by hand
- node_split: islands over 7 lessons split into parts of <=6 (lesson ids and progress kept)
- Lesson.figures hand-authored and visually checked for 653 lessons of the new books;
  gen_lesson_figures got --subject and survives network errors
- seed files re-exported

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 05:35:18 +03:30
parent 3d51b3b83a
commit 8cb1ecb85e
32 changed files with 81218 additions and 4486 deletions

View File

@@ -451,6 +451,24 @@ preferred setup, because there is then no key to leak. **Never let the server ru
`django-insecure-…` fallback from `config/settings.py`: that string is committed here, so `django-insecure-…` fallback from `config/settings.py`: that string is committed here, so
anyone who can read this repo can forge a token for any user.** anyone who can read this repo can forge a token for any user.**
## کتاب‌ها (Subject)، جزیره‌های بیشتر و شکلِ درس‌های کتاب‌های تازه — ۲۰۲۶-۱۰-۱۲
- **`Subject`** (`content.0014..0018`): ریاضی، حسابان، هندسه، آمار و احتمال، گسسته؛ `Unit.subject` اجباری است.
`/api/home?subject=<key>` (کتابِ بی‌ربط ۴۰۰)؛ `subjects` در پاسخ فقط کتاب‌های همان پایه است (دهم: ریاضی و هندسه؛
یازدهم: حسابان، هندسه، آمار؛ دوازدهم: حسابان، هندسه، گسسته) و نوارِ فرانت (`SubjectBar`) فقط وقتی بیش از یکی باشد می‌آید.
فصلِ رایگان = اولین فصلِ **هر کتاب**. فایل‌های seed حالا `subject` دارند و بی‌آن رد می‌شوند.
- **پنج کتابِ تازه** (هندسه ۱۰/۱۱/۱۲، آمار ۱۱، گسسته ۱۲) روی Qdrant خوانده شده‌اند. ۲۰۲ جزیره‌ی تازه با نامِ دستی،
۱۱۳۰ + ۲۰ سوالِ نوشته‌ی خودم (حلِ مستقل، نه از کلیدِ کتاب)، و مثال/قدم برای همه‌ی درس‌های هندسه (و برای درس‌های آمار/گسسته‌ای که شکل دارند).
- **شکل‌ها را دستی نوشتم، نه با API.** `gen_lesson_figures` روی هندسه حدود نیمی غلط داد و کنار گذاشته شد (بازبینیِ چشمی). حالا
`Lesson.figures` برای ۵۰۳ درسِ هندسه (از ۵۸۹)، ۸۲ درسِ آمار و ۶۸ درسِ گسسته با مشخصه‌ی `rag.services.diagram` نوشته و با
`rag.services.diagram.validate` اعتبارسنجی شده؛ هر شکل با Chrome رندر و با چشم دیده شد (دو دور روی نمونه‌ی تصادفی).
ندارند: درس‌های جبریِ ماتریس‌ها، اثبات‌های جبری، چند درس‌ِ آمار/گسسته‌ی مفهومی.
جدول‌های درستی با `kind: table` کشیده شده‌اند؛ ترتیبِ ستون‌ها در پیش‌نمایشِ من راست‌به‌چپ بود (به نمایشِ زنده نگاه کن).
- **تقسیمِ جزیره‌ها** (`content/node_split.py`، `0018`): جزیره‌ی بیش از ۷ درس به قسمت‌های ≤۶ درسی شکسته می‌شود؛ شناسه‌ی درس
و پیشرفتِ کاربر حفظ می‌شود. نامِ قسمت‌ها در کتاب‌های قدیمی «عنوان (۲)…» است و دستی نام‌گذاری نشده.
- `gen_lesson_figures` حالا `--subject` دارد و خطای شبکه یک درس را رد می‌کند (کلِ اجرا نمی‌افتد). نکته: `--dry-run` هم API را صدا می‌زند.
- **سوالِ جزیره‌ها:** چهار جزیره‌ی تقسیم‌شده‌ی پایه‌های ۳ تا ۷ خالی بودند و سوال گرفتند؛ آزمونِ `test_every_topic_has_at_least_one_question` نگهشان می‌دارد.
## ظرفیت زیرِ بار — روی سرور از ۲۰۲۶-۱۰-۱۱ (بک‌اند `98bf05f`، فرانت `16cf5c9`) ## ظرفیت زیرِ بار — روی سرور از ۲۰۲۶-۱۰-۱۱ (بک‌اند `98bf05f`، فرانت `16cf5c9`)
گزارشِ سینیور: `~/Desktop/گزارشات Tadrisino/گزارش-۳۳/ظرفیتِ-اپ-زیرِ-بار.html` (و PDF). بکاپِ تغییرهای یک‌باره‌ی گزارشِ سینیور: `~/Desktop/گزارشات Tadrisino/گزارش-۳۳/ظرفیتِ-اپ-زیرِ-بار.html` (و PDF). بکاپِ تغییرهای یک‌باره‌ی

View File

@@ -84,3 +84,48 @@ class AskWithoutLessonUsesTheStudentsBookTests(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(retrieve_mock.call_args.kwargs['subject'], '') self.assertEqual(retrieve_mock.call_args.kwargs['subject'], '')
class AskFollowsTheBookTheStudentPickedTests(TestCase):
"""۲۰۲۶-۱۰-۱۱: یک پایه چند کتاب دارد؛ دستیار از کتابی جواب می‌دهد که دانش‌آموز انتخاب کرده یا درس به آن تعلق دارد."""
def setUp(self):
cache.clear()
self.grade = Grade.objects.create(title='پایه دوازدهم', icon='', color='')
self.user = User.objects.create(phone='09120000021', grade_id=self.grade.id)
def _ask(self, **extra):
retrieve = mock.patch('rag.services.retrieval.retrieve_context',
return_value=([], {'gradeTitle': 'دوازدهم'}))
generate = mock.patch('rag.services.generation.generate_answer', return_value=('جواب', [], {}))
configured = mock.patch('rag.services.config.rag_is_configured', return_value=True)
with retrieve as retrieve_mock, generate, configured:
response = self.client.post(
'/api/ai/ask', {'userQuestion': 'ماتریس چیست؟', **extra}, content_type='application/json',
HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}',
)
self.assertEqual(response.status_code, 200)
return retrieve_mock.call_args.kwargs
def test_explicit_subject_without_lesson_gets_the_grade_title(self):
kwargs = self._ask(subject='هندسه')
self.assertEqual((kwargs['subject'], kwargs['grade_title']), ('هندسه', 'دوازدهم'))
def test_lesson_beats_the_subject_stored_on_the_conversation(self):
from content.models import Lesson, Node, Subject, Unit
geo = Unit.objects.create(grade=self.grade, subject=Subject.objects.get(key='geometry'),
title='فصل ۱: ماتریس و کاربردها', order=1)
calc = Unit.objects.create(grade=self.grade, subject=Subject.objects.get(key='calculus'),
title='فصل ۴: مشتق', order=4)
geo_lesson = Lesson.objects.create(node=Node.objects.create(unit=geo, title='n'), title='l')
calc_lesson = Lesson.objects.create(node=Node.objects.create(unit=calc, title='n'), title='l')
first = self.client.post(
'/api/ai/ask', {'userQuestion': 'سلام', 'lessonId': str(geo_lesson.id)},
content_type='application/json', HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}',
)
self.assertEqual(first.status_code, 200)
conversation_id = first.json()['conversationId']
kwargs = self._ask(conversationId=conversation_id, lessonId=str(calc_lesson.id))
self.assertEqual((kwargs['subject'], kwargs['lesson_number']), ('حسابان', 4))

View File

@@ -406,10 +406,14 @@ class AskAIView(APIView):
# فقط conversationId می‌فرستد و دیگر lessonId/gradeId نمی‌آید. # فقط conversationId می‌فرستد و دیگر lessonId/gradeId نمی‌آید.
grade_id = v.get('gradeId') or conversation.grade_id grade_id = v.get('gradeId') or conversation.grade_id
lesson_id = v.get('lessonId') or conversation.lesson_id lesson_id = v.get('lessonId') or conversation.lesson_id
subject = (v.get('subject') or conversation.subject or '').strip() # ⚠️ درسِ تازه‌ی همین درخواست از کتابِ ذخیره‌شده‌ی مکالمه جلو می‌افتد: از وقتی یک پایه چند
# کتاب دارد، مکالمه‌ای که با درسِ هندسه شروع شده می‌تواند با درسِ حسابان ادامه پیدا کند و
# `conversation.subject`/`lesson_number`ِ قدیمی جواب را از کتابِ غلط می‌گرفت.
explicit_lesson = bool(v.get('lessonId'))
subject = (v.get('subject') or ('' if explicit_lesson else conversation.subject) or '').strip()
grade_title = (v.get('grade') or '').strip() grade_title = (v.get('grade') or '').strip()
lesson_number = v.get('lessonNumber') lesson_number = v.get('lessonNumber')
if lesson_number is None: if lesson_number is None and not explicit_lesson:
lesson_number = conversation.lesson_number lesson_number = conversation.lesson_number
# اگر اپ فقط lessonId (یا فقط conversationId) داده — حالتِ طبیعی، چون # اگر اپ فقط lessonId (یا فقط conversationId) داده — حالتِ طبیعی، چون
@@ -436,6 +440,14 @@ class AskAIView(APIView):
conversation.lesson_number = lesson_number conversation.lesson_number = lesson_number
conversation.save(update_fields=['subject', 'lesson_number']) conversation.save(update_fields=['subject', 'lesson_number'])
# کتاب صریح آمده (فرانت کتابِ انتخابیِ دانش‌آموز را می‌فرستد) ولی درس و عنوانِ پایه نه:
# عنوانِ پایه از پایه‌ی خودِ کاربر درمی‌آید، وگرنه resolve_target هیچ collectionی نمی‌سازد
# و جواب بی‌صدا عمومی می‌شد.
if subject and not grade_title:
from rag.services.config import resolve_grade_title
grade_title = resolve_grade_title(grade_id or getattr(user, 'grade_id', None)) or ''
# نه درسی آمده نه subject — حالتِ عادیِ صفحه‌ی «از پی بپرس» که از نوارِ # نه درسی آمده نه subject — حالتِ عادیِ صفحه‌ی «از پی بپرس» که از نوارِ
# پایین باز می‌شود. پایه‌ی ذخیره‌شده‌ی خودِ کاربر کتاب را معلوم می‌کند؛ # پایین باز می‌شود. پایه‌ی ذخیره‌شده‌ی خودِ کاربر کتاب را معلوم می‌کند؛
# بدونِ این، جواب‌های آن صفحه هیچ‌وقت به کتاب مستند نمی‌شدند. # بدونِ این، جواب‌های آن صفحه هیچ‌وقت به کتاب مستند نمی‌شدند.

View File

@@ -15,8 +15,8 @@ from .curriculum_io import parse as parse_curriculum
from .question_io import ImportError_, export_questions, import_questions, parse from .question_io import ImportError_, export_questions, import_questions, parse
from .models import ( from .models import (
Choice, Grade, Lesson, Node, NodeProgress, Question, QuestionReport, Unit, UserAnswer, Choice, Grade, Lesson, Node, NodeProgress, Question, QuestionReport, Subject, Unit,
UserLessonCompletion, UserAnswer, UserLessonCompletion,
) )
@@ -84,10 +84,18 @@ class GradeAdmin(admin.ModelAdmin):
}) })
@admin.register(Subject)
class SubjectAdmin(admin.ModelAdmin):
"""کتاب‌های ریاضی (ریاضی، حسابان، هندسه، …) که نوارِ جابه‌جاییِ اپ از آن‌ها ساخته می‌شود."""
list_display = ('order', 'title', 'key', 'icon', 'color')
ordering = ('order',)
@admin.register(Unit) @admin.register(Unit)
class UnitAdmin(admin.ModelAdmin): class UnitAdmin(admin.ModelAdmin):
list_display = ('id', 'title', 'grade', 'order') list_display = ('id', 'title', 'grade', 'subject', 'order')
list_filter = ('grade',) list_filter = ('grade', 'subject')
# فیلترِ «فصل» در صفحه‌ی سوال‌ها هم از همین ترتیب می‌آید: پایه‌به‌پایه، نه # فیلترِ «فصل» در صفحه‌ی سوال‌ها هم از همین ترتیب می‌آید: پایه‌به‌پایه، نه
# همه‌ی «فصل ۱»ها پشتِ هم. # همه‌ی «فصل ۱»ها پشتِ هم.
ordering = ('grade__number', 'grade_id', 'order') ordering = ('grade__number', 'grade_id', 'order')

View File

@@ -102,6 +102,8 @@ def parse(raw: bytes) -> list[dict]:
}) })
clean_units.append({ clean_units.append({
# کتاب صریح است: فایلِ بی‌کتاب رد می‌شود تا فصلِ حسابان بی‌صدا «ریاضی» نشود.
'subject': _text(unit.get('subject'), 'subject', uwhere),
'title': _text(unit.get('title'), 'title', uwhere), 'title': _text(unit.get('title'), 'title', uwhere),
'blurb': str(unit.get('blurb') or '')[:120], 'blurb': str(unit.get('blurb') or '')[:120],
'order': int(unit.get('order') or ui + 1), 'order': int(unit.get('order') or ui + 1),
@@ -117,6 +119,16 @@ def parse(raw: bytes) -> list[dict]:
return cleaned return cleaned
def _subject_or_error(title: str):
"""کتابِ فایل باید از قبل در جدولِ Subject باشد (ادمین ← کتاب‌ها)؛ نامِ ناشناخته خطا است، نه ساختنِ بی‌صدا."""
from .models import Subject
subject = Subject.objects.filter(title=title).first()
if subject is None:
known = '، '.join(Subject.objects.values_list('title', flat=True))
raise CurriculumError(f'کتابِ «{title}» در جدولِ کتاب‌ها نیست. موجود: {known}')
return subject
@transaction.atomic @transaction.atomic
def import_curriculum(grades: list[dict]) -> dict: def import_curriculum(grades: list[dict]) -> dict:
""" """
@@ -135,9 +147,10 @@ def import_curriculum(grades: list[dict]) -> dict:
counts['grades'] += 1 if created else 0 counts['grades'] += 1 if created else 0
for unit_row in grade_row['units']: for unit_row in grade_row['units']:
subject = _subject_or_error(unit_row['subject'])
unit, created = Unit.objects.get_or_create( unit, created = Unit.objects.get_or_create(
grade=grade, title=unit_row['title'], grade=grade, title=unit_row['title'],
defaults={'order': unit_row['order']}, defaults={'order': unit_row['order'], 'subject': subject},
) )
counts['units'] += 1 if created else 0 counts['units'] += 1 if created else 0
# متنِ ادمین برنده است: فقط خانه‌ی خالی از فایل پر می‌شود # متنِ ادمین برنده است: فقط خانه‌ی خالی از فایل پر می‌شود
@@ -185,13 +198,14 @@ def import_curriculum(grades: list[dict]) -> dict:
def export_curriculum(grades_queryset) -> dict: def export_curriculum(grades_queryset) -> dict:
"""خروجی دقیقاً همان قالبی‌ست که import می‌خواند.""" """خروجی دقیقاً همان قالبی‌ست که import می‌خواند."""
out = [] out = []
for grade in grades_queryset.prefetch_related('units__nodes__lessons'): for grade in grades_queryset.prefetch_related('units__subject', 'units__nodes__lessons'):
out.append({ out.append({
'title': grade.title, 'title': grade.title,
'icon': grade.icon, 'icon': grade.icon,
'color': grade.color, 'color': grade.color,
'units': [ 'units': [
{ {
'subject': unit.subject.title,
'title': unit.title, 'title': unit.title,
'order': unit.order, 'order': unit.order,
**({'blurb': unit.blurb} if unit.blurb else {}), **({'blurb': unit.blurb} if unit.blurb else {}),

View File

@@ -124,7 +124,7 @@ class Command(BaseCommand):
continue continue
finder._book = book finder._book = book
collection = build_collection_name(book.subject, book.grade) collection = build_collection_name(book.subject, book.grade)
lessons = (Lesson.objects.filter(node__unit__grade=grade) lessons = (Lesson.objects.filter(node__unit__grade=grade, node__unit__subject__title=book.subject)
.select_related('node__unit').order_by('node__unit__order', 'node__order', 'id')) .select_related('node__unit').order_by('node__unit__order', 'node__order', 'id'))
for lesson in lessons: for lesson in lessons:
if lesson.example.strip() or lesson.steps: if lesson.example.strip() or lesson.steps:

View File

@@ -24,6 +24,7 @@ import threading
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from pathlib import Path from pathlib import Path
import openai
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from content.models import Lesson from content.models import Lesson
@@ -109,6 +110,7 @@ class Command(BaseCommand):
def add_arguments(self, p): def add_arguments(self, p):
p.add_argument('--grade', type=int, help='بدونِ آن همه‌ی پایه‌ها') p.add_argument('--grade', type=int, help='بدونِ آن همه‌ی پایه‌ها')
p.add_argument('--subject', help='عنوانِ کتاب (مثلاً هندسه)؛ بدونِ آن همه‌ی کتاب‌ها')
p.add_argument('--limit', type=int, default=4) p.add_argument('--limit', type=int, default=4)
p.add_argument('--lesson', type=int, help='فقط همین شناسه') p.add_argument('--lesson', type=int, help='فقط همین شناسه')
p.add_argument('--redo', action='store_true', help='درس‌هایی که شکل دارند هم') p.add_argument('--redo', action='store_true', help='درس‌هایی که شکل دارند هم')
@@ -128,6 +130,8 @@ class Command(BaseCommand):
'node__unit__grade__number', 'node__unit__order', 'node__order', 'id') 'node__unit__grade__number', 'node__unit__order', 'node__order', 'id')
if o['grade']: if o['grade']:
qs = qs.filter(node__unit__grade__number=o['grade']) qs = qs.filter(node__unit__grade__number=o['grade'])
if o['subject']:
qs = qs.filter(node__unit__subject__title=o['subject'])
if o['lesson']: if o['lesson']:
qs = qs.filter(pk=o['lesson']) qs = qs.filter(pk=o['lesson'])
if not o['redo']: if not o['redo']:
@@ -179,7 +183,8 @@ class Command(BaseCommand):
return return
try: try:
items = _json_list(ask(PROMPT + spec, text)) items = _json_list(ask(PROMPT + spec, text))
except (ValueError, KeyError) as e: # خطای شبکه/درگاه یک درس را رد می‌کند، نه کلِ اجرا را؛ در ledger ثبت نمی‌شود تا دفعه‌ی بعد دوباره برود.
except (ValueError, KeyError, openai.OpenAIError) as e:
with lock: with lock:
stats['invalid'] += 1 stats['invalid'] += 1
self.stderr.write(f'درس {lesson.id}: خروجیِ نامعتبر ({e}); ذخیره نشد.') self.stderr.write(f'درس {lesson.id}: خروجیِ نامعتبر ({e}); ذخیره نشد.')
@@ -218,7 +223,7 @@ class Command(BaseCommand):
ok = {v.get('i'): v for v in verdicts if isinstance(v, dict)} ok = {v.get('i'): v for v in verdicts if isinstance(v, dict)}
kept = [f for i, f in enumerate(figures) if ok.get(i, {}).get('ok') is True] kept = [f for i, f in enumerate(figures) if ok.get(i, {}).get('ok') is True]
notes = [f"{i}: {v.get('why', '')}" for i, v in ok.items() if v.get('ok') is not True] notes = [f"{i}: {v.get('why', '')}" for i, v in ok.items() if v.get('ok') is not True]
except (ValueError, KeyError, AttributeError) as e: except (ValueError, KeyError, AttributeError, openai.OpenAIError) as e:
kept, notes = [], [f'وارسی ناموفق: {e}'] kept, notes = [], [f'وارسی ناموفق: {e}']
with lock: with lock:
stats['rejected'] += len(figures) - len(kept) stats['rejected'] += len(figures) - len(kept)

View File

@@ -298,7 +298,7 @@ class Command(BaseCommand):
"""[(lesson, chunk), ...] — فقط درس‌هایی که هنوز سوال کم دارند.""" """[(lesson, chunk), ...] — فقط درس‌هایی که هنوز سوال کم دارند."""
lessons = ( lessons = (
Lesson.objects Lesson.objects
.filter(node__unit__grade=grade) .filter(node__unit__grade=grade, node__unit__subject__title=self._book.subject)
.select_related('node__unit') .select_related('node__unit')
.order_by('node__unit__order', 'node__order', 'id') .order_by('node__unit__order', 'node__order', 'id')
) )

View File

@@ -21,7 +21,7 @@
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from django.db import transaction from django.db import transaction
from content.models import Grade, Lesson, Node, Unit from content.models import Grade, Lesson, Node, Subject, Unit
from rag.models import LessonChunk from rag.models import LessonChunk
from rag.services.config import build_collection_name from rag.services.config import build_collection_name
from rag.services.textbooks import TEXTBOOKS, get_textbook from rag.services.textbooks import TEXTBOOKS, get_textbook
@@ -179,11 +179,17 @@ class Command(BaseCommand):
title=grade_title, defaults={'icon': icon, 'color': color}, title=grade_title, defaults={'icon': icon, 'color': color},
) )
# کتاب باید در جدولِ Subject باشد (migrationِ 0015 پنج‌تایی را می‌سازد؛ کتابِ تازه از ادمین).
subject = Subject.objects.filter(title=book.subject).first()
if subject is None:
raise CommandError(
f'کتابِ «{book.subject}» در جدولِ Subject نیست؛ از ادمین (محتوا ← کتاب‌ها) بسازید.'
)
counts = {'units': 0, 'nodes': 0, 'lessons': 0} counts = {'units': 0, 'nodes': 0, 'lessons': 0}
for chapter, groups in plan: for chapter, groups in plan:
unit, _ = Unit.objects.update_or_create( unit, _ = Unit.objects.update_or_create(
grade=grade, title=chapter.title, grade=grade, title=chapter.title,
defaults={'order': chapter.number}, defaults={'order': chapter.number, 'subject': subject},
) )
counts['units'] += 1 counts['units'] += 1

View File

@@ -0,0 +1,36 @@
"""
جزیره‌های بلند را به جزیره‌های حداکثر ۶ درسی می‌شکند (منطق در `content/node_split.py`).
python manage.py split_long_nodes --grade 8 --dry-run
python manage.py split_long_nodes --grade 4 --grade 5
پایه‌های ۳ تا ۱۲ را migrationِ 0018 یک بار شکسته است؛ این دستور برای پایه‌ی تازه یا آستانه‌ی دیگر است و اجرای
دوباره‌اش بی‌اثر. بعد از اجرا فایل‌های seed را دوباره بسازید (`seed_content --export`)، وگرنه
`seed_content` جزیره‌های بلندِ قدیمی را کنارِ بخش‌های تازه دوباره می‌سازد.
"""
from django.core.management.base import BaseCommand
from django.db import transaction
from content.models import Lesson, Node, NodeProgress, Unit, UserNodeCompletion
from content.node_split import split_long_nodes
class Command(BaseCommand):
help = 'شکستنِ جزیره‌های بلند به جزیره‌های کوتاه‌تر، با حفظِ پیشرفتِ کاربران.'
def add_arguments(self, parser):
parser.add_argument('--grade', type=int, action='append', required=True,
help='شماره‌ی پایه؛ چند بار قابلِ تکرار.')
parser.add_argument('--max-lessons', type=int, default=7)
parser.add_argument('--part-cap', type=int, default=6)
parser.add_argument('--dry-run', action='store_true')
def handle(self, *args, **opts):
units = Unit.objects.filter(grade__number__in=opts['grade']).order_by('id')
with transaction.atomic():
counts = split_long_nodes(
Node=Node, Lesson=Lesson, UserNodeCompletion=UserNodeCompletion,
NodeProgress=NodeProgress, units=units,
max_lessons=opts['max_lessons'], part_cap=opts['part_cap'], dry_run=opts['dry_run'],
)
self.stdout.write(f"{'(dry-run) ' if opts['dry_run'] else ''}{counts}")

View File

@@ -0,0 +1,23 @@
from django.db import migrations, models
class Migration(migrations.Migration):
"""مرحله‌ی ۱ از ۴ (جدولِ کتاب‌ها). چهار مرحله‌ی جدا چون PostgreSQL اجازه نمی‌دهد در یک تراکنش
هم داده نوشت و هم همان جدول را ALTER کرد (pending trigger events)."""
dependencies = [('content', '0013_alter_userlessoncompletion_created_at_and_more')]
operations = [
migrations.CreateModel(
name='Subject',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.SlugField(max_length=30, unique=True)),
('title', models.CharField(max_length=50, unique=True)),
('icon', models.CharField(blank=True, max_length=100)),
('color', models.CharField(blank=True, max_length=10)),
('order', models.PositiveSmallIntegerField(default=0)),
],
options={'ordering': ['order', 'id']},
),
]

View File

@@ -0,0 +1,24 @@
from django.db import migrations
# رنگ‌ها جدا از رنگِ پایه‌ها (Grade.color) و از هم‌اند تا دکمه‌های نوارِ کتاب‌ها در نقشه
# به‌چشم یک مسیرِ جدا بیایند. آیکون‌ها نامِ lucide‌اند (همان فهرستی که نقشه می‌شناسد).
SUBJECTS = [
('math', 'ریاضی', 'Sigma', '#ED6956', 1),
('calculus', 'حسابان', 'LineChart', '#00BCD4', 2),
('geometry', 'هندسه', 'Compass', '#9B7BFF', 3),
('stats', 'آمار و احتمال', 'BarChart3', '#FF7A3D', 4),
('discrete', 'گسسته', 'Network', '#5FBF8F', 5),
]
def create_subjects(apps, schema_editor):
Subject = apps.get_model('content', 'Subject')
for key, title, icon, color, order in SUBJECTS:
Subject.objects.get_or_create(
key=key, defaults={'title': title, 'icon': icon, 'color': color, 'order': order},
)
class Migration(migrations.Migration):
dependencies = [('content', '0014_subject')]
operations = [migrations.RunPython(create_subjects, migrations.RunPython.noop)]

View File

@@ -0,0 +1,20 @@
import django.db.models.deletion
from django.db import migrations, models
import content.models
class Migration(migrations.Migration):
dependencies = [('content', '0015_seed_subjects')]
operations = [
migrations.AddField(
model_name='unit',
name='subject',
field=models.ForeignKey(
default=content.models.default_subject_pk,
on_delete=django.db.models.deletion.PROTECT,
related_name='units', to='content.subject',
),
),
]

View File

@@ -0,0 +1,19 @@
from django.db import migrations
def mark_calculus(apps, schema_editor):
"""
فصل‌های پایه‌ی یازدهم و دوازدهمِ موجود از کتابِ «حسابان»اند (`CALCULUS_11/12`)، نه ریاضیِ عمومی.
۰۰۱۶ همه را «ریاضی» گذاشت؛ اینجا درست می‌شود. پایه از `number` شناخته می‌شود، نه از id.
بررسی‌نشده: دیتابیسی که در آن فصلِ یازدهم/دوازدهم از کتابی جز حسابان آمده باشد (روی سرور و
لپ‌تاپ نیست؛ هیچ کتابِ دیگری برای آن پایه‌ها پیش از این migration ingest نشده بود).
"""
Subject = apps.get_model('content', 'Subject')
Unit = apps.get_model('content', 'Unit')
calculus = Subject.objects.get(key='calculus')
Unit.objects.filter(grade__number__in=(11, 12)).update(subject=calculus)
class Migration(migrations.Migration):
dependencies = [('content', '0016_unit_subject')]
operations = [migrations.RunPython(mark_calculus, migrations.RunPython.noop)]

View File

@@ -0,0 +1,25 @@
from django.db import migrations
from content.node_split import split_long_nodes
def split(apps, schema_editor):
"""
جزیره‌های بلندِ (بیش از ۷ درس) پایه‌های سوم تا دوازدهم را به جزیره‌های حداکثر ۶ درسی می‌شکند (`content/node_split.py`).
روی دیتابیسِ خالی (تست، سرورِ تازه) هیچ کاری نمی‌کند؛ آنجا `seed_content` از فایلِ بازساخته‌شده می‌سازد.
"""
Unit = apps.get_model('content', 'Unit')
stats = split_long_nodes(
Node=apps.get_model('content', 'Node'),
Lesson=apps.get_model('content', 'Lesson'),
UserNodeCompletion=apps.get_model('content', 'UserNodeCompletion'),
NodeProgress=apps.get_model('content', 'NodeProgress'),
units=Unit.objects.filter(grade__number__in=range(3, 13)).order_by('id'),
)
if stats['nodes_split']:
print(f'\n split_long_nodes: {stats}')
class Migration(migrations.Migration):
dependencies = [('content', '0017_calculus_units')]
operations = [migrations.RunPython(split, migrations.RunPython.noop)]

View File

@@ -48,8 +48,53 @@ class Grade(models.Model):
return self.title return self.title
class Subject(models.Model):
"""
کتابِ درسیِ یک پایه: «ریاضی»، «حسابان»، «هندسه»، «آمار و احتمال»، «گسسته».
چرا جدا از Grade: از دهم به بعد یک پایه چند کتابِ ریاضی دارد (دوازدهم: حسابان +
هندسه + گسسته) و دانش‌آموز بینشان جابه‌جا می‌شود. `title` برابرِ `subject`ِ
کتاب در `rag/services/textbooks.py` است (همان رشته‌ای که در نامِ collectionِ
Qdrant می‌آید)؛ ثبت‌نامِ کتابِ تازه یک ردیفِ اینجا هم می‌خواهد.
"""
key = models.SlugField(max_length=30, unique=True)
title = models.CharField(max_length=50, unique=True)
icon = models.CharField(max_length=100, blank=True)
color = models.CharField(max_length=10, blank=True)
# ترتیبِ دکمه‌ها در نوارِ کتاب‌ها؛ کتابِ اولِ هر پایه همان است که بی‌انتخاب باز می‌شود.
order = models.PositiveSmallIntegerField(default=0)
class Meta:
ordering = ['order', 'id']
def __str__(self):
return self.title
DEFAULT_SUBJECT_KEY = 'math'
def default_subject_pk():
"""
کتابِ پیش‌فرضِ یک فصل: «ریاضی» (کتابِ عمومیِ همه‌ی پایه‌ها).
انتخابِ محصولی است، نه «محض احتیاط»: تا پیش از وجودِ Subject همه‌ی فصل‌ها
ریاضی بودند. مسیرهای سیدِ برنامه‌ی درسی کتاب را صریح می‌دهند و فایلِ
ناقص را رد می‌کنند (`curriculum_io.parse`)؛ فقط ساختنِ دستیِ Unit بی‌کتاب
(ادمین، تست) به اینجا می‌رسد. ردیفِ ریاضی را migrationِ ۰۰۱۵ می‌سازد؛
get_or_create فقط دیتابیسی را پوشش می‌دهد که تستِ TransactionTestCase پاکش کرده.
"""
return Subject.objects.get_or_create(
key=DEFAULT_SUBJECT_KEY, defaults={'title': 'ریاضی', 'order': 1},
)[0].pk
class Unit(models.Model): class Unit(models.Model):
grade = models.ForeignKey(Grade, on_delete=models.CASCADE, related_name='units') grade = models.ForeignKey(Grade, on_delete=models.CASCADE, related_name='units')
subject = models.ForeignKey(
Subject, on_delete=models.PROTECT, related_name='units', default=default_subject_pk,
)
title = models.CharField(max_length=100) title = models.CharField(max_length=100)
# یک جمله‌ی کوتاه زیرِ عنوانِ فصل در صفحه‌ی دسته‌بندیِ درس‌ها. # یک جمله‌ی کوتاه زیرِ عنوانِ فصل در صفحه‌ی دسته‌بندیِ درس‌ها.
blurb = models.CharField(max_length=120, blank=True) blurb = models.CharField(max_length=120, blank=True)

113
content/node_split.py Normal file
View File

@@ -0,0 +1,113 @@
"""
شکستنِ جزیره‌های بلند به جزیره‌های حداکثر ۶ درسی (۲۰۲۶-۱۰-۱۲؛ نخست ۱۰ بود، به خواستِ مالک: «جزیره‌ها زود تموم می‌شن، صدها جزیره»).
چرا: `seed_curriculum_from_textbook` هر «درسِ فهرستِ کتاب» را یک جزیره می‌کند. حسابانِ دوازدهم
فقط ۱۲ جزیره دارد با میانگینِ ۲۷ و حداکثرِ ۴۳ نوعِ مسئله، در حالی‌که پایه‌های دیگر ۲۸ تا ۵۳ جزیره‌ی
۸ تا ۱۰ درسی دارند. حجمِ سوال یکی بود، ولی دانش‌آموز «تعداد جزیره» را می‌بیند و هر جزیره‌ی بلند فقط
یک آزمونِ چهارسوالی می‌دهد (سینیور درست می‌گفت: کم است).
مهم‌ترین قیدها:
- شناسه‌ی درس‌ها (Lesson.id) و سوال‌ها دست نمی‌خورد؛ فقط `Lesson.node` عوض می‌شود. پس
UserLessonCompletion/UserAnswer همه‌جا درست می‌ماند.
- جزیره‌ی اصلی بخشِ اول می‌ماند (همان id)، بخش‌های بعدی جزیره‌ی تازه‌اند.
- کسی که جزیره‌ی اصلی را با آزمون تمام کرده بود، همان را برای بخش‌های تازه هم تمام‌شده دارد
(ردیفِ UserNodeCompletion/NodeProgress با xp و سکه‌ی صفر و زمانِ اصلی؛ XP دوبار شمرده نشود و
لیدربورد/گزارشِ هفتگی تغییر نکند). بدونِ این، پیشرفتِ کاربرانِ فعلی عقب می‌رفت.
- ایدمپوتنت: جزیره‌ای که از قبل کوتاه است (≤ max_lessons) دست نمی‌خورد، پس اجرای دوباره
چیزی را دوباره نمی‌شکند.
نسنجیده: اجرا روی دیتابیسی که کاربرِ واقعی در وسطِ آزمونِ یک جزیره‌ی بلند دارد (آزمون ۴ سوالِ
ثابت با seed `nodeId:userId` است؛ شناسه‌ی جزیره‌ی بخشِ اول همان است، پس آزمونِ نیمه‌کاره‌اش
فقط ممکن است با درس‌های کمتر ساخته شود).
"""
import math
_FA = str.maketrans('0123456789', '۰۱۲۳۴۵۶۷۸۹')
def part_count(n_lessons: int, max_lessons: int, part_cap: int) -> int:
"""جزیره‌ی تا max_lessons درس دست نمی‌خورد؛ بلندتر به کمترین تعدادی تکه می‌شود که هیچ‌کدام از part_cap بزرگ‌تر نشود."""
if n_lessons <= max_lessons:
return 1
return max(2, math.ceil(n_lessons / part_cap))
def balanced_chunks(items: list, parts: int) -> list[list]:
"""parts تکه‌ی پشتِ‌هم با اندازه‌های حداکثر یک واحد اختلاف (اولی‌ها بزرگ‌تر)."""
size, extra = divmod(len(items), parts)
out, start = [], 0
for i in range(parts):
end = start + size + (1 if i < extra else 0)
out.append(items[start:end])
start = end
return out
def part_title(base: str, index: int) -> str:
return f'{base} ({str(index).translate(_FA)})'
def split_long_nodes(*, Node, Lesson, UserNodeCompletion, NodeProgress, units, max_lessons=7, part_cap=6,
dry_run=False) -> dict:
"""
units: queryset از Unit؛ مدل‌ها پارامتر می‌شوند تا هم migration (مدل‌های تاریخی) و هم دستورِ مدیریتی
از همین کد استفاده کنند.
"""
counts = {'nodes_split': 0, 'nodes_created': 0, 'lessons_moved': 0, 'completions_cloned': 0}
for unit in units:
nodes = list(Node.objects.filter(unit=unit).order_by('order', 'id'))
plan = []
for node in nodes:
lessons = list(Lesson.objects.filter(node=node).order_by('id').values_list('id', flat=True))
parts = part_count(len(lessons), max_lessons, part_cap)
plan.append((node, balanced_chunks(lessons, parts) if parts > 1 else None))
if not any(chunks for _node, chunks in plan):
continue
if dry_run:
for node, chunks in plan:
if chunks:
counts['nodes_split'] += 1
counts['nodes_created'] += len(chunks) - 1
counts['lessons_moved'] += sum(len(c) for c in chunks[1:])
continue
order = 0
for node, chunks in plan:
if not chunks:
order += 1
if node.order != order:
Node.objects.filter(pk=node.pk).update(order=order)
continue
base = node.title
counts['nodes_split'] += 1
completions = list(UserNodeCompletion.objects.filter(node=node))
progresses = list(NodeProgress.objects.filter(node=node, status='completed'))
for index, lesson_ids in enumerate(chunks, start=1):
order += 1
title = part_title(base, index)
if index == 1:
Node.objects.filter(pk=node.pk).update(title=title, order=order)
continue
new = Node.objects.create(unit=unit, title=title, order=order, icon=node.icon, color=node.color)
counts['nodes_created'] += 1
counts['lessons_moved'] += Lesson.objects.filter(pk__in=lesson_ids).update(node=new)
clones = UserNodeCompletion.objects.bulk_create([
UserNodeCompletion(
user_id=c.user_id, node=new, correct_count=c.correct_count,
total_questions=c.total_questions, stars=c.stars, xp_earned=0, coins_earned=0,
idempotency_key=None,
) for c in completions
])
# created_at از auto_now_add است و در bulk_create نادیده گرفته نمی‌شود؛ بعداً به زمانِ اصلی برمی‌گردد.
for clone, original in zip(clones, completions):
UserNodeCompletion.objects.filter(pk=clone.pk).update(created_at=original.created_at)
counts['completions_cloned'] += len(clones)
NodeProgress.objects.bulk_create([
NodeProgress(
user_id=p.user_id, node=new, status='completed',
lessons_completed_count=len(lesson_ids), started_at=p.started_at,
completed_at=p.completed_at, last_activity_at=p.last_activity_at,
) for p in progresses
])
# بخشِ اول فقط درس‌های خودش را نگه می‌دارد: درس‌های بخش‌های بعدی بالا جابه‌جا شدند.
return counts

View File

@@ -1,7 +1,7 @@
from django.db.models.signals import post_delete, post_save from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver from django.dispatch import receiver
from .models import Grade, Lesson, Node, Question, Unit, UserAnswer, UserLessonCompletion from .models import Grade, Lesson, Node, Question, Subject, Unit, UserAnswer, UserLessonCompletion
from .services import touch_node_progress_on_answer, update_node_progress_on_lesson_completion from .services import touch_node_progress_on_answer, update_node_progress_on_lesson_completion
@@ -25,6 +25,8 @@ def _on_lesson_completion_created(sender, instance, created, raw=False, **kwargs
# می‌رود و همه‌ی کلیدهای content:grades / content:tree را یک‌جا باطل می‌کند. # می‌رود و همه‌ی کلیدهای content:grades / content:tree را یک‌جا باطل می‌کند.
@receiver(post_save, sender=Grade, dispatch_uid='content_bump_curriculum_grade_save') @receiver(post_save, sender=Grade, dispatch_uid='content_bump_curriculum_grade_save')
@receiver(post_delete, sender=Grade, dispatch_uid='content_bump_curriculum_grade_delete') @receiver(post_delete, sender=Grade, dispatch_uid='content_bump_curriculum_grade_delete')
@receiver(post_save, sender=Subject, dispatch_uid='content_bump_curriculum_subject_save')
@receiver(post_delete, sender=Subject, dispatch_uid='content_bump_curriculum_subject_delete')
@receiver(post_save, sender=Unit, dispatch_uid='content_bump_curriculum_unit_save') @receiver(post_save, sender=Unit, dispatch_uid='content_bump_curriculum_unit_save')
@receiver(post_delete, sender=Unit, dispatch_uid='content_bump_curriculum_unit_delete') @receiver(post_delete, sender=Unit, dispatch_uid='content_bump_curriculum_unit_delete')
@receiver(post_save, sender=Node, dispatch_uid='content_bump_curriculum_node_save') @receiver(post_save, sender=Node, dispatch_uid='content_bump_curriculum_node_save')

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -27,6 +27,7 @@ import json
from django.db import transaction from django.db import transaction
from . import question_io from . import question_io
from .curriculum_io import _subject_or_error
from .models import Grade, Lesson, Node, Question, SeedRecord, Unit from .models import Grade, Lesson, Node, Question, SeedRecord, Unit
@@ -91,7 +92,10 @@ def sync(grades: list[dict], question_rows: list[dict]) -> dict:
if ledger.delivered(_key('unit', *upath)): if ledger.delivered(_key('unit', *upath)):
counts['retired'] += 1 counts['retired'] += 1
continue continue
unit = Unit.objects.create(grade=grade, title=u['title'], order=u['order']) unit = Unit.objects.create(
grade=grade, title=u['title'], order=u['order'],
subject=_subject_or_error(u['subject']),
)
counts['units'] += 1 counts['units'] += 1
ledger.record(_key('unit', *upath), 'unit') ledger.record(_key('unit', *upath), 'unit')
if u.get('blurb') and not unit.blurb: if u.get('blurb') and not unit.blurb:

View File

@@ -61,7 +61,24 @@ class StatsSerializer(serializers.Serializer):
coins = serializers.IntegerField() coins = serializers.IntegerField()
class SubjectSerializer(serializers.Serializer):
key = serializers.CharField(help_text='همان که به `?subject=` داده می‌شود.')
title = serializers.CharField()
icon = serializers.CharField(allow_blank=True, help_text='نامِ آیکنِ lucide.')
color = serializers.CharField(allow_blank=True)
order = serializers.IntegerField()
class HomeResponseSerializer(serializers.Serializer): class HomeResponseSerializer(serializers.Serializer):
subject = SubjectSerializer(
allow_null=True,
help_text='کتابی که `units` از آن است؛ null فقط وقتی پایه هیچ فصلی ندارد.',
)
subjects = SubjectSerializer(
many=True,
help_text='همه‌ی کتاب‌های این پایه. فرانت نوارِ جابه‌جاییِ کتاب را فقط وقتی نشان می‌دهد '
'که بیش از یکی باشد.',
)
units = UnitWithNodesSerializer(many=True) units = UnitWithNodesSerializer(many=True)
stats = StatsSerializer() stats = StatsSerializer()

View File

@@ -12,7 +12,7 @@ from rag.models import LessonChunk
def _curriculum(blurb='', example='', steps=None): def _curriculum(blurb='', example='', steps=None):
return json.dumps({'grades': [{'title': 'پایه آزمون', 'units': [{ return json.dumps({'grades': [{'title': 'پایه آزمون', 'units': [{
'title': 'فصل ۱', 'blurb': blurb, 'nodes': [{'title': 'جزیره', 'lessons': [ 'subject': 'ریاضی', 'title': 'فصل ۱', 'blurb': blurb, 'nodes': [{'title': 'جزیره', 'lessons': [
{'title': 'درس', 'introText': 'توضیح', 'example': example, 'steps': steps or []}, {'title': 'درس', 'introText': 'توضیح', 'example': example, 'steps': steps or []},
]}], ]}],
}]}]}).encode() }]}]}).encode()

View File

@@ -109,7 +109,7 @@ class CurriculumImportTests(TestCase):
'grades': [{ 'grades': [{
'title': 'پایه نهم', 'icon': 'grade-9', 'color': '#FF9800', 'title': 'پایه نهم', 'icon': 'grade-9', 'color': '#FF9800',
'units': [{ 'units': [{
'title': 'فصل ۴: توان و ریشه', 'order': 4, 'subject': 'ریاضی', 'title': 'فصل ۴: توان و ریشه', 'order': 4,
'nodes': [{ 'nodes': [{
'title': 'توان صحیح', 'order': 1, 'title': 'توان صحیح', 'order': 1,
'lessons': [{'title': 'ضرب توان‌ها', 'introText': 'پایه‌ها برابرند.'}], 'lessons': [{'title': 'ضرب توان‌ها', 'introText': 'پایه‌ها برابرند.'}],

View File

@@ -21,7 +21,7 @@ CURRICULUM = {
'grades': [{ 'grades': [{
'title': 'پایه نهم', 'icon': 'grade-9', 'color': '#FF9800', 'title': 'پایه نهم', 'icon': 'grade-9', 'color': '#FF9800',
'units': [{ 'units': [{
'title': 'فصل ۴: توان و ریشه', 'order': 4, 'subject': 'ریاضی', 'title': 'فصل ۴: توان و ریشه', 'order': 4,
'nodes': [{ 'nodes': [{
'title': 'توان صحیح', 'order': 1, 'title': 'توان صحیح', 'order': 1,
'lessons': [ 'lessons': [

159
content/tests_subjects.py Normal file
View File

@@ -0,0 +1,159 @@
"""
یک پایه چند کتاب دارد (دوازدهم: حسابان، هندسه، گسسته) و دانش‌آموز بینشان جابه‌جا می‌شود
(۲۰۲۶-۱۰-۱۱). `/api/home?subject=` نقشه‌ی همان کتاب را می‌دهد؛ کتابِ ناموجود در پایه ۴۰۰ است؛
فصلِ اولِ هر کتاب رایگان است؛ و دستیار کتابِ درس را از خودِ فصل می‌گیرد.
"""
from django.core.cache import cache
from django.test import TestCase
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from .models import Grade, Lesson, Node, Subject, Unit
class SubjectHomeTests(TestCase):
def setUp(self):
cache.clear()
self.grade = Grade.objects.create(title='پایه دوازدهم', icon='', color='')
self.calc = Subject.objects.get(key='calculus')
self.geo = Subject.objects.get(key='geometry')
self.units = {}
for subject, titles in ((self.calc, ['فصل ۱: تابع', 'فصل ۲: مثلثات']),
(self.geo, ['فصل ۱: ماتریس', 'فصل ۲: مقاطع مخروطی'])):
for order, title in enumerate(titles, start=1):
unit = Unit.objects.create(grade=self.grade, subject=subject, title=title, order=order)
node = Node.objects.create(unit=unit, title=f'جزیره {title}', order=1)
Lesson.objects.create(node=node, title='درس')
self.units[(subject.key, order)] = unit
self.user = User.objects.create(phone='09120007911', grade_id=self.grade.id)
self.client = APIClient()
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
def test_default_is_the_first_subject_of_the_grade(self):
body = self.client.get('/api/home').json()
self.assertEqual(body['subject']['key'], 'calculus')
self.assertEqual([s['key'] for s in body['subjects']], ['calculus', 'geometry'])
self.assertEqual([u['title'] for u in body['units']], ['فصل ۱: تابع', 'فصل ۲: مثلثات'])
def test_subject_param_switches_the_map(self):
body = self.client.get('/api/home?subject=geometry').json()
self.assertEqual(body['subject']['key'], 'geometry')
self.assertEqual([u['title'] for u in body['units']], ['فصل ۱: ماتریس', 'فصل ۲: مقاطع مخروطی'])
def test_a_subject_the_grade_does_not_have_is_a_400_not_a_silent_default(self):
self.assertEqual(self.client.get('/api/home?subject=discrete').status_code, 400)
self.assertEqual(self.client.get('/api/home?subject=nope').status_code, 400)
def test_the_first_unit_of_each_subject_is_free(self):
for key in ('calculus', 'geometry'):
body = self.client.get(f'/api/home?subject={key}').json()
self.assertEqual([u['requiresSubscription'] for u in body['units']], [False, True], key)
def test_second_unit_of_other_subject_is_still_locked_by_the_paywall(self):
node = Node.objects.get(unit=self.units[('geometry', 2)])
lesson = node.lessons.first()
self.assertEqual(self.client.get(f'/api/lessons/{lesson.id}/questions').status_code, 402)
def test_ordering_follows_subject_order_not_creation_order(self):
# هندسه زودتر ساخته شود؛ نقشه باز هم حسابان را اول می‌دهد (Subject.order).
cache.clear()
Subject.objects.filter(pk=self.geo.pk).update(order=1)
Subject.objects.filter(pk=self.calc.pk).update(order=9)
cache.clear()
self.assertEqual(self.client.get('/api/home').json()['subject']['key'], 'geometry')
class LessonContextUsesTheUnitSubjectTests(TestCase):
def test_a_geometry_lesson_resolves_to_the_geometry_book(self):
from rag.services.lesson_context import resolve_from_lesson
grade = Grade.objects.create(title='پایه دهم', icon='', color='')
geo = Subject.objects.get(key='geometry')
unit = Unit.objects.create(grade=grade, subject=geo, title='فصل ۲: قضیه‌ی تالس، تشابه و کاربردهای آن')
lesson = Lesson.objects.create(node=Node.objects.create(unit=unit, title='n'), title='l')
self.assertEqual(
resolve_from_lesson(lesson.id),
{'subject': 'هندسه', 'gradeTitle': 'دهم', 'chapter': 2},
)
def test_same_title_under_the_wrong_subject_does_not_match(self):
from rag.services.lesson_context import resolve_from_lesson
grade = Grade.objects.create(title='پایه دهم', icon='', color='')
math = Subject.objects.get(key='math')
# عنوانِ فصلِ هندسه، ولی زیرِ کتابِ ریاضی: نباید به collectionِ هندسه برود.
unit = Unit.objects.create(grade=grade, subject=math, title='فصل ۲: قضیه‌ی تالس، تشابه و کاربردهای آن')
lesson = Lesson.objects.create(node=Node.objects.create(unit=unit, title='n'), title='l')
self.assertEqual(resolve_from_lesson(lesson.id), {'gradeTitle': 'دهم'})
class CurriculumFileNeedsASubjectTests(TestCase):
def test_a_unit_without_subject_is_rejected(self):
import json
from .curriculum_io import CurriculumError, parse
raw = json.dumps({'grades': [{'title': 'پایه ۱', 'units': [{'title': 'فصل ۱', 'nodes': []}]}]})
with self.assertRaises(CurriculumError):
parse(raw.encode())
def test_an_unknown_subject_is_rejected_on_import(self):
from .curriculum_io import CurriculumError, import_curriculum
grades = [{'title': 'پایه ۱', 'icon': '', 'color': '', 'units': [
{'subject': 'فیزیک', 'title': 'فصل ۱', 'blurb': '', 'order': 1, 'nodes': []}]}]
with self.assertRaises(CurriculumError):
import_curriculum(grades)
class NodeSplitTests(TestCase):
"""`content.node_split`: جزیره‌ی بلند → چند جزیره‌ی حداکثر ۶ درسی، بی‌آن‌که پیشرفتِ کسی گم یا دوبار شمرده شود."""
def setUp(self):
from .models import NodeProgress, UserNodeCompletion
self.grade = Grade.objects.create(title='پایه دوازدهم', icon='', color='')
self.subject = Subject.objects.get(key='calculus')
self.unit = Unit.objects.create(grade=self.grade, subject=self.subject, title='فصل ۱: تابع', order=1)
self.long = Node.objects.create(unit=self.unit, title='تبدیل نمودار', order=1)
self.short = Node.objects.create(unit=self.unit, title='کوتاه', order=2)
self.lesson_ids = [Lesson.objects.create(node=self.long, title=f'درس {i}').id for i in range(25)]
Lesson.objects.create(node=self.short, title='تک')
UserNodeCompletion.objects.create(user_id=7, node=self.long, correct_count=3, total_questions=4,
stars=2, xp_earned=50, coins_earned=10)
NodeProgress.objects.create(user_id=7, node=self.long, status='completed')
def _run(self, **kw):
from .models import NodeProgress, UserNodeCompletion
from .node_split import split_long_nodes
return split_long_nodes(Node=Node, Lesson=Lesson, UserNodeCompletion=UserNodeCompletion,
NodeProgress=NodeProgress, units=Unit.objects.all(), **kw)
def test_splits_into_balanced_ordered_parts_and_keeps_lesson_ids(self):
counts = self._run()
self.assertEqual((counts['nodes_split'], counts['nodes_created']), (1, 4))
nodes = list(Node.objects.filter(unit=self.unit).order_by('order'))
self.assertEqual([n.title for n in nodes], ['تبدیل نمودار (۱)', 'تبدیل نمودار (۲)', 'تبدیل نمودار (۳)', 'تبدیل نمودار (۴)', 'تبدیل نمودار (۵)', 'کوتاه'])
self.assertEqual([n.order for n in nodes], [1, 2, 3, 4, 5, 6])
self.assertEqual(nodes[0].pk, self.long.pk) # بخشِ اول همان شناسه را نگه می‌دارد
sizes = [Lesson.objects.filter(node=n).count() for n in nodes]
self.assertEqual(sizes, [5, 5, 5, 5, 5, 1])
# ترتیبِ کتاب حفظ شده: درس‌های هر بخش پشتِ‌هم‌اند.
first = list(Lesson.objects.filter(node=nodes[0]).order_by('id').values_list('id', flat=True))
self.assertEqual(first, self.lesson_ids[:5])
def test_a_finished_island_stays_finished_without_double_counting_xp(self):
from .models import NodeProgress, UserNodeCompletion
self._run()
rows = UserNodeCompletion.objects.filter(user_id=7).order_by('node__order')
self.assertEqual(rows.count(), 5)
self.assertEqual(sum(r.xp_earned for r in rows), 50)
self.assertEqual(sum(r.coins_earned for r in rows), 10)
self.assertEqual(NodeProgress.objects.filter(user_id=7, status='completed').count(), 5)
def test_second_run_changes_nothing(self):
self._run()
again = self._run()
self.assertEqual(again, {'nodes_split': 0, 'nodes_created': 0, 'lessons_moved': 0, 'completions_cloned': 0})
self.assertEqual(Node.objects.filter(unit=self.unit).count(), 6)
def test_dry_run_writes_nothing(self):
counts = self._run(dry_run=True)
self.assertEqual(counts['nodes_created'], 4)
self.assertEqual(Node.objects.filter(unit=self.unit).count(), 2)

View File

@@ -26,7 +26,7 @@ from .services import (
playable_lesson_ids, quiz_completed_node_ids, verified_correct_count, playable_lesson_ids, quiz_completed_node_ids, verified_correct_count,
) )
from .models import ( from .models import (
Choice, Grade, Lesson, Node, NodeProgress, Question, QuestionReport, UserAnswer, Choice, Grade, Lesson, Node, NodeProgress, Question, QuestionReport, Subject, UserAnswer,
UserLessonCompletion, UserNodeCompletion, UserLessonCompletion, UserNodeCompletion,
) )
from .serializers import ( from .serializers import (
@@ -88,6 +88,29 @@ def _grades_payload() -> dict:
return cache.get_or_set(key, build, _CACHE_TTL) return cache.get_or_set(key, build, _CACHE_TTL)
def _subjects_payload() -> dict:
"""{key: {key,title,icon,color,order}} — کلِ جدولِ کتاب‌ها (چند ردیف)، کش‌شده با نسخه‌ی برنامه‌ی درسی."""
key = f'content:subjects:v{curriculum_version()}'
def build():
return {
s.key: {'key': s.key, 'title': s.title, 'icon': s.icon, 'color': s.color, 'order': s.order}
for s in Subject.objects.all()
}
return cache.get_or_set(key, build, _CACHE_TTL)
def grade_subjects(tree: list) -> list[dict]:
"""کتاب‌هایی که این پایه فصل دارد، به ترتیبِ `Subject.order`."""
table = _subjects_payload()
keys = []
for unit in tree:
if unit['subject'] not in keys:
keys.append(unit['subject'])
return sorted((table[k] for k in keys), key=lambda s: s['order'])
# پالت و آیکونِ پیش‌فرضِ کارتِ مبحث — هر دو از خودِ اپ گرفته شده‌اند # پالت و آیکونِ پیش‌فرضِ کارتِ مبحث — هر دو از خودِ اپ گرفته شده‌اند
# (رنگ‌های موجود در طرح، و آیکون‌هایی که از lucide وارد می‌شوند)، تا تا وقتی # (رنگ‌های موجود در طرح، و آیکون‌هایی که از lucide وارد می‌شوند)، تا تا وقتی
# کسی از پنلِ ادمین مقدارِ دلخواه نگذاشته، کارت‌ها بی‌رنگ نمانند. # کسی از پنلِ ادمین مقدارِ دلخواه نگذاشته، کارت‌ها بی‌رنگ نمانند.
@@ -101,24 +124,24 @@ _NODE_ICONS = ['Grid3x3', 'TrendingUp', 'LineChart', 'Sigma', 'Compass', 'Repeat
def _grade_tree(grade_id: int): def _grade_tree(grade_id: int):
"""اسکلتِ گریدِ [{id,title,nodes:[{id,title,lesson_ids}]}] — بدونِ status/دیتای کاربر. """اسکلتِ گریدِ [{id,title,nodes:[{id,title,lesson_ids}]}] — بدونِ status/دیتای کاربر.
None یعنی گریدِ ناموجود؛ [] یعنی گریدِ موجودِ بدونِ واحد.""" None یعنی گریدِ ناموجود؛ [] یعنی گریدِ موجودِ بدونِ واحد."""
# tree2: هر node حالا `playable_ids` هم دارد؛ کلیدِ ساختارِ قبلی باطل شد. # tree3: هر فصل حالا `subject` (کلیدِ کتاب) دارد؛ tree2 (و پیش از آن، playable_ids) باطل شد.
# پایه‌ی ناموجود کش نمی‌شود: gradeId از query string می‌آید و هر عددِ تازه یک کلیدِ تازه # پایه‌ی ناموجود کش نمی‌شود: gradeId از query string می‌آید و هر عددِ تازه یک کلیدِ تازه
# می‌ساخت. LocMemCache (تا CACHE_URL نیست) بعد از ۳۰۰ کلید یک‌سومِ کهنه‌ها را دور می‌ریزد و # می‌ساخت. LocMemCache (تا CACHE_URL نیست) بعد از ۳۰۰ کلید یک‌سومِ کهنه‌ها را دور می‌ریزد و
# شمارنده‌های throttle (سقفِ ورودِ ادمین، OTP بر اساسِ IP) هم در همین کش‌اند؛ با چند صد # شمارنده‌های throttle (سقفِ ورودِ ادمین، OTP بر اساسِ IP) هم در همین کش‌اند؛ با چند صد
# gradeIdِ ساختگی سقفِ ورودِ ادمین صفر می‌شد (بازتولید شد، tests_security_review). # gradeIdِ ساختگی سقفِ ورودِ ادمین صفر می‌شد (بازتولید شد، tests_security_review).
if grade_id not in {g['id'] for g in _grades_payload()['grades']}: if grade_id not in {g['id'] for g in _grades_payload()['grades']}:
return None return None
key = f'content:tree2:grade:{grade_id}:v{curriculum_version()}' key = f'content:tree3:grade:{grade_id}:v{curriculum_version()}'
cached = cache.get(key) cached = cache.get(key)
if cached is not None: if cached is not None:
return cached return cached
units = ( units = (
Grade.objects.get(pk=grade_id) Grade.objects.get(pk=grade_id)
.units.prefetch_related(Prefetch( .units.select_related('subject').prefetch_related(Prefetch(
'nodes__lessons', 'nodes__lessons',
queryset=Lesson.objects.annotate(question_total=Count('questions')), queryset=Lesson.objects.annotate(question_total=Count('questions')),
)).all() )).order_by('subject__order', 'subject_id', 'order', 'id')
) )
tree = [] tree = []
position = 0 # شمارنده‌ی سراسریِ مبحث در این پایه، برای چرخشِ رنگ position = 0 # شمارنده‌ی سراسریِ مبحث در این پایه، برای چرخشِ رنگ
@@ -140,6 +163,7 @@ def _grade_tree(grade_id: int):
position += 1 position += 1
tree.append({ tree.append({
'id': str(unit.id), 'id': str(unit.id),
'subject': unit.subject.key,
'title': unit.title, 'title': unit.title,
'blurb': unit.blurb, 'blurb': unit.blurb,
'nodes': nodes, 'nodes': nodes,
@@ -275,12 +299,19 @@ def node_is_unlocked(user_id, node) -> bool:
def unit_is_free(unit_id: int, grade_id: int) -> bool: def unit_is_free(unit_id: int, grade_id: int) -> bool:
""" """
فصلِ رایگان: فقط اولین فصلِ هر پایه (به ترتیبِ `Unit.order`، همان ترتیبِ نقشه). بقیه‌ی فصل‌ها فصلِ رایگان: فقط اولین فصلِ هر کتابِ هر پایه (به ترتیبِ `Unit.order`، همان ترتیبِ نقشه).
اشتراکِ زمانی می‌خواهند (تصمیمِ سینیور و مدیرعامل، ۲۰۲۶-۱۰-۱۰: «هر کاربر یک درس را ببرد جلو، پیش از افزوده‌شدنِ کتاب‌های هندسه/آمار/گسسته (۲۰۲۶-۱۰-۱۱) هر پایه یک کتاب داشت و «اولین فصلِ پایه»
همین بود؛ حالا هر کتاب فصلِ اولش را رایگان دارد تا دانش‌آموزِ دوازدهم هر سه کتاب را بچشد. این
تعبیر را ما از «یک فصل رایگان» کردیم و سینیور تأیید نکرده؛ اگر نخواست، شرطِ بالا را به
`tree[0]` برگردانید. بقیه‌ی فصل‌ها اشتراکِ زمانی می‌خواهند (تصمیمِ سینیور و مدیرعامل، ۲۰۲۶-۱۰-۱۰: «هر کاربر یک درس را ببرد جلو،
برای بقیه اشتراک بخرد»؛ مالکِ پروژه «درس» را «فصل» گرفت). برای بقیه اشتراک بخرد»؛ مالکِ پروژه «درس» را «فصل» گرفت).
""" """
tree = _grade_tree(grade_id) or [] tree = _grade_tree(grade_id) or []
return bool(tree) and tree[0]['id'] == str(unit_id) unit = next((u for u in tree if u['id'] == str(unit_id)), None)
if unit is None:
return False
first_of_subject = next(u for u in tree if u['subject'] == unit['subject'])
return first_of_subject['id'] == str(unit_id)
def has_unit_access(user_id, unit_id: int, grade_id: int) -> bool: def has_unit_access(user_id, unit_id: int, grade_id: int) -> bool:
@@ -340,6 +371,9 @@ class HomeView(APIView):
OpenApiParameter('gradeId', OpenApiTypes.INT, required=False, OpenApiParameter('gradeId', OpenApiTypes.INT, required=False,
description='پایه‌ای که نقشه‌اش خواسته می‌شود؛ ' description='پایه‌ای که نقشه‌اش خواسته می‌شود؛ '
'بدونِ آن، پایه‌ی ذخیره‌شده‌ی خودِ کاربر.'), 'بدونِ آن، پایه‌ی ذخیره‌شده‌ی خودِ کاربر.'),
OpenApiParameter('subject', OpenApiTypes.STR, required=False,
description='کلیدِ کتاب (`subjects[].key` در همین پاسخ). بدونِ آن اولین '
'کتابِ پایه؛ کتابِ ناموجود در این پایه ۴۰۰.'),
], ],
responses=HomeResponseSerializer, responses=HomeResponseSerializer,
tags=['Curriculum (content)'], tags=['Curriculum (content)'],
@@ -366,6 +400,9 @@ class HomeView(APIView):
grade_id = request.user.grade_id grade_id = request.user.grade_id
tree = [] tree = []
subjects = []
active_subject = None
subject_param = request.query_params.get('subject')
if grade_id: if grade_id:
tree = _grade_tree(grade_id) tree = _grade_tree(grade_id)
if tree is None: if tree is None:
@@ -375,6 +412,18 @@ class HomeView(APIView):
if grade_param: if grade_param:
raise Http404 raise Http404
tree = [] tree = []
else:
# یک پایه چند کتاب دارد (دوازدهم: حسابان، هندسه، گسسته). بی‌`subject` اولین کتابِ
# پایه (به ترتیبِ `Subject.order`) باز می‌شود؛ فرانت آخرین انتخابِ کاربر را خودش می‌فرستد.
# کلیدِ نامعتبر یا کتابی که این پایه ندارد ۴۰۰ است، نه نقشه‌ی پایه‌ی خودِ کاربر.
subjects = grade_subjects(tree)
if subject_param:
if subject_param not in {s['key'] for s in subjects}:
raise ValidationError({'subject': 'این پایه چنین کتابی ندارد.'})
active_subject = subject_param
else:
active_subject = subjects[0]['key'] if subjects else None
tree = [u for u in tree if u['subject'] == active_subject]
# همه‌ی lesson_idهای کامل‌شده‌ی این کاربر در همین گرید — یک کوئری، # همه‌ی lesson_idهای کامل‌شده‌ی این کاربر در همین گرید — یک کوئری،
# مستقل از تعدادِ node (رفعِ N+1). # مستقل از تعدادِ node (رفعِ N+1).
@@ -466,7 +515,12 @@ class HomeView(APIView):
'coins': stats.coins if stats else 0, 'coins': stats.coins if stats else 0,
} }
data = {'units': units_payload, 'stats': stats_payload} data = {
'subject': next((s for s in subjects if s['key'] == active_subject), None) if subjects else None,
'subjects': subjects,
'units': units_payload,
'stats': stats_payload,
}
# خودِ dict، نه HomeResponseSerializer(data).data: سریال‌سازِ تو در توی DRF برای ~۳۰ جزیره حدودِ ۱٫۶ # خودِ dict، نه HomeResponseSerializer(data).data: سریال‌سازِ تو در توی DRF برای ~۳۰ جزیره حدودِ ۱٫۶
# میلی‌ثانیه از ~۸٫۶ِ این پرترافیک‌ترین صفحه بود (پروفایلِ ۲۰۲۶-۱۰-۱۱) و چیزی را تبدیل نمی‌کرد. # میلی‌ثانیه از ~۸٫۶ِ این پرترافیک‌ترین صفحه بود (پروفایلِ ۲۰۲۶-۱۰-۱۱) و چیزی را تبدیل نمی‌کرد.
# سریال‌ساز همچنان قراردادِ Swagger است؛ HomeMatchesItsSerializerTests برابریِ این دو را نگه می‌دارد. # سریال‌ساز همچنان قراردادِ Swagger است؛ HomeMatchesItsSerializerTests برابریِ این دو را نگه می‌دارد.

View File

@@ -19,6 +19,7 @@
| سوم، ششم، هفتم | روی Qdrant هست (ساخته‌ی تیم دیگر) — PDF نداریم جز هفتم | | سوم، ششم، هفتم | روی Qdrant هست (ساخته‌ی تیم دیگر) — PDF نداریم جز هفتم |
| هشتم، نهم، دهم | ingest شده؛ بازخوانیِ صفحه‌های شکل‌دار مانده | | هشتم، نهم، دهم | ingest شده؛ بازخوانیِ صفحه‌های شکل‌دار مانده |
| یازدهم، دوازدهم | در نوبتِ ingest (۱۴۰۵/۰۶/۲۸) | | یازدهم، دوازدهم | در نوبتِ ingest (۱۴۰۵/۰۶/۲۸) |
| هندسه دهم/یازدهم/دوازدهم، آمار و احتمال یازدهم، گسسته دوازدهم | ثبت و ingest شد ۲۰۲۶-۱۰-۱۱ (`subject` = «هندسه»، «آمار و احتمال»، «گسسته»؛ هر کدام collectionِ خودش) |
| چهارم، پنجم | ingest شد ۲۰۲۶-۱۰-۰۳ (درخواستِ دکتر: «از سوم به بعد»)؛ پنجم چاپِ ۱۴۰۵ است | | چهارم، پنجم | ingest شد ۲۰۲۶-۱۰-۰۳ (درخواستِ دکتر: «از سوم به بعد»)؛ پنجم چاپِ ۱۴۰۵ است |
| اول، دوم | PDF موجود است، خارج از اولویتِ فعلی | | اول، دوم | PDF موجود است، خارج از اولویتِ فعلی |

View File

@@ -125,7 +125,7 @@ class Command(BaseCommand):
parser.add_argument('--judge', default=DEFAULT_JUDGE) parser.add_argument('--judge', default=DEFAULT_JUDGE)
parser.add_argument('--per-grade', type=int, default=6) parser.add_argument('--per-grade', type=int, default=6)
parser.add_argument('--collections', nargs='*', default=None, parser.add_argument('--collections', nargs='*', default=None,
help='پیش‌فرض: همه‌ی کتاب‌های ریاضی/حسابان') help='پیش‌فرض: همه‌ی کتاب‌های ریاضی/حسابان/هندسه/آمار/گسسته')
parser.add_argument('--seed', default='bench-v1') parser.add_argument('--seed', default='bench-v1')
parser.add_argument('--workers', type=int, default=4) parser.add_argument('--workers', type=int, default=4)
parser.add_argument('--no-context', action='store_true', parser.add_argument('--no-context', action='store_true',
@@ -149,7 +149,7 @@ class Command(BaseCommand):
if collections: if collections:
qs = qs.filter(collection__in=collections) qs = qs.filter(collection__in=collections)
else: else:
qs = qs.filter(collection__regex=r'^(ریاضی|حسابان) ') qs = qs.filter(collection__regex=r'^(ریاضی|حسابان|هندسه|آمار و احتمال|گسسته) ')
by_collection = defaultdict(list) by_collection = defaultdict(list)
for chunk in qs.only('id', 'problem', 'checked_answer', 'collection', 'grade_title', for chunk in qs.only('id', 'problem', 'checked_answer', 'collection', 'grade_title',
'subject', 'chapter'): 'subject', 'chapter'):

View File

@@ -24,7 +24,7 @@ MATH_LESSON_FILTER_KEY = 'chapter'
# همان شکلِ payload را دارد. اگر اینجا نباشد، retrieval آن را درسِ متنی حساب # همان شکلِ payload را دارد. اگر اینجا نباشد، retrieval آن را درسِ متنی حساب
# می‌کند و به‌جای `chapter` روی `lesson_number` فیلتر می‌زند — بدونِ هیچ خطایی # می‌کند و به‌جای `chapter` روی `lesson_number` فیلتر می‌زند — بدونِ هیچ خطایی
# همیشه صفر نتیجه. # همیشه صفر نتیجه.
MATH_SUBJECTS = {'ریاضی', 'حسابان'} MATH_SUBJECTS = {'ریاضی', 'حسابان', 'هندسه', 'آمار و احتمال', 'گسسته'}
# فصول ریاضی به تفکیکِ (subject, grade) — کلیدِ داخلی هم شماره‌ی فصل و هم # فصول ریاضی به تفکیکِ (subject, grade) — کلیدِ داخلی هم شماره‌ی فصل و هم
# رشته‌ی کامل رو می‌پذیره. # رشته‌ی کامل رو می‌پذیره.

View File

@@ -51,7 +51,7 @@ def resolve_from_lesson(lesson_id) -> dict:
lesson = ( lesson = (
Lesson.objects Lesson.objects
.select_related('node__unit__grade') .select_related('node__unit__grade', 'node__unit__subject')
.filter(pk=lesson_id) .filter(pk=lesson_id)
.first() .first()
) )
@@ -67,16 +67,15 @@ def resolve_from_lesson(lesson_id) -> dict:
if not grade_title: if not grade_title:
return {} return {}
# عنوانِ Unit همان رشته‌ی دقیقِ فصل است؛ در کاتالوگ دنبالش می‌گردیم تا هم # کتاب از خودِ فصل می‌آید (`Unit.subject`)، نه از حدسِ روی عنوان: یک پایه چند کتاب دارد و
# subject و هم شماره‌ی فصل دربیاید. برابریِ رشته‌ای عمدی‌ست: فیلترِ Qdrant # عنوانِ فصل بینِ کتاب‌ها می‌تواند تکرار شود. عنوانِ Unit همان رشته‌ی دقیقِ فصل است؛ در جدولِ
# هم روی همین رشته‌ی دقیق است، پس اگر اینجا نخورد آنجا هم نمی‌خورد. # فصل‌های همان کتاب دنبالش می‌گردیم تا شماره‌ی فصل دربیاید. برابریِ رشته‌ای عمدی‌ست: فیلترِ
# Qdrant هم روی همین رشته‌ی دقیق است، پس اگر اینجا نخورد آنجا هم نمی‌خورد.
unit_title = (unit.title or '').strip() unit_title = (unit.title or '').strip()
for (subject, grade), chapters in MATH_CHAPTERS.items(): subject = unit.subject.title
if grade != grade_title: for number, title in MATH_CHAPTERS.get((subject, grade_title), {}).items():
continue if title == unit_title:
for number, title in chapters.items(): return {'subject': subject, 'gradeTitle': grade_title, 'chapter': number}
if title == unit_title:
return {'subject': subject, 'gradeTitle': grade_title, 'chapter': number}
logger.info( logger.info(
'rag.lesson_context: عنوانِ فصل «%s» (پایه %s) در کاتالوگ نبود', 'rag.lesson_context: عنوانِ فصل «%s» (پایه %s) در کاتالوگ نبود',
@@ -90,10 +89,13 @@ def resolve_from_grade(grade_id) -> dict:
{'subject', 'gradeTitle'} برای وقتی که هیچ درسی در کار نیست. {'subject', 'gradeTitle'} برای وقتی که هیچ درسی در کار نیست.
صفحه‌ی «از پی بپرس» از نوارِ پایینِ اپ باز می‌شود، نه از داخلِ یک درس؛ صفحه‌ی «از پی بپرس» از نوارِ پایینِ اپ باز می‌شود، نه از داخلِ یک درس؛
پس نه lessonId دارد نه subject. بدونِ این تابع retrieval کلاً رد می‌شد و پس نه lessonId دارد نه (اگر فرانت نفرستد) subject. بدونِ این تابع retrieval
همه‌ی جواب‌های آن صفحه عمومی بودند. هر پایه دقیقاً یک کتابِ ریاضی دارد، کلاً رد می‌شد و همه‌ی جواب‌های آن صفحه عمومی بودند.
پس پایه‌ی خودِ کاربر برای انتخابِ کتاب کافی‌ست؛ فصل معلوم نیست و جستجو
روی کلِ همان کتاب انجام می‌شود. ⚠️ از ۲۰۲۶-۱۰-۱۱ یک پایه چند کتاب دارد (دوازدهم: حسابان، هندسه، گسسته). این تابع فقط
«اولین کتابِ پایه» را برمی‌گرداند (ترتیبِ ثبتِ کتاب‌ها در `TEXTBOOKS`: ریاضی/حسابان پیش از
هندسه…)؛ دانش‌آموزی که در اپ «هندسه» را انتخاب کرده باید `subject` را صریح بفرستد تا از
کتابِ خودش جواب بگیرد. فصل معلوم نیست و جستجو روی کلِ همان کتاب انجام می‌شود.
""" """
from .config import resolve_grade_title from .config import resolve_grade_title

View File

@@ -470,9 +470,131 @@ CALCULUS_12 = Textbook(
) )
# ── هندسه، آمار و احتمال، گسسته (۲۰۲۶-۱۰-۱۱؛ درخواستِ سینیور) ─────────────
# کتاب‌های رشته‌ی ریاضی‌فیزیک که کنارِ ریاضی/حسابانِ همان پایه می‌آیند، پس هر
# کدام subject و collectionِ خودش را دارد (همان دلیلِ «حسابان»).
# فهرست و آفست از خودِ PDFها خوانده شد (فهرست چشمی، آفست با تطبیقِ صفحه‌ی بازکننده‌ی
# فصل با متنِ همان صفحه‌ی PDF): هندسه‌ها و گسسته چاپی=PDF (۰) و (۸)؛ آمار صفحه‌ی ۱
# چاپی روی PDF ۷ است (آفست ۶، مثلِ حسابان). ⚠️ آخرین صفحه‌ی هر فصل «یکی قبل از فصل
# بعد» است و آخرین فصل تا قبل از «منابع»؛ مرزها با ingest و شمارشِ chunk وارسی شوند.
GEOMETRY_10 = Textbook(
subject='هندسه', grade='دهم', grade_label='دهم',
filename='هندسه دهم.pdf', page_offset=0,
chapters=_ch(
(1, 'فصل ۱: ترسیم‌های هندسی و استدلال', 9, 28, (
(1, 'ترسیم‌های هندسی', 10),
(2, 'استدلال', 17),
)),
(2, 'فصل ۲: قضیه‌ی تالس، تشابه و کاربردهای آن', 29, 52, (
(1, 'نسبت و تناسب در هندسه', 30),
(2, 'قضیه‌ی تالس', 34),
(3, 'تشابه مثلث‌ها', 38),
(4, 'کاربردهایی از قضیه‌ی تالس و تشابه مثلث‌ها', 45),
)),
(3, 'فصل ۳: چندضلعی‌ها', 53, 76, (
(1, 'چندضلعی‌ها و ویژگی‌هایی از آن‌ها', 54),
(2, 'مساحت و کاربردهای آن', 65),
)),
(4, 'فصل ۴: تجسم فضایی', 77, 96, (
(1, 'خط، نقطه و صفحه', 78),
(2, 'تفکر تجسمی', 87),
)),
),
)
GEOMETRY_11 = Textbook(
subject='هندسه', grade='یازدهم', grade_label='یازدهم',
filename='هندسه یازدهم.pdf', page_offset=0,
chapters=_ch(
(1, 'فصل ۱: دایره', 9, 30, (
(1, 'مفاهیم اولیه و زاویه‌ها در دایره', 10),
(2, 'رابطه‌های طولی در دایره', 18),
(3, 'چندضلعی‌های محاطی و محیطی', 24),
)),
(2, 'فصل ۲: تبدیل‌های هندسی و کاربردها', 31, 58, (
(1, 'تبدیل‌های هندسی', 32),
(2, 'کاربرد تبدیل‌ها', 50),
)),
(3, 'فصل ۳: روابط طولی در مثلث', 59, 80, (
(1, 'قضیه‌ی سینوس‌ها', 60),
(2, 'قضیه‌ی کسینوس‌ها', 64),
(3, 'قضیه‌ی نیمسازهای زوایای داخلی و محاسبه‌ی طول نیمسازها', 68),
(4, 'قضیه‌ی هرون (محاسبه‌ی ارتفاع‌ها و مساحت مثلث)', 71),
)),
),
)
GEOMETRY_12 = Textbook(
subject='هندسه', grade='دوازدهم', grade_label='دوازدهم',
filename='هندسه دوازدهم.pdf', page_offset=0,
chapters=_ch(
(1, 'فصل ۱: ماتریس و کاربردها', 9, 32, (
(1, 'ماتریس و اعمال روی ماتریس‌ها', 10),
(2, 'وارون ماتریس و دترمینان', 22),
)),
(2, 'فصل ۲: آشنایی با مقاطع مخروطی', 33, 60, (
(1, 'آشنایی با مقاطع مخروطی و مکان هندسی', 34),
(2, 'دایره', 40),
(3, 'بیضی و سهمی', 47),
)),
(3, 'فصل ۳: بردارها', 61, 86, (
(1, 'معرفی فضای R³', 62),
(2, 'ضرب داخلی و ضرب خارجی بردارها', 77),
)),
),
)
STATS_11 = Textbook(
subject='آمار و احتمال', grade='یازدهم', grade_label='یازدهم',
filename='آمار و احتمال یازدهم.pdf', page_offset=6,
chapters=_ch(
(1, 'فصل ۱: آشنایی با مبانی ریاضیات', 1, 34, (
(1, 'آشنایی با منطق ریاضی', 2),
(2, 'جبر مجموعه‌ها', 16),
)),
(2, 'فصل ۲: احتمال', 35, 68, (
(1, 'مبانی احتمال', 36),
(2, 'احتمال غیرهم‌شانس', 44),
(3, 'احتمال شرطی', 48),
(4, 'پیشامدهای مستقل و وابسته', 63),
)),
(3, 'فصل ۳: آمار توصیفی', 69, 96, (
(1, 'توصیف و نمایش داده‌ها', 70),
(2, 'معیارهای گرایش به مرکز', 80),
(3, 'معیارهای پراکندگی', 87),
)),
(4, 'فصل ۴: آمار استنباطی', 97, 121, (
(1, 'گردآوری داده‌ها', 98),
(2, 'برآورد', 112),
)),
),
)
DISCRETE_12 = Textbook(
subject='گسسته', grade='دوازدهم', grade_label='دوازدهم',
filename='گسسته دوازدهم.pdf', page_offset=8,
chapters=_ch(
(1, 'فصل ۱: آشنایی با نظریه‌ی اعداد', 1, 30, (
(1, 'استدلال ریاضی', 2),
(2, 'بخش‌پذیری در اعداد صحیح', 9),
(3, 'هم‌نهشتی در اعداد صحیح و کاربردها', 18),
)),
(2, 'فصل ۲: گراف و مدل‌سازی', 31, 54, (
(1, 'معرفی گراف', 32),
(2, 'مدل‌سازی با گراف', 43),
)),
(3, 'فصل ۳: ترکیبیات (شمارش)', 55, 84, (
(1, 'مباحثی در ترکیبیات', 56),
(2, 'روش‌هایی برای شمارش', 73),
)),
),
)
TEXTBOOKS: dict[str, Textbook] = { TEXTBOOKS: dict[str, Textbook] = {
b.key: b for b in (MATH_3, MATH_4, MATH_5, MATH_6, MATH_7, MATH_8, MATH_9, MATH_10, b.key: b for b in (MATH_3, MATH_4, MATH_5, MATH_6, MATH_7, MATH_8, MATH_9, MATH_10,
CALCULUS_11, CALCULUS_12) CALCULUS_11, CALCULUS_12, GEOMETRY_10, GEOMETRY_11, GEOMETRY_12,
STATS_11, DISCRETE_12)
} }
# کتاب‌هایی که واقعاً فایلِ PDF دارند و `rag_ingest_book` رویشان کار می‌کند. # کتاب‌هایی که واقعاً فایلِ PDF دارند و `rag_ingest_book` رویشان کار می‌کند.