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:
18
CLAUDE.md
18
CLAUDE.md
@@ -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). بکاپِ تغییرهای یکبارهی
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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 — حالتِ عادیِ صفحهی «از پی بپرس» که از نوارِ
|
||||||
# پایین باز میشود. پایهی ذخیرهشدهی خودِ کاربر کتاب را معلوم میکند؛
|
# پایین باز میشود. پایهی ذخیرهشدهی خودِ کاربر کتاب را معلوم میکند؛
|
||||||
# بدونِ این، جوابهای آن صفحه هیچوقت به کتاب مستند نمیشدند.
|
# بدونِ این، جوابهای آن صفحه هیچوقت به کتاب مستند نمیشدند.
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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 {}),
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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')
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
36
content/management/commands/split_long_nodes.py
Normal file
36
content/management/commands/split_long_nodes.py
Normal 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}")
|
||||||
23
content/migrations/0014_subject.py
Normal file
23
content/migrations/0014_subject.py
Normal 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']},
|
||||||
|
),
|
||||||
|
]
|
||||||
24
content/migrations/0015_seed_subjects.py
Normal file
24
content/migrations/0015_seed_subjects.py
Normal 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)]
|
||||||
20
content/migrations/0016_unit_subject.py
Normal file
20
content/migrations/0016_unit_subject.py
Normal 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',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
19
content/migrations/0017_calculus_units.py
Normal file
19
content/migrations/0017_calculus_units.py
Normal 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)]
|
||||||
25
content/migrations/0018_split_long_calculus_nodes.py
Normal file
25
content/migrations/0018_split_long_calculus_nodes.py
Normal 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)]
|
||||||
@@ -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
113
content/node_split.py
Normal 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
|
||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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': 'پایهها برابرند.'}],
|
||||||
|
|||||||
@@ -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
159
content/tests_subjects.py
Normal 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)
|
||||||
@@ -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 برابریِ این دو را نگه میدارد.
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
| سوم، ششم، هفتم | روی Qdrant هست (ساختهی تیم دیگر) — PDF نداریم جز هفتم |
|
| سوم، ششم، هفتم | روی Qdrant هست (ساختهی تیم دیگر) — PDF نداریم جز هفتم |
|
||||||
| هشتم، نهم، دهم | ingest شده؛ بازخوانیِ صفحههای شکلدار مانده |
|
| هشتم، نهم، دهم | ingest شده؛ بازخوانیِ صفحههای شکلدار مانده |
|
||||||
| یازدهم، دوازدهم | در نوبتِ ingest (۱۴۰۵/۰۶/۲۸) |
|
| یازدهم، دوازدهم | در نوبتِ ingest (۱۴۰۵/۰۶/۲۸) |
|
||||||
|
| هندسه دهم/یازدهم/دوازدهم، آمار و احتمال یازدهم، گسسته دوازدهم | ثبت و ingest شد ۲۰۲۶-۱۰-۱۱ (`subject` = «هندسه»، «آمار و احتمال»، «گسسته»؛ هر کدام collectionِ خودش) |
|
||||||
| چهارم، پنجم | ingest شد ۲۰۲۶-۱۰-۰۳ (درخواستِ دکتر: «از سوم به بعد»)؛ پنجم چاپِ ۱۴۰۵ است |
|
| چهارم، پنجم | ingest شد ۲۰۲۶-۱۰-۰۳ (درخواستِ دکتر: «از سوم به بعد»)؛ پنجم چاپِ ۱۴۰۵ است |
|
||||||
| اول، دوم | PDF موجود است، خارج از اولویتِ فعلی |
|
| اول، دوم | PDF موجود است، خارج از اولویتِ فعلی |
|
||||||
|
|
||||||
|
|||||||
@@ -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'):
|
||||||
|
|||||||
@@ -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) — کلیدِ داخلی هم شمارهی فصل و هم
|
||||||
# رشتهی کامل رو میپذیره.
|
# رشتهی کامل رو میپذیره.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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` رویشان کار میکند.
|
||||||
|
|||||||
Reference in New Issue
Block a user