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

@@ -84,3 +84,48 @@ class AskWithoutLessonUsesTheStudentsBookTests(TestCase):
self.assertEqual(response.status_code, 200)
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 نمی‌آید.
grade_id = v.get('gradeId') or conversation.grade_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()
lesson_number = v.get('lessonNumber')
if lesson_number is None:
if lesson_number is None and not explicit_lesson:
lesson_number = conversation.lesson_number
# اگر اپ فقط lessonId (یا فقط conversationId) داده — حالتِ طبیعی، چون
@@ -436,6 +440,14 @@ class AskAIView(APIView):
conversation.lesson_number = 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 — حالتِ عادیِ صفحه‌ی «از پی بپرس» که از نوارِ
# پایین باز می‌شود. پایه‌ی ذخیره‌شده‌ی خودِ کاربر کتاب را معلوم می‌کند؛
# بدونِ این، جواب‌های آن صفحه هیچ‌وقت به کتاب مستند نمی‌شدند.