diff --git a/content/curriculum_io.py b/content/curriculum_io.py index 8569f6f..746ddf1 100644 --- a/content/curriculum_io.py +++ b/content/curriculum_io.py @@ -95,6 +95,7 @@ def parse(raw: bytes) -> list[dict]: 'intro_text': (lesson.get('introText') or ''), 'example': str(lesson.get('example') or ''), 'steps': [str(x) for x in (lesson.get('steps') or []) if str(x).strip()][:12], + 'figures': lesson.get('figures') or [], } for li, lesson in enumerate(lessons) ], @@ -170,6 +171,9 @@ def import_curriculum(grades: list[dict]) -> dict: if lesson_row.get('steps') and not lesson.steps: lesson.steps = lesson_row['steps'] changed.append('steps') + if lesson_row.get('figures') and not lesson.figures: + lesson.figures = lesson_row['figures'] + changed.append('figures') if changed: lesson.save(update_fields=changed) if not created: @@ -200,6 +204,7 @@ def export_curriculum(grades_queryset) -> dict: 'title': lesson.title, 'introText': lesson.intro_text, **({'example': lesson.example} if lesson.example else {}), **({'steps': lesson.steps} if lesson.steps else {}), + **({'figures': lesson.figures} if lesson.figures else {}), } for lesson in node.lessons.all() ], diff --git a/content/management/commands/gen_lesson_figures.py b/content/management/commands/gen_lesson_figures.py new file mode 100644 index 0000000..071ed90 --- /dev/null +++ b/content/management/commands/gen_lesson_figures.py @@ -0,0 +1,93 @@ +""" +شکلِ ثابتِ درس‌ها را یک بار می‌سازد و در `Lesson.figures` می‌نویسد. + +همان مسیرِ ارزانِ دستیار: همان مدلِ چت، همان کاتالوگِ ، همان +اعتبارسنجِ `rag.services.diagram.validate`. فرقش این است که نتیجه ذخیره می‌شود و +بعد از تأییدِ چشمی با `seed_content --export` به curriculum.json می‌رود. +فقط درس‌هایی که شکل لازم دارند شکل می‌گیرند؛ مدل می‌تواند `[]` بدهد. +آزموده نشده: درستیِ هندسی/ریاضیِ شکل؛ باید با چشم دید. + + python manage.py gen_lesson_figures --grade 12 --limit 4 [--redo] [--dry-run] +""" +import json +import re + +from django.core.management.base import BaseCommand, CommandError + +from content.models import Lesson +from rag.services import clients, diagram as diagram_mod +from rag.services.generation import chat_model_for, completion_kwargs + +PROMPT = ( + 'تو برای یک درسِ ریاضی (اپِ آموزشی) شکل‌های آموزشی می‌سازی. متنِ درس: «توضیح»، «مثال» و ' + '«قدم‌ها» (شماره از صفر). فقط جاهایی شکل بگذار که دیدنش مفهوم را روشن می‌کند (نمودار، ' + 'انتقال، تقارن، محور، جدول، هندسه)؛ هر شکل باید دقیقاً همان چیزی را نشان دهد که آن بخش ' + 'می‌گوید، با همان اعداد. بخشِ بی‌نیاز شکل نمی‌گیرد؛ اگر هیچ‌جا لازم نبود []. حداکثر ۵ شکل.\n' + 'خروجی فقط یک آرایه‌ی JSON: [{"where":"intro"|"example"|"step","index":0,"diagram":{...}}] ' + '(index فقط برای step). بدونِ توضیح و بدونِ ```.\n' + 'مثال‌های کاتالوگ فقط قالب‌اند: نقطه، تابع یا آیتمی را که این درس لازم ندارد کپی نکن؛ فقط ' + 'چیزهایی بگذار که همین شکل نشان می‌دهد (برای نمودارِ تابع معمولاً فقط items با type=function). نمودارِ خط‌شکسته یا تکه‌ای از نقطه‌ها را ' + 'با type=segment بین نقطه‌های متوالی بکش، نه type=line (آن خطِ بی‌پایان است). نقطه‌هایی که ' + 'در items نمی‌آیند تعریف نکن.\n\n' +) + + +class Command(BaseCommand): + help = 'ساختِ شکلِ ثابتِ درس‌ها (یک بار، ذخیره در Lesson.figures).' + + def add_arguments(self, p): + p.add_argument('--grade', type=int, required=True) + p.add_argument('--limit', type=int, default=4) + p.add_argument('--lesson', type=int, help='فقط همین شناسه') + p.add_argument('--redo', action='store_true', help='درس‌هایی که شکل دارند را هم بساز') + p.add_argument('--dry-run', action='store_true') + + def handle(self, *a, **o): + qs = Lesson.objects.filter(node__unit__grade__number=o['grade']).order_by( + 'node__unit__order', 'node__order', 'id') + if o['lesson']: + qs = qs.filter(pk=o['lesson']) + if not o['redo']: + qs = qs.filter(figures=[]) + lessons = list(qs[:o['limit']]) + if not lessons: + raise CommandError('درسی پیدا نشد.') + model = chat_model_for('') + client = clients.get_openai_client() + spent = 0 + for lesson in lessons: + body = (f'عنوان: {lesson.title}\nتوضیح: {lesson.intro_text}\nمثال: {lesson.example}\n' + + '\n'.join(f'قدم {i}: {s}' for i, s in enumerate(lesson.steps or []))) + r = client.chat.completions.create( + model=model, **completion_kwargs(model), + messages=[{'role': 'system', 'content': PROMPT + diagram_mod.DIAGRAM_SPEC}, + {'role': 'user', 'content': body}]) + u = getattr(r, 'usage', None) + spent += (getattr(u, 'total_tokens', 0) or 0) + raw = re.sub(r'^```(?:json)?|```$', '', (r.choices[0].message.content or '').strip()).strip() + try: + start = raw.index('[') + items, _ = json.JSONDecoder().raw_decode(raw[start:]) + if not isinstance(items, list): + raise ValueError('not a list') + except ValueError as e: + self.stderr.write(f'درس {lesson.id}: خروجیِ نامعتبر ({e}); ذخیره نشد.') + continue + figures, dropped = [], 0 + for it in items: + where = it.get('where') if isinstance(it, dict) else None + spec = diagram_mod.validate(it.get('diagram')) if where else None + idx = it.get('index') + if (spec is None or where not in ('intro', 'example', 'step') + or (where == 'step' and not (isinstance(idx, int) and 0 <= idx < len(lesson.steps or []))) + or (where == 'example' and not lesson.example)): + dropped += 1 + continue + figures.append({'where': where, **({'index': idx} if where == 'step' else {}), 'diagram': spec}) + self.stdout.write(f'درس {lesson.id} «{lesson.title}»: {len(figures)} شکل، {dropped} ردشده') + for f in figures: + self.stdout.write(' ' + json.dumps(f, ensure_ascii=False)) + if figures and not o['dry_run']: + lesson.figures = figures + lesson.save(update_fields=['figures']) + self.stdout.write(f'توکنِ مصرفی: {spent}') diff --git a/content/migrations/0011_lesson_figures.py b/content/migrations/0011_lesson_figures.py new file mode 100644 index 0000000..d00407b --- /dev/null +++ b/content/migrations/0011_lesson_figures.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.17 on 2026-10-07 12:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('content', '0010_seedrecord'), + ] + + operations = [ + migrations.AddField( + model_name='lesson', + name='figures', + field=models.JSONField(blank=True, default=list), + ), + ] diff --git a/content/models.py b/content/models.py index abebe20..8699cb1 100644 --- a/content/models.py +++ b/content/models.py @@ -99,6 +99,11 @@ class Lesson(models.Model): intro_text = models.TextField() example = models.TextField(blank=True) steps = models.JSONField(default=list, blank=True) + # شکل‌های ثابتِ درس: [{"where": "intro"|"example"|"step", "index": n, "diagram": {...}}]. + # یک بار با `gen_lesson_figures` ساخته و با چشم تأیید می‌شود و بعد همین‌جا و در + # curriculum.json می‌ماند؛ هنگامِ نمایش هیچ فراخوانیِ مدل و هزینه‌ای نیست. + # `index` فقط برای where='step' است (از صفر). شکل با `rag.services.diagram.validate` گذشته است. + figures = models.JSONField(default=list, blank=True) # فقط برای kind='video' video_url = models.URLField(blank=True) diff --git a/content/seed/curriculum.json b/content/seed/curriculum.json index d439df7..9c8425e 100644 --- a/content/seed/curriculum.json +++ b/content/seed/curriculum.json @@ -20664,6 +20664,190 @@ "به طور مثال، نقطه (-4,0) ابتدا به (-5,0) منتقل می‌شود و سپس به (-5,-3) می‌رسد.", "همین کار برای سایر نقاط انجام می‌شود و نمودار نهایی به دست می‌آید.", "✅ جواب: نقاط جدید: (-5,-3), (-3,-2), (-1,-2), (2,-3)" + ], + "figures": [ + { + "where": "example", + "diagram": { + "kind": "plane", + "title": "نمودار تابع f", + "axes": true, + "points": { + "A": [ + -4.0, + 0.0 + ], + "B": [ + -2.0, + 1.0 + ], + "C": [ + 0.0, + 1.0 + ], + "D": [ + 3.0, + 0.0 + ] + }, + "items": [ + { + "type": "point", + "point": "A" + }, + { + "type": "point", + "point": "B" + }, + { + "type": "point", + "point": "C" + }, + { + "type": "point", + "point": "D" + }, + { + "type": "segment", + "from": "A", + "to": "B" + }, + { + "type": "segment", + "from": "B", + "to": "C" + }, + { + "type": "segment", + "from": "C", + "to": "D" + } + ], + "grid": true + } + }, + { + "where": "step", + "index": 0, + "diagram": { + "kind": "plane", + "title": "انتقال نمودار f به سمت چپ", + "axes": true, + "points": { + "A": [ + -5.0, + 0.0 + ], + "B": [ + -3.0, + 1.0 + ], + "C": [ + -1.0, + 1.0 + ], + "D": [ + 2.0, + 0.0 + ] + }, + "items": [ + { + "type": "point", + "point": "A" + }, + { + "type": "point", + "point": "B" + }, + { + "type": "point", + "point": "C" + }, + { + "type": "point", + "point": "D" + }, + { + "type": "segment", + "from": "A", + "to": "B" + }, + { + "type": "segment", + "from": "B", + "to": "C" + }, + { + "type": "segment", + "from": "C", + "to": "D" + } + ], + "grid": true + } + }, + { + "where": "step", + "index": 1, + "diagram": { + "kind": "plane", + "title": "انتقال نمودار به سمت پایین", + "axes": true, + "points": { + "A": [ + -5.0, + -3.0 + ], + "B": [ + -3.0, + -2.0 + ], + "C": [ + -1.0, + -2.0 + ], + "D": [ + 2.0, + -3.0 + ] + }, + "items": [ + { + "type": "point", + "point": "A" + }, + { + "type": "point", + "point": "B" + }, + { + "type": "point", + "point": "C" + }, + { + "type": "point", + "point": "D" + }, + { + "type": "segment", + "from": "A", + "to": "B" + }, + { + "type": "segment", + "from": "B", + "to": "C" + }, + { + "type": "segment", + "from": "C", + "to": "D" + } + ], + "grid": true + } + } ] }, { @@ -20681,11 +20865,79 @@ }, { "title": "انتقال عمودی نمودار تابع", - "introText": "برای انتقال عمودی نمودار تابع، اگر تابع اصلی y = f(x) باشد، تابع جدید را به صورت g(x) = f(x) + k تعریف می‌کنیم. این انتقال باعث می‌شود که هر نقطه (x, y) روی نمودار تابع اصلی به نقطه (x, y+k) روی نمودار تابع جدید منتقل شود. بنابراین نمودار تابع به اندازه k واحد به سمت بالا (اگر k مثبت باشد) یا به سمت پایین (اگر k منفی باشد) جابجا می‌شود." + "introText": "برای انتقال عمودی نمودار تابع، اگر تابع اصلی y = f(x) باشد، تابع جدید را به صورت g(x) = f(x) + k تعریف می‌کنیم. این انتقال باعث می‌شود که هر نقطه (x, y) روی نمودار تابع اصلی به نقطه (x, y+k) روی نمودار تابع جدید منتقل شود. بنابراین نمودار تابع به اندازه k واحد به سمت بالا (اگر k مثبت باشد) یا به سمت پایین (اگر k منفی باشد) جابجا می‌شود.", + "figures": [ + { + "where": "intro", + "diagram": { + "kind": "plane", + "title": "انتقال عمودی نمودار تابع", + "axes": true, + "points": {}, + "items": [ + { + "type": "function", + "expr": "x^2", + "from": -2.0, + "to": 2.0, + "marks": [ + "vertex" + ], + "label": "y = f(x)" + }, + { + "type": "function", + "expr": "x^2 + 2", + "from": -2.0, + "to": 2.0, + "marks": [ + "vertex" + ], + "label": "g(x) = f(x) + 2" + } + ], + "grid": true + } + } + ] }, { "title": "انتقال افقی نمودار تابع", - "introText": "برای انتقال افقی نمودار تابع، اگر تابع اصلی y = f(x) باشد، تابع جدید را به صورت g(x) = f(x + c) تعریف می‌کنیم. این انتقال باعث می‌شود که هر نقطه (x, y) روی نمودار تابع اصلی به نقطه (x - c, y) روی نمودار تابع جدید منتقل شود. بنابراین نمودار تابع به اندازه c واحد به سمت چپ (اگر c مثبت باشد) یا به سمت راست (اگر c منفی باشد) جابجا می‌شود." + "introText": "برای انتقال افقی نمودار تابع، اگر تابع اصلی y = f(x) باشد، تابع جدید را به صورت g(x) = f(x + c) تعریف می‌کنیم. این انتقال باعث می‌شود که هر نقطه (x, y) روی نمودار تابع اصلی به نقطه (x - c, y) روی نمودار تابع جدید منتقل شود. بنابراین نمودار تابع به اندازه c واحد به سمت چپ (اگر c مثبت باشد) یا به سمت راست (اگر c منفی باشد) جابجا می‌شود.", + "figures": [ + { + "where": "intro", + "diagram": { + "kind": "plane", + "title": "انتقال افقی تابع", + "axes": true, + "points": {}, + "items": [ + { + "type": "function", + "expr": "x^2", + "from": -3.0, + "to": 3.0, + "marks": [ + "vertex" + ], + "label": "y = f(x)" + }, + { + "type": "function", + "expr": "(x-2)^2", + "from": -3.0, + "to": 3.0, + "marks": [ + "vertex" + ], + "label": "y = g(x)" + } + ], + "grid": true + } + } + ] }, { "title": "انتقال نمودار تابع به سمت بالا یا پایین", diff --git a/content/seed_sync.py b/content/seed_sync.py index 414343e..e6e7352 100644 --- a/content/seed_sync.py +++ b/content/seed_sync.py @@ -126,7 +126,7 @@ def _sync_lesson(node, path, row, ledger, counts): return lesson = Lesson.objects.create( node=node, title=row['title'], intro_text=row['intro_text'], - example=row['example'], steps=row['steps'], + example=row['example'], steps=row['steps'], figures=row.get('figures') or [], ) counts['lessons'] += 1 ledger.record(_key('lesson', *path), 'lesson') @@ -154,6 +154,9 @@ def _sync_lesson(node, path, row, ledger, counts): if row.get('steps') and not lesson.steps: lesson.steps = row['steps'] changed.append('steps') + if row.get('figures') and not lesson.figures: + lesson.figures = row['figures'] + changed.append('figures') if changed: lesson.save(update_fields=changed) counts['updated'] += 1 diff --git a/content/serializers.py b/content/serializers.py index 4778e7a..d7dfd94 100644 --- a/content/serializers.py +++ b/content/serializers.py @@ -82,6 +82,8 @@ class LessonIntroResponseSerializer(serializers.Serializer): # متنِ درس سه‌تکه است: توضیح (همان introText)، مثال، و حلِ گام‌به‌گام. example = serializers.CharField(allow_blank=True) steps = serializers.ListField(child=serializers.CharField()) + # شکل‌های ثابتِ درس؛ فرانت با `diagram` همان رندرِ دستیار را می‌کشد. + figures = serializers.ListField(child=serializers.DictField(), default=list) # فقط برای kind='video' videoUrl = serializers.CharField(allow_blank=True) durationMinutes = serializers.IntegerField(allow_null=True) diff --git a/content/tests_lesson_figures.py b/content/tests_lesson_figures.py new file mode 100644 index 0000000..9c4b018 --- /dev/null +++ b/content/tests_lesson_figures.py @@ -0,0 +1,17 @@ +from django.test import TestCase +from rest_framework.test import APIClient + +from content.models import Grade, Lesson, Node, Unit + + +class LessonFiguresTests(TestCase): + def test_intro_returns_stored_figures_and_empty_list_by_default(self): + grade = Grade.objects.create(title='دوازدهم', number=12) + unit = Unit.objects.create(grade=grade, title='u', order=1) + node = Node.objects.create(unit=unit, title='n', order=1) + fig = {'where': 'step', 'index': 0, 'diagram': {'kind': 'plane', 'title': 't'}} + with_fig = Lesson.objects.create(node=node, title='a', intro_text='x', steps=['s'], figures=[fig]) + plain = Lesson.objects.create(node=node, title='b', intro_text='x') + c = APIClient() + self.assertEqual(c.get(f'/api/lessons/{with_fig.id}/intro').json()['figures'], [fig]) + self.assertEqual(c.get(f'/api/lessons/{plain.id}/intro').json()['figures'], []) diff --git a/content/views.py b/content/views.py index 9ff1119..ad36c7a 100644 --- a/content/views.py +++ b/content/views.py @@ -441,6 +441,7 @@ class LessonIntroView(APIView): 'introText': lesson.intro_text, 'example': lesson.example, 'steps': lesson.steps or [], + 'figures': lesson.figures or [], 'videoUrl': lesson.video_url, 'durationMinutes': lesson.duration_minutes, 'mascotAnimation': lesson.mascot_animation,