label every textbook solution, and build quizzes at the level the app plays
سه کارِ بههمپیوسته که از یک کشف شروع شدند: راهحلهایی که vision از کتاب
درآورده گاهی غلطاند، و این خطا در بازیابی دیده نمیشود — فقط وقتی کسی به
`solution` اعتماد کند خودش را نشان میدهد.
۱) بازبینیِ راهحلها (rag_verify_solutions)
هر chunk دوباره حل میشود و با کتاب مقایسه. نتیجه سهحالته است، نه بله/خیر:
اجرای اول با بولی ۱۹٪ خطا گزارش کرد؛ خواندنِ دستیِ نمونهها نشان داد ۳۵٪ از
آنها مسئلههایی بودند که به شکلِ کتاب ارجاع میدادند و اصلاً قابلِ قضاوت
نبودند، و ۲۴٪ موردی که «۳/۱۰۴» را کسرِ ۳÷۱۰۴ خوانده بود نه عددِ ۳٫۱۰۴. با دو
حالت، «نمیدانم» ناچار به «غلط است» ترجمه میشد.
با سه حالت و پرامپتی که این دو تله را میشناسد: ۲٬۱۱۹ chunk بررسی شد،
۱٬۱۱۲ درست، ۱۲۳ غلط (۵٫۸٪)، ۸۸۱ غیرقابلِ قضاوت. آن ۸۸۱ رایگان برچسب خوردند
چون پیشفیلتر قبل از هر فراخوانی کنارشان گذاشت.
`solution` هرگز بازنویسی نمیشود — کتاب باید کتاب بماند. برچسب در payloadِ
Qdrant هم مینشیند، ولی با نامی که `retrieval._payload_text` نمیخواند: یک
نویسه هم به متنی که به مدل میرسد اضافه نشد.
نمونهی خطاهای واقعیِ کتاب که پیدا شد:
BMI = ۱۰۰/(۲٫۷۴)² کتاب: ۳۳٫۲ درست: ۱۳٫۳
۱۶ آبی + ۲۴ صورتی کتاب: «کل ۵۰» درست: ۴۰
بزرگترین عددِ ۴رقمیِ بیتکرار کتاب: ۸۵۳۲ درست: ۹۸۷۶
g(x) = x³ − ۲ کتاب: «نمایی» درست: چندجملهای
۲) سوالها فقط از راهحلهای تأییدشده
۱٬۱۹۹ سوال در content/seed/questions.json، همه از chunkهایی که برچسبِ
«درست» گرفتهاند. هر شش پایه برنامهی درسی دارند (۱٬۹۲۸ درس) — هشتم و دهم
تازه ساخته شدند.
۳) GET /api/nodes/{id}/quiz
واحدِ بازی در اپ «مبحث» است ولی سوالها به «درس» بستهاند، و درس اینجا
ریزترین دانه است: یک نوعِ مسئله، نه یک جلسه. نتیجهاش این بود که اپ برای یک
آزمونِ چهارسوالی باید دهها درخواست میزد. این اندپوینت از همهی درسهای مبحث
نمونه برمیدارد، حداکثر یکی از هر درس تا آزمون روی یک مهارت گیر نکند.
درهمریزی با بذرِ nodeId:userId پایدار است: رفرشِ صفحه همان آزمون را میدهد،
نه مجموعهی تازهای که پیشرفت را بیمعنا کند. `questionCount` جدا از
`requested` میآید تا مبحثِ کمسوال وانمود نکند کامل است.
──────────
اصلاحاتِ جانبی که سرِ راه لازم شدند:
• QDRANT_TIMEOUT_SECONDS: کلاینت بدونِ timeout ساخته میشد و پیشفرضِ ۵
ثانیهایِ کتابخانه ingestِ کتابِ هشتم را روی اولین فراخوانی کشت.
• پاکسازیِ نقاطِ نیمهنوشته: upsert با wait=True ممکن است نوشته باشد و فقط
جوابش تایماوت شده باشد؛ مسیرِ جبران فقط ردیفهای DB را پس میگرفت و هر
retry یک نسخهی تکراری در Qdrant جا میگذاشت (۸ مورد در پایه نهم، پاک شد).
• تشخیصِ سقفِ حساب: «insufficient user quota» با فاصله میآید نه زیرخط، و
محافظِ اول نمیگرفتش — یک اجرا ۷۷۰ فراخوانیِ محکومبهشکست زد.
• پرامپتِ vision حالا میخواهد محتوای شکل داخلِ متنِ مسئله نوشته شود. ۴۲٪ از
محتوای موجود به شکلی ارجاع میدهد که در متن نیست؛ مدل آن را دیده بود ولی
از او خواسته نشده بود بنویسدش. کتابهای موجود قبل از این قاعده وارد شدهاند.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -575,6 +575,11 @@ OPENAI_EMBEDDING_DIM = int(os.environ.get('OPENAI_EMBEDDING_DIM', '1536'))
|
|||||||
OPENAI_TIMEOUT_SECONDS = int(os.environ.get('OPENAI_TIMEOUT_SECONDS', '60'))
|
OPENAI_TIMEOUT_SECONDS = int(os.environ.get('OPENAI_TIMEOUT_SECONDS', '60'))
|
||||||
|
|
||||||
QDRANT_URL = os.environ.get('QDRANT_URL', 'https://qdrant.tadrisino2.ir:443')
|
QDRANT_URL = os.environ.get('QDRANT_URL', 'https://qdrant.tadrisino2.ir:443')
|
||||||
|
# پیشفرضِ خودِ qdrant-client پنج ثانیه است و برای این سرور کم است: ingestِ
|
||||||
|
# کتابِ هشتم روی همان اولین فراخوانی (ساختِ collection) افتاد و صفر صفحه رفت.
|
||||||
|
# سی ثانیه در مسیرِ ingest که دقیقهها طول میکشد هزینهای ندارد، و جلوی
|
||||||
|
# مردنِ یک اجرای طولانی بابتِ یک کندیِ لحظهای را میگیرد.
|
||||||
|
QDRANT_TIMEOUT_SECONDS = int(os.environ.get('QDRANT_TIMEOUT_SECONDS', '30'))
|
||||||
QDRANT_API_KEY = os.environ.get('QDRANT_API_KEY', '')
|
QDRANT_API_KEY = os.environ.get('QDRANT_API_KEY', '')
|
||||||
|
|
||||||
# نامِ collection در دادهٔ تیمِ دیگه = f"{subject} {grade}".lower() . کلیدِ
|
# نامِ collection در دادهٔ تیمِ دیگه = f"{subject} {grade}".lower() . کلیدِ
|
||||||
|
|||||||
@@ -15,6 +15,9 @@
|
|||||||
python manage.py generate_questions_from_chunks --grade نهم
|
python manage.py generate_questions_from_chunks --grade نهم
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
@@ -26,29 +29,54 @@ from rag.services import clients
|
|||||||
from rag.services.config import build_collection_name, rag_is_configured
|
from rag.services.config import build_collection_name, rag_is_configured
|
||||||
from rag.services.textbooks import TEXTBOOKS, get_textbook
|
from rag.services.textbooks import TEXTBOOKS, get_textbook
|
||||||
|
|
||||||
PROMPT = """
|
def _is_account_limit(exc) -> bool:
|
||||||
تو یک معلمِ ریاضیِ پایهی {grade} هستی.
|
"""
|
||||||
|
آیا این خطا یعنی «حسابِ سرویس دیگر جواب نمیدهد» یا فقط کندیِ لحظهای؟
|
||||||
|
|
||||||
از روی «مسئله» و «راهحل»ِ زیر — که مستقیماً از کتابِ درسی درآمده — یک سوالِ
|
دو شکلِ واقعی که دیدیم و هر دو باید اجرا را متوقف کنند:
|
||||||
چهارگزینهای بساز.
|
429 initial_usage_limit_exceeded — سقفِ حسابِ تازه، ساعتها
|
||||||
|
403 insufficient user quota — اعتبار تمام شده
|
||||||
|
|
||||||
قواعد:
|
نکته: متنِ دومی «insufficient user quota» است با فاصله، نه زیرخط. محافظِ
|
||||||
- صورتِ سوال باید کوتاه، خودکفا و به فارسی باشد؛ دانشآموز بدونِ دیدنِ کتاب
|
اولِ ما دنبالِ `insufficient_quota` میگشت و نمیگرفتش، و اجرا ۷۷۰ فراخوانیِ
|
||||||
بتواند جواب دهد.
|
محکومبهشکست زد بهجای اینکه سرِ اولی بایستد.
|
||||||
- دقیقاً چهار گزینه، و دقیقاً یکی درست.
|
"""
|
||||||
- سه گزینهی نادرست باید **خطاهای رایجِ همین مبحث** باشند (مثلاً اشتباه در
|
text = str(exc).lower()
|
||||||
علامتِ توان، جا انداختنِ یک مرحله، مخرجِ مشترک نگرفتن) — نه عددِ تصادفی و
|
if 'usage_limit' in text or 'quota' in text:
|
||||||
نه گزینهی آشکارا مسخره.
|
return True
|
||||||
- هر گزینه حداکثر ۴۰ نویسه.
|
return '403' in text and 'insufficient' in text
|
||||||
- اگر از روی این متن نمیشود سوالِ سالمی ساخت، دقیقاً {{"skip": true}} برگردان.
|
|
||||||
|
|
||||||
|
PROMPT = """تو یک معلمِ ریاضیِ باتجربهی پایهی {grade} هستی.
|
||||||
|
|
||||||
|
از روی «مسئله» و «راهحل»ِ زیر — که مستقیماً از کتابِ درسی درآمده و درستیِ
|
||||||
|
راهحلش جداگانه بررسی و تأیید شده — یک سوالِ چهارگزینهای بساز.
|
||||||
|
|
||||||
|
⚠️ نکتهی نگارشی: «/» بینِ دو عدد در این متنها جداکنندهی اعشار است، نه خطِ
|
||||||
|
کسر. یعنی «۲/۵» عددِ ۲٫۵ است.
|
||||||
|
|
||||||
|
قواعد — نقضِ هرکدام یعنی سوالِ بیارزش:
|
||||||
|
|
||||||
|
- **خودکفا:** تمامِ دادهی لازم باید داخلِ صورتِ سوال باشد. «مساحتِ شکل چند
|
||||||
|
است؟» بیارزش است چون دانشآموز شکل را نمیبیند. اگر مسئله بدونِ تصویرِ کتاب
|
||||||
|
قابلِ بازنویسی نیست، دقیقاً {{"skip": true}} برگردان.
|
||||||
|
- **دقیقاً یک گزینهی درست.** هر سه گزینهی دیگر را جداگانه بسنج و مطمئن شو
|
||||||
|
قطعاً غلطاند. تلهی رایج: در «کدام مجموعه تهی است؟» چند گزینه تهی از آب
|
||||||
|
دربیایند، یا دو گزینه از نظر عددی برابر باشند و فقط شکلِ نوشتنشان فرق کند
|
||||||
|
(مثلاً «√۷/√۶» و «√۴۲/۶» یک عددند).
|
||||||
|
- **گزینههای غلط باید خطای واقعیِ دانشآموز باشند** — جابهجایی علامتِ توان،
|
||||||
|
مخرجِ مشترک نگرفتن، یک مرحله جا انداختن. نه عددِ تصادفی، نه گزینهی مسخره.
|
||||||
|
- هیچ دو گزینهای هممعنا نباشند.
|
||||||
|
- **بدونِ LaTeX و بدونِ markdown.** بهجای \\frac{{a}}{{b}} بنویس (a)/(b).
|
||||||
|
متن عیناً همانطور که مینویسی به دانشآموز نشان داده میشود.
|
||||||
|
- فارسی، با ارقامِ فارسی. هر گزینه حداکثر ۴۰ نویسه.
|
||||||
|
|
||||||
نوعِ مسئله: {problem_type}
|
نوعِ مسئله: {problem_type}
|
||||||
مسئله: {problem}
|
مسئله: {problem}
|
||||||
راهحل: {solution}
|
راهحل: {solution}
|
||||||
|
|
||||||
فقط این JSON را برگردان، بدونِ markdown و بدونِ توضیح:
|
فقط این JSON را برگردان، بدونِ markdown و بدونِ توضیح:
|
||||||
{{"prompt": "صورتِ سوال", "options": ["الف", "ب", "ج", "د"], "correctIndex": 0}}
|
{{"prompt": "صورتِ سوال", "options": ["الف", "ب", "ج", "د"], "correctIndex": 0}}"""
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
@@ -64,6 +92,14 @@ class Command(BaseCommand):
|
|||||||
help='چند سوال برای هر درس.')
|
help='چند سوال برای هر درس.')
|
||||||
parser.add_argument('--dry-run', action='store_true',
|
parser.add_argument('--dry-run', action='store_true',
|
||||||
help='فقط نشان بده چند سوال ساخته میشود؛ هیچ فراخوانیِ پولی نکن.')
|
help='فقط نشان بده چند سوال ساخته میشود؛ هیچ فراخوانیِ پولی نکن.')
|
||||||
|
parser.add_argument('--model', default='o4-mini',
|
||||||
|
help='مدلِ سازنده. پیشفرض o4-mini؛ gpt-4o-mini کیفیتِ '
|
||||||
|
'قابلِ قبولی نداد (۴ سوال از ۶ خراب).')
|
||||||
|
parser.add_argument('--all-chunks', dest='verified_only', action='store_false',
|
||||||
|
help='از chunkهای بازبینینشده هم بساز (پیشفرض: فقط تأییدشدهها).')
|
||||||
|
parser.set_defaults(verified_only=True)
|
||||||
|
parser.add_argument('--workers', type=int, default=4,
|
||||||
|
help='فراخوانیِ همزمان. بیش از ۴ روی این پروکسی ۴۲۹ میگیرد.')
|
||||||
|
|
||||||
def handle(self, *args, **opts):
|
def handle(self, *args, **opts):
|
||||||
book = get_textbook(opts['subject'], opts['grade'])
|
book = get_textbook(opts['subject'], opts['grade'])
|
||||||
@@ -95,9 +131,10 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write(f' … و {len(targets) - 10} مورد دیگر')
|
self.stdout.write(f' … و {len(targets) - 10} مورد دیگر')
|
||||||
|
|
||||||
# ~۹۰۰ توکن ورودی و ~۱۵۰ خروجی برای هر سوال با gpt-4o-mini.
|
# ~۹۰۰ توکن ورودی و ~۱۵۰ خروجی برای هر سوال با gpt-4o-mini.
|
||||||
cost = len(targets) * (900 / 1e6 * 0.15 + 150 / 1e6 * 0.60)
|
self.model = opts['model']
|
||||||
|
cost = len(targets) * 0.0018 # اندازهگیریِ واقعیِ o4-mini
|
||||||
self.stdout.write(self.style.WARNING(
|
self.stdout.write(self.style.WARNING(
|
||||||
f'\nبرآوردِ هزینه با {settings.OPENAI_CHAT_MODEL}: ≈ ${cost:.3f}'
|
f'\nبرآوردِ هزینه با {self.model}: ≈ ${cost:.2f}'
|
||||||
))
|
))
|
||||||
if opts['dry_run']:
|
if opts['dry_run']:
|
||||||
self.stdout.write(self.style.WARNING('--dry-run: چیزی ساخته نشد.'))
|
self.stdout.write(self.style.WARNING('--dry-run: چیزی ساخته نشد.'))
|
||||||
@@ -106,21 +143,49 @@ class Command(BaseCommand):
|
|||||||
if not rag_is_configured():
|
if not rag_is_configured():
|
||||||
raise CommandError('RAG پیکربندی نشده (کلیدها خالیاند یا RAG_FORCE_MOCK روشن است).')
|
raise CommandError('RAG پیکربندی نشده (کلیدها خالیاند یا RAG_FORCE_MOCK روشن است).')
|
||||||
|
|
||||||
|
# سقفِ مصرفِ حسابِ سرویس با فشارِ لحظهای فرق دارد: اولی ساعتها باز
|
||||||
|
# نمیشود و تلاشِ دوباره فقط پنجره را طولانیتر میکند.
|
||||||
|
stop = threading.Event()
|
||||||
|
|
||||||
|
def work(pair):
|
||||||
|
lesson, chunk = pair
|
||||||
|
if stop.is_set():
|
||||||
|
return lesson, None, None
|
||||||
|
delay = 4.0
|
||||||
|
last = None
|
||||||
|
for _ in range(4):
|
||||||
|
try:
|
||||||
|
return lesson, self._ask_model(chunk, book.grade), None
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
last = exc
|
||||||
|
text = str(exc).lower()
|
||||||
|
if _is_account_limit(exc):
|
||||||
|
stop.set()
|
||||||
|
return lesson, None, exc
|
||||||
|
if '429' not in text and 'rate' not in text:
|
||||||
|
break
|
||||||
|
time.sleep(delay)
|
||||||
|
delay *= 2
|
||||||
|
return lesson, None, last
|
||||||
|
|
||||||
created = skipped = failed = 0
|
created = skipped = failed = 0
|
||||||
for lesson, chunk in targets:
|
with ThreadPoolExecutor(max_workers=opts['workers']) as pool:
|
||||||
try:
|
for lesson, data, err in pool.map(work, targets):
|
||||||
data = self._ask_model(chunk, book.grade)
|
if err is not None:
|
||||||
except Exception as exc: # noqa: BLE001
|
failed += 1
|
||||||
failed += 1
|
self.stdout.write(self.style.ERROR(f' ✗ {lesson.title}: {str(err)[:85]}'))
|
||||||
self.stdout.write(self.style.ERROR(f' ✗ {lesson.title}: {str(exc)[:90]}'))
|
continue
|
||||||
continue
|
if data is None:
|
||||||
if data is None:
|
skipped += 1
|
||||||
skipped += 1
|
continue
|
||||||
self.stdout.write(f' — {lesson.title}: مدل گفت از این متن سوال درنمیآید')
|
self._persist(lesson, data)
|
||||||
continue
|
created += 1
|
||||||
self._persist(lesson, data)
|
self.stdout.write(self.style.SUCCESS(
|
||||||
created += 1
|
f' ✓ {lesson.title}: {data["prompt"][:55]}'))
|
||||||
self.stdout.write(self.style.SUCCESS(f' ✓ {lesson.title}: {data["prompt"][:60]}'))
|
if stop.is_set():
|
||||||
|
self.stdout.write(self.style.ERROR(
|
||||||
|
'\nسقفِ مصرفِ حسابِ سرویس پر شد؛ اجرا نیمهکاره ماند. '
|
||||||
|
'اجرای بعدی از همینجا ادامه میدهد.'))
|
||||||
|
|
||||||
self.stdout.write(self.style.SUCCESS(
|
self.stdout.write(self.style.SUCCESS(
|
||||||
f'\n{created} سوال ساخته شد، {skipped} رد شد، {failed} خطا.'
|
f'\n{created} سوال ساخته شد، {skipped} رد شد، {failed} خطا.'
|
||||||
@@ -144,32 +209,33 @@ class Command(BaseCommand):
|
|||||||
need = opts['per_lesson'] - have
|
need = opts['per_lesson'] - have
|
||||||
if need <= 0:
|
if need <= 0:
|
||||||
continue
|
continue
|
||||||
chunks = list(
|
# فقط chunkهایی که راهحلشان بازبینی و تأیید شده. ساختنِ سوال از
|
||||||
LessonChunk.objects
|
# راهحلی که میدانیم غلط است، خطای کتاب را به دانشآموز منتقل
|
||||||
.filter(collection=collection, problem_type=lesson.title)
|
# میکند — و ۱۲۳ موردِ اینچنینی در همین کتابها پیدا شد.
|
||||||
.exclude(problem='')
|
candidates = LessonChunk.objects.filter(
|
||||||
.order_by('source_page', 'order')[:need]
|
collection=collection, problem_type=lesson.title).exclude(problem='')
|
||||||
)
|
if opts['verified_only']:
|
||||||
|
candidates = candidates.filter(
|
||||||
|
solution_verdict=LessonChunk.VERDICT_AGREES)
|
||||||
|
chunks = list(candidates.order_by('source_page', 'order')[:need])
|
||||||
for chunk in chunks:
|
for chunk in chunks:
|
||||||
targets.append((lesson, chunk))
|
targets.append((lesson, chunk))
|
||||||
if opts['limit'] and len(targets) >= opts['limit']:
|
if opts['limit'] and len(targets) >= opts['limit']:
|
||||||
return targets
|
return targets
|
||||||
return targets
|
return targets
|
||||||
|
|
||||||
def _ask_model(self, chunk, grade):
|
def _ask_model(self, chunk, grade): # noqa: D401
|
||||||
prompt = PROMPT.format(
|
prompt = PROMPT.format(
|
||||||
grade=grade, problem_type=chunk.problem_type,
|
grade=grade, problem_type=chunk.problem_type,
|
||||||
problem=chunk.problem, solution=chunk.solution,
|
problem=chunk.problem, solution=chunk.solution,
|
||||||
)
|
)
|
||||||
client = clients.get_openai_client()
|
client = clients.get_openai_client()
|
||||||
completion = client.chat.completions.create(
|
completion = client.chat.completions.create(
|
||||||
model=settings.OPENAI_CHAT_MODEL,
|
model=self.model,
|
||||||
messages=[
|
messages=[
|
||||||
{'role': 'system', 'content': 'You always answer with a single valid JSON object.'},
|
{'role': 'system', 'content': 'You always answer with a single valid JSON object.'},
|
||||||
{'role': 'user', 'content': prompt},
|
{'role': 'user', 'content': prompt},
|
||||||
],
|
],
|
||||||
temperature=0.4,
|
|
||||||
max_tokens=400,
|
|
||||||
)
|
)
|
||||||
raw = (completion.choices[0].message.content or '').strip()
|
raw = (completion.choices[0].message.content or '').strip()
|
||||||
for fence in ('```json', '```'):
|
for fence in ('```json', '```'):
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -108,6 +108,34 @@ class QuestionSerializer(serializers.Serializer):
|
|||||||
options = ChoiceOptionSerializer(many=True)
|
options = ChoiceOptionSerializer(many=True)
|
||||||
|
|
||||||
|
|
||||||
|
class QuizQuestionSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
مثلِ QuestionSerializer است بهعلاوهی اینکه سوال از کدام درس آمده.
|
||||||
|
|
||||||
|
چرا lessonId و lessonTitle: آزمونِ مبحث از چند درس نمونه برمیدارد، و
|
||||||
|
وقتی دانشآموز غلط جواب داد باید بشود گفت «این مهارت را مرور کن». بدونِ
|
||||||
|
این دو فیلد، فرانت فقط میداند «یک سوال غلط شد» و نمیداند کدام مبحثِ ریز.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id = serializers.CharField()
|
||||||
|
lessonId = serializers.CharField()
|
||||||
|
lessonTitle = serializers.CharField()
|
||||||
|
type = serializers.CharField()
|
||||||
|
prompt = serializers.CharField()
|
||||||
|
options = ChoiceOptionSerializer(many=True)
|
||||||
|
|
||||||
|
|
||||||
|
class NodeQuizResponseSerializer(serializers.Serializer):
|
||||||
|
nodeId = serializers.CharField()
|
||||||
|
nodeTitle = serializers.CharField()
|
||||||
|
unitTitle = serializers.CharField()
|
||||||
|
# questionCount ممکن است از requested کمتر باشد؛ مبحثهایی هستند که هنوز
|
||||||
|
# سوالِ کافی ندارند و فرانت باید بتواند این را ببیند نه اینکه حدس بزند.
|
||||||
|
questionCount = serializers.IntegerField()
|
||||||
|
requested = serializers.IntegerField()
|
||||||
|
questions = QuizQuestionSerializer(many=True)
|
||||||
|
|
||||||
|
|
||||||
class LessonQuestionsResponseSerializer(serializers.Serializer):
|
class LessonQuestionsResponseSerializer(serializers.Serializer):
|
||||||
questions = QuestionSerializer(many=True)
|
questions = QuestionSerializer(many=True)
|
||||||
|
|
||||||
|
|||||||
@@ -218,3 +218,86 @@ class LessonWithoutQuestionsTests(TestCase):
|
|||||||
response = self.client.get(f'/api/lessons/{self.empty.id}/intro')
|
response = self.client.get(f'/api/lessons/{self.empty.id}/intro')
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.data['introText'], 'متنِ درس')
|
self.assertEqual(response.data['introText'], 'متنِ درس')
|
||||||
|
|
||||||
|
|
||||||
|
class NodeQuizTests(TestCase):
|
||||||
|
"""
|
||||||
|
آزمونِ مبحث: واحدِ بازی در اپ «مبحث» است نه «درس».
|
||||||
|
|
||||||
|
درس در این پروژه ریزترین دانه است (یک نوعِ مسئله از کتاب) و معمولاً یکی-دو
|
||||||
|
سوال دارد؛ اپ برای هر جزیره چهار سوال میخواهد. بدونِ این اندپوینت، فرانت
|
||||||
|
باید درسبهدرس میگشت و خودش سرِ هم میکرد.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
|
||||||
|
unit = Unit.objects.create(grade=grade, title='فصل ۴', order=1)
|
||||||
|
self.node = Node.objects.create(unit=unit, title='توان', order=1)
|
||||||
|
for i in range(6):
|
||||||
|
lesson = Lesson.objects.create(node=self.node, title=f'درس {i}')
|
||||||
|
question = Question.objects.create(
|
||||||
|
lesson=lesson, type='multiple_choice', prompt=f'سوال {i}')
|
||||||
|
Choice.objects.create(question=question, value='الف', is_correct=True)
|
||||||
|
Choice.objects.create(question=question, value='ب', is_correct=False)
|
||||||
|
|
||||||
|
def test_returns_four_questions_by_default(self):
|
||||||
|
body = self.client.get(f'/api/nodes/{self.node.pk}/quiz').json()
|
||||||
|
|
||||||
|
self.assertEqual(body['questionCount'], 4)
|
||||||
|
self.assertEqual(len(body['questions']), 4)
|
||||||
|
self.assertEqual(body['nodeTitle'], 'توان')
|
||||||
|
|
||||||
|
def test_each_question_says_which_lesson_it_came_from(self):
|
||||||
|
"""برای اینکه بعدِ جوابِ غلط بشود گفت کدام مهارت را مرور کند."""
|
||||||
|
body = self.client.get(f'/api/nodes/{self.node.pk}/quiz').json()
|
||||||
|
|
||||||
|
for question in body['questions']:
|
||||||
|
self.assertTrue(question['lessonId'])
|
||||||
|
self.assertTrue(question['lessonTitle'])
|
||||||
|
|
||||||
|
def test_it_spreads_across_lessons_instead_of_drilling_one(self):
|
||||||
|
body = self.client.get(f'/api/nodes/{self.node.pk}/quiz').json()
|
||||||
|
|
||||||
|
lesson_ids = [q['lessonId'] for q in body['questions']]
|
||||||
|
self.assertEqual(len(set(lesson_ids)), len(lesson_ids))
|
||||||
|
|
||||||
|
def test_the_same_seed_gives_the_same_quiz(self):
|
||||||
|
"""رفرشِ صفحه نباید آزمونِ تازهای بدهد و پیشرفت را بیمعنا کند."""
|
||||||
|
first = self.client.get(f'/api/nodes/{self.node.pk}/quiz?seed=abc').json()
|
||||||
|
again = self.client.get(f'/api/nodes/{self.node.pk}/quiz?seed=abc').json()
|
||||||
|
|
||||||
|
self.assertEqual([q['id'] for q in first['questions']],
|
||||||
|
[q['id'] for q in again['questions']])
|
||||||
|
|
||||||
|
def test_a_different_seed_gives_a_different_order(self):
|
||||||
|
a = self.client.get(f'/api/nodes/{self.node.pk}/quiz?seed=one').json()
|
||||||
|
b = self.client.get(f'/api/nodes/{self.node.pk}/quiz?seed=two').json()
|
||||||
|
|
||||||
|
self.assertNotEqual([q['id'] for q in a['questions']],
|
||||||
|
[q['id'] for q in b['questions']])
|
||||||
|
|
||||||
|
def test_a_thin_node_reports_what_it_actually_has(self):
|
||||||
|
"""
|
||||||
|
مبحثی که هنوز چهار سوال ندارد نباید وانمود کند دارد — فرانت باید
|
||||||
|
بتواند تفاوتِ «آزمونِ کوتاه» و «خطا» را بفهمد.
|
||||||
|
"""
|
||||||
|
thin_unit = Unit.objects.create(
|
||||||
|
grade=self.node.unit.grade, title='فصل ۵', order=2)
|
||||||
|
thin = Node.objects.create(unit=thin_unit, title='کمسوال', order=1)
|
||||||
|
lesson = Lesson.objects.create(node=thin, title='تنها درس')
|
||||||
|
question = Question.objects.create(
|
||||||
|
lesson=lesson, type='multiple_choice', prompt='تنها سوال')
|
||||||
|
Choice.objects.create(question=question, value='الف', is_correct=True)
|
||||||
|
|
||||||
|
body = self.client.get(f'/api/nodes/{thin.pk}/quiz').json()
|
||||||
|
|
||||||
|
self.assertEqual(body['questionCount'], 1)
|
||||||
|
self.assertEqual(body['requested'], 4)
|
||||||
|
|
||||||
|
def test_size_is_capped_so_one_request_cannot_pull_everything(self):
|
||||||
|
body = self.client.get(f'/api/nodes/{self.node.pk}/quiz?size=999').json()
|
||||||
|
|
||||||
|
self.assertLessEqual(body['questionCount'], 20)
|
||||||
|
|
||||||
|
def test_unknown_node_is_a_clean_404(self):
|
||||||
|
self.assertEqual(self.client.get('/api/nodes/999999/quiz').status_code, 404)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from .views import (
|
|||||||
LessonIntroView,
|
LessonIntroView,
|
||||||
LessonQuestionsView,
|
LessonQuestionsView,
|
||||||
NodeLessonsView,
|
NodeLessonsView,
|
||||||
|
NodeQuizView,
|
||||||
NodeProgressListView,
|
NodeProgressListView,
|
||||||
QuestionAnswerView,
|
QuestionAnswerView,
|
||||||
QuestionReportView,
|
QuestionReportView,
|
||||||
@@ -21,5 +22,6 @@ urlpatterns = [
|
|||||||
path('questions/<int:question_id>/answer', QuestionAnswerView.as_view(), name='question-answer'),
|
path('questions/<int:question_id>/answer', QuestionAnswerView.as_view(), name='question-answer'),
|
||||||
path('questions/<int:question_id>/report', QuestionReportView.as_view(), name='question-report'),
|
path('questions/<int:question_id>/report', QuestionReportView.as_view(), name='question-report'),
|
||||||
path('nodes/<int:node_id>/lessons', NodeLessonsView.as_view(), name='node-lessons'),
|
path('nodes/<int:node_id>/lessons', NodeLessonsView.as_view(), name='node-lessons'),
|
||||||
|
path('nodes/<int:node_id>/quiz', NodeQuizView.as_view(), name='node-quiz'),
|
||||||
path('progress/nodes', NodeProgressListView.as_view(), name='node-progress-list'),
|
path('progress/nodes', NodeProgressListView.as_view(), name='node-progress-list'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ from django.core.cache import cache
|
|||||||
from django.db import IntegrityError, transaction
|
from django.db import IntegrityError, transaction
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
|
import random
|
||||||
|
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||||
@@ -29,6 +31,7 @@ from .serializers import (
|
|||||||
LessonIntroResponseSerializer,
|
LessonIntroResponseSerializer,
|
||||||
LessonQuestionsResponseSerializer,
|
LessonQuestionsResponseSerializer,
|
||||||
NodeLessonsResponseSerializer,
|
NodeLessonsResponseSerializer,
|
||||||
|
NodeQuizResponseSerializer,
|
||||||
NodeProgressListResponseSerializer,
|
NodeProgressListResponseSerializer,
|
||||||
QuestionReportRequestSerializer,
|
QuestionReportRequestSerializer,
|
||||||
QuestionReportResponseSerializer,
|
QuestionReportResponseSerializer,
|
||||||
@@ -635,6 +638,95 @@ class QuestionReportView(APIView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NodeQuizView(APIView):
|
||||||
|
"""
|
||||||
|
GET /api/nodes/{id}/quiz — یک آزمونِ آماده برای کلِ مبحث.
|
||||||
|
|
||||||
|
چرا لازم شد: واحدِ بازی در اپ «مبحث» است (همان جزیرهی روی نقشه)، ولی
|
||||||
|
سوالها به «درس» بستهاند و درس در این پروژه ریزترین دانه است — یک نوعِ
|
||||||
|
مسئلهی استخراجشده از کتاب، نه یک جلسهی درسی. نتیجهاش این بود که هر
|
||||||
|
درس یکی-دو سوال داشت و اپ برای ساختنِ یک آزمونِ چهارسوالی باید دهها
|
||||||
|
درخواستِ `lessons/{id}/questions` میزد و خودش سرِ هم میکرد.
|
||||||
|
|
||||||
|
این اندپوینت همان کار را یکجا میکند: از همهی درسهای مبحث نمونه
|
||||||
|
برمیدارد و یک آزمون میدهد.
|
||||||
|
|
||||||
|
ترتیب تصادفی نیست: `?seed=` یا شناسهی کاربر مبنای درهمریزی است تا
|
||||||
|
دانشآموزی که صفحه را رفرش میکند همان آزمون را ببیند، نه مجموعهی تازهای
|
||||||
|
که پیشرفتش را بیمعنا کند.
|
||||||
|
"""
|
||||||
|
|
||||||
|
throttle_scope = 'read'
|
||||||
|
throttle_classes = [GuestAwareContentThrottle]
|
||||||
|
|
||||||
|
DEFAULT_SIZE = 4
|
||||||
|
MAX_SIZE = 20
|
||||||
|
|
||||||
|
@extend_schema(responses=NodeQuizResponseSerializer, tags=['Curriculum (content)'])
|
||||||
|
def get(self, request, node_id: int):
|
||||||
|
node = get_object_or_404(
|
||||||
|
Node.objects.select_related('unit'), pk=node_id,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
size = int(request.query_params.get('size', self.DEFAULT_SIZE))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
size = self.DEFAULT_SIZE
|
||||||
|
size = max(1, min(size, self.MAX_SIZE))
|
||||||
|
|
||||||
|
questions = list(
|
||||||
|
Question.objects
|
||||||
|
.filter(lesson__node_id=node_id)
|
||||||
|
.select_related('lesson')
|
||||||
|
.prefetch_related('choices')
|
||||||
|
.order_by('lesson__id', 'id')
|
||||||
|
)
|
||||||
|
|
||||||
|
# درهمریزیِ پایدار: همان کاربر روی همان مبحث همیشه همان ترتیب را
|
||||||
|
# میبیند، ولی دو کاربرِ مختلف دو ترتیبِ مختلف.
|
||||||
|
seed = request.query_params.get('seed') or str(getattr(request.user, 'id', '') or '')
|
||||||
|
rng = random.Random(f'{node_id}:{seed}')
|
||||||
|
rng.shuffle(questions)
|
||||||
|
|
||||||
|
# حداکثر یک سوال از هر درس تا آزمون روی یک مهارت گیر نکند؛ اگر با این
|
||||||
|
# شرط به اندازهی لازم نرسیدیم، بقیه از همان استخر پر میشوند.
|
||||||
|
picked, seen_lessons = [], set()
|
||||||
|
for question in questions:
|
||||||
|
if question.lesson_id in seen_lessons:
|
||||||
|
continue
|
||||||
|
seen_lessons.add(question.lesson_id)
|
||||||
|
picked.append(question)
|
||||||
|
if len(picked) >= size:
|
||||||
|
break
|
||||||
|
if len(picked) < size:
|
||||||
|
for question in questions:
|
||||||
|
if question in picked:
|
||||||
|
continue
|
||||||
|
picked.append(question)
|
||||||
|
if len(picked) >= size:
|
||||||
|
break
|
||||||
|
|
||||||
|
return Response(NodeQuizResponseSerializer({
|
||||||
|
'nodeId': str(node.pk),
|
||||||
|
'nodeTitle': node.title,
|
||||||
|
'unitTitle': node.unit.title,
|
||||||
|
'questionCount': len(picked),
|
||||||
|
'requested': size,
|
||||||
|
'questions': [
|
||||||
|
{
|
||||||
|
'id': str(q.pk),
|
||||||
|
'lessonId': str(q.lesson_id),
|
||||||
|
'lessonTitle': q.lesson.title,
|
||||||
|
'type': q.type,
|
||||||
|
'prompt': q.prompt,
|
||||||
|
'options': [
|
||||||
|
{'id': str(c.pk), 'value': c.value} for c in q.choices.all()
|
||||||
|
],
|
||||||
|
}
|
||||||
|
for q in picked
|
||||||
|
],
|
||||||
|
}).data)
|
||||||
|
|
||||||
|
|
||||||
class NodeLessonsView(APIView):
|
class NodeLessonsView(APIView):
|
||||||
"""
|
"""
|
||||||
GET /api/nodes/{id}/lessons — قدمهای یک مبحث برای نقشهی مهارت.
|
GET /api/nodes/{id}/lessons — قدمهای یک مبحث برای نقشهی مهارت.
|
||||||
|
|||||||
379
rag/management/commands/rag_verify_solutions.py
Normal file
379
rag/management/commands/rag_verify_solutions.py
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
"""
|
||||||
|
راستیآزماییِ راهحلهایی که vision از کتابِ درسی درآورده.
|
||||||
|
|
||||||
|
چرا لازم است: استخراج گاهی راهحلِ غلط میسازد و این خطا در بازیابی دیده
|
||||||
|
نمیشود — فقط وقتی کسی به `solution` اعتماد کند (ساختِ سؤال، یا خودِ دستیار
|
||||||
|
هنگامِ نقلِ جواب) خودش را نشان میدهد. نمونههای واقعی از همین کتابها:
|
||||||
|
|
||||||
|
BMI = ۱۰۰/(۲٫۷۴)² کتاب: ۳۳٫۲ درست: ۱۳٫۳
|
||||||
|
۱۶ آبی + ۲۴ صورتی کتاب: «کل ۵۰» درست: ۴۰
|
||||||
|
بزرگترین عددِ ۴رقمیِ بیتکرار کتاب: ۸۵۳۲ درست: ۹۸۷۶
|
||||||
|
g(x) = x³ − ۲ کتاب: «نمایی» درست: چندجملهایِ درجه ۳
|
||||||
|
|
||||||
|
`solution` هرگز بازنویسی نمیشود. کتاب باید کتاب بماند: اگر جوابِ مدل را
|
||||||
|
جایش میگذاشتیم و مدل اشتباه میکرد، منبعِ حقیقت بیصدا خراب میشد.
|
||||||
|
|
||||||
|
──────────────────────────────────────────────────────────────────────────
|
||||||
|
درسهایی که از اجرای اولِ این دستور گرفته شد و در طراحیِ فعلی نشستهاند:
|
||||||
|
|
||||||
|
۱. نتیجه باید سهحالته باشد. با بله/خیر، «نمیدانم» ناچار به «غلط است»
|
||||||
|
ترجمه میشد: ۳۵٪ از گزارشها مسئلههایی بودند که به شکل یا جدولِ کتاب
|
||||||
|
ارجاع میدادند و اصلاً قابلِ قضاوت نبودند.
|
||||||
|
|
||||||
|
۲. «۳/۱۰۴» در این کتابها عددِ اعشاریِ ۳٫۱۰۴ است، نه کسرِ ۳÷۱۰۴. بدونِ گفتنِ
|
||||||
|
این به مدل، ۲۴٪ از گزارشها هشدارِ کاذب بود — بیشترشان در پایه ششم که
|
||||||
|
پر از اعشار است.
|
||||||
|
|
||||||
|
۳. مسئلههای وابسته به شکل اصلاً فرستاده نمیشوند. هزینه دارند و جوابشان
|
||||||
|
بیمعناست؛ همانجا برچسبِ unjudgeable میخورند.
|
||||||
|
|
||||||
|
۴. دو مدل، دو نقش. deepseek-reasoner ارزان است و تشخیصش خوب، ولی جوابِ
|
||||||
|
خودش گاهی غلط است (مسئلهی سرعتِ نور را ۱۰^۱۷ داد بهجای ۱۰^۱۴). پس
|
||||||
|
او تور میاندازد و o4-mini فقط روی صیدها داوری میکند.
|
||||||
|
──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
python manage.py rag_verify_solutions --all
|
||||||
|
python manage.py rag_verify_solutions --all --adjudicate
|
||||||
|
python manage.py rag_verify_solutions --collection "ریاضی ششم" --limit 20
|
||||||
|
python manage.py rag_verify_solutions --report
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db.models import Count, Q
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from rag.models import LessonChunk
|
||||||
|
from rag.services import clients
|
||||||
|
from rag.services.config import rag_is_configured
|
||||||
|
|
||||||
|
SCOUT_MODEL = 'deepseek-reasoner'
|
||||||
|
JUDGE_MODEL = 'o4-mini'
|
||||||
|
|
||||||
|
# دو خطای کاملاً متفاوت که هر دو ۴۲۹اند و نباید یکسان با آنها رفتار کرد:
|
||||||
|
#
|
||||||
|
# • فشارِ لحظهایِ موازیکاری — گذراست، عقبنشینی حلش میکند.
|
||||||
|
# • سقفِ مصرفِ حساب — ساعتها باز نمیشود. تلاشِ دوباره نهتنها بیفایده
|
||||||
|
# است، پنجرهی محدودیت را هم تمدید میکند: یک اجرا ۱۱۹ بار دوباره تلاش
|
||||||
|
# کرد، ۶۵ دقیقه طول کشید، هیچ کاری نکرد، و مهلت را از ۱۴ ساعت جلوتر برد.
|
||||||
|
class AccountLimitReached(Exception):
|
||||||
|
"""حسابِ سرویس جواب نمیدهد؛ ادامه دادن بیمعناست."""
|
||||||
|
|
||||||
|
|
||||||
|
def is_account_limit(exc) -> bool:
|
||||||
|
"""
|
||||||
|
آیا این خطا یعنی «حسابِ سرویس دیگر جواب نمیدهد» یا فقط کندیِ لحظهای؟
|
||||||
|
|
||||||
|
دو شکلِ واقعی که دیدیم و هر دو باید اجرا را متوقف کنند:
|
||||||
|
429 initial_usage_limit_exceeded — سقفِ حسابِ تازه، ساعتها
|
||||||
|
403 insufficient user quota — اعتبار تمام شده
|
||||||
|
|
||||||
|
نکته: متنِ دومی «insufficient user quota» است با فاصله، نه زیرخط. محافظِ
|
||||||
|
اولِ ما دنبالِ `insufficient_quota` میگشت و نمیگرفتش، و اجرا ۷۷۰ فراخوانیِ
|
||||||
|
محکومبهشکست زد بهجای اینکه سرِ اولی بایستد.
|
||||||
|
"""
|
||||||
|
text = str(exc).lower()
|
||||||
|
if 'usage_limit' in text or 'quota' in text:
|
||||||
|
return True
|
||||||
|
return '403' in text and 'insufficient' in text
|
||||||
|
|
||||||
|
|
||||||
|
# مسئلهای که به شکل/جدولِ کتاب ارجاع میدهد بدونِ آن شکل قابلِ حل نیست.
|
||||||
|
# فرستادنش هم پول میسوزاند هم آمار را خراب میکند.
|
||||||
|
NEEDS_FIGURE = re.compile(
|
||||||
|
r'شکل|جدول|نمودار|روبهرو|روبرو|مقابل|تصویر|نقشه|رسم کنید|بکشید|'
|
||||||
|
r'رنگ[ ]?آمیزی|زیر را|بالا را|وِن'
|
||||||
|
)
|
||||||
|
|
||||||
|
PROMPT = """تو یک ریاضیدانِ دقیق هستی که راهحلهای کتابِ درسیِ ایران را بازبینی میکند.
|
||||||
|
|
||||||
|
⚠️ سه قاعدهی مهم دربارهی نگارشِ این متنها:
|
||||||
|
|
||||||
|
۱. علامتِ «/» بینِ دو عدد، در این کتابها تقریباً همیشه **جداکنندهی اعشار**
|
||||||
|
است، نه خطِ کسر. یعنی «۳/۱۰۴» عددِ ۳٫۱۰۴ است و «۲/۵» عددِ ۲٫۵.
|
||||||
|
کسرها معمولاً به شکلِ «۳ بر ۴» یا با کسرِ عمودی میآیند.
|
||||||
|
۲. صفر در ریاضیِ مدرسهی ایران عددِ طبیعی **نیست**.
|
||||||
|
۳. متن با OCR از PDF درآمده؛ فاصله و نیمفاصله ممکن است بههمریخته باشد.
|
||||||
|
تفاوتِ صرفاً نگارشی، گِردکردن، یا ترتیبِ نوشتن **خطا نیست**.
|
||||||
|
|
||||||
|
مسئله:
|
||||||
|
{problem}
|
||||||
|
|
||||||
|
راهحلِ کتاب:
|
||||||
|
{solution}
|
||||||
|
|
||||||
|
کارَت:
|
||||||
|
۱. اول خودت مسئله را قدمبهقدم حل کن.
|
||||||
|
۲. بعد راهحلِ کتاب را با جوابِ خودت بسنج.
|
||||||
|
|
||||||
|
یکی از این سه را برگردان:
|
||||||
|
• "agrees" — راهحلِ کتاب از نظرِ ریاضی درست است (حتی اگر نگارشش فرق دارد)
|
||||||
|
• "disagrees" — خطای قطعیِ ریاضی دارد. فقط وقتی مطمئنی.
|
||||||
|
• "unjudgeable" — بدونِ شکل/جدولِ کتاب یا دادهی جاافتاده نمیشود قضاوت کرد،
|
||||||
|
یا مسئله جوابِ عددیِ مشخصی ندارد (پرسشِ باز یا توضیحی).
|
||||||
|
|
||||||
|
اگر شک داری بینِ disagrees و unjudgeable، unjudgeable را انتخاب کن.
|
||||||
|
|
||||||
|
فقط این JSON را برگردان، بدونِ هیچ متنِ اضافه:
|
||||||
|
{{"verdict": "agrees|disagrees|unjudgeable", "answer": "جوابِ خودت، کوتاه", "why": "حداکثر ۱۲۰ نویسه"}}"""
|
||||||
|
|
||||||
|
|
||||||
|
def first_json(text: str):
|
||||||
|
"""
|
||||||
|
اولین شیءِ JSON را از متن بیرون میکشد.
|
||||||
|
|
||||||
|
مدلهای استدلالی گاهی قبل یا بعد از JSON توضیح میچسبانند و json.loads خام
|
||||||
|
روی همان میافتد. در محکِ اولیه همین باعث شد deepseek بهاشتباه ۷ از ۸
|
||||||
|
بهنظر برسد در حالی که ۸ از ۸ بود.
|
||||||
|
"""
|
||||||
|
depth = 0
|
||||||
|
start = None
|
||||||
|
for i, ch in enumerate(text or ''):
|
||||||
|
if ch == '{':
|
||||||
|
if depth == 0:
|
||||||
|
start = i
|
||||||
|
depth += 1
|
||||||
|
elif ch == '}':
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0 and start is not None:
|
||||||
|
try:
|
||||||
|
return json.loads(text[start:i + 1])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
start = None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'بازبینیِ درستیِ راهحلهای استخراجشده از کتاب (هزینه دارد، کم).'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('--collection', default=None)
|
||||||
|
parser.add_argument('--all', action='store_true')
|
||||||
|
parser.add_argument('--limit', type=int, default=None)
|
||||||
|
parser.add_argument('--workers', type=int, default=12)
|
||||||
|
parser.add_argument('--adjudicate', action='store_true',
|
||||||
|
help='پاسِ دوم: مشکوکها را با مدلِ دقیقتر بسنج.')
|
||||||
|
parser.add_argument('--recheck', action='store_true')
|
||||||
|
parser.add_argument('--report', action='store_true',
|
||||||
|
help='فقط گزارشِ وضعیت؛ هیچ فراخوانیای نمیکند.')
|
||||||
|
parser.add_argument('--sync-qdrant', action='store_true',
|
||||||
|
help='برچسبها را در payloadِ Qdrant بنویس (رایگان).')
|
||||||
|
|
||||||
|
def handle(self, *args, **opts):
|
||||||
|
if opts['report']:
|
||||||
|
return self._report()
|
||||||
|
if opts['sync_qdrant']:
|
||||||
|
return self._sync_qdrant(opts)
|
||||||
|
if not rag_is_configured():
|
||||||
|
raise CommandError('RAG پیکربندی نشده (کلیدها خالیاند).')
|
||||||
|
if opts['adjudicate']:
|
||||||
|
return self._adjudicate(opts)
|
||||||
|
|
||||||
|
qs = LessonChunk.objects.exclude(problem='').exclude(solution='')
|
||||||
|
if opts['collection']:
|
||||||
|
qs = qs.filter(collection=opts['collection'])
|
||||||
|
elif not opts['all']:
|
||||||
|
raise CommandError('یا --collection بدهید یا --all.')
|
||||||
|
if not opts['recheck']:
|
||||||
|
qs = qs.filter(solution_checked_at__isnull=True)
|
||||||
|
rows = list(qs.order_by('collection', 'source_page', 'order'))
|
||||||
|
|
||||||
|
skipped = self._mark_figure_dependent(rows)
|
||||||
|
rows = [r for r in rows if not NEEDS_FIGURE.search(r.problem or '')]
|
||||||
|
if opts['limit']:
|
||||||
|
rows = rows[:opts['limit']]
|
||||||
|
if skipped:
|
||||||
|
self.stdout.write(
|
||||||
|
f' {skipped} مسئلهی وابسته به شکل/جدول بدونِ فراخوانی '
|
||||||
|
'برچسبِ unjudgeable گرفتند.'
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
self.stdout.write(self.style.WARNING('چیزی برای بررسی نماند.'))
|
||||||
|
return
|
||||||
|
|
||||||
|
self.stdout.write(self.style.MIGRATE_HEADING(
|
||||||
|
f'\n{len(rows)} chunk با {SCOUT_MODEL} (~${len(rows) * 0.00014:.2f})\n'))
|
||||||
|
self._run(rows, SCOUT_MODEL, opts['workers'])
|
||||||
|
|
||||||
|
def _mark_figure_dependent(self, rows) -> int:
|
||||||
|
"""قبل از خرجِ یک ریال، آنهایی که ذاتاً قابلِ قضاوت نیستند کنار میروند."""
|
||||||
|
marked = []
|
||||||
|
for chunk in rows:
|
||||||
|
if NEEDS_FIGURE.search(chunk.problem or ''):
|
||||||
|
chunk.solution_verdict = LessonChunk.VERDICT_UNJUDGEABLE
|
||||||
|
chunk.check_note = 'به شکل/جدولِ کتاب ارجاع میدهد؛ از متن قابلِ قضاوت نیست.'
|
||||||
|
chunk.checked_by = 'pre-filter'
|
||||||
|
chunk.solution_checked_at = timezone.now()
|
||||||
|
marked.append(chunk)
|
||||||
|
if marked:
|
||||||
|
LessonChunk.objects.bulk_update(
|
||||||
|
marked, ['solution_verdict', 'check_note', 'checked_by',
|
||||||
|
'solution_checked_at'])
|
||||||
|
return len(marked)
|
||||||
|
|
||||||
|
def _adjudicate(self, opts):
|
||||||
|
qs = LessonChunk.objects.filter(
|
||||||
|
solution_verdict=LessonChunk.VERDICT_DISAGREES).exclude(problem='')
|
||||||
|
if opts['collection']:
|
||||||
|
qs = qs.filter(collection=opts['collection'])
|
||||||
|
if not opts['recheck']:
|
||||||
|
qs = qs.exclude(checked_by=JUDGE_MODEL)
|
||||||
|
rows = list(qs.order_by('collection', 'source_page')[:opts['limit'] or 10_000])
|
||||||
|
if not rows:
|
||||||
|
self.stdout.write(self.style.WARNING('مشکوکی برای داوری نمانده.'))
|
||||||
|
return
|
||||||
|
# مدلِ داور روی این پروکسی سختگیرتر نرخبندی میشود؛ نصفِ کارگرها.
|
||||||
|
workers = max(1, min(opts['workers'], 4))
|
||||||
|
self.stdout.write(self.style.MIGRATE_HEADING(
|
||||||
|
f'\nداوریِ {len(rows)} مورد با {JUDGE_MODEL} ({workers} کارگر) '
|
||||||
|
f'(~${len(rows) * 0.0018:.2f})\n'))
|
||||||
|
self._run(rows, JUDGE_MODEL, workers)
|
||||||
|
|
||||||
|
def _run(self, rows, model, workers):
|
||||||
|
"""
|
||||||
|
فراخوانیها موازی، نوشتن دستهای از نخِ اصلی.
|
||||||
|
|
||||||
|
هر chunk چند ثانیه منتظرِ شبکه است، پس ترتیبی یعنی ساعتها؛ ولی نوشتنِ
|
||||||
|
همزمان از چند نخ روی SQLite به قفلِ نوشتن میخورد.
|
||||||
|
"""
|
||||||
|
client = clients.get_openai_client()
|
||||||
|
|
||||||
|
stop = threading.Event()
|
||||||
|
|
||||||
|
def ask(chunk):
|
||||||
|
# اگر یک نخ به سقفِ حساب خورده، بقیه بیدرنگ برمیگردند؛ ادامه دادن
|
||||||
|
# فقط پنجرهی محدودیت را طولانیتر میکند.
|
||||||
|
if stop.is_set():
|
||||||
|
return chunk, None, None
|
||||||
|
delay = 4.0
|
||||||
|
last = None
|
||||||
|
for _ in range(5):
|
||||||
|
try:
|
||||||
|
completion = client.chat.completions.create(
|
||||||
|
model=model,
|
||||||
|
messages=[{'role': 'user', 'content': PROMPT.format(
|
||||||
|
problem=chunk.problem[:900], solution=chunk.solution[:900])}],
|
||||||
|
)
|
||||||
|
data = first_json(completion.choices[0].message.content)
|
||||||
|
verdict = (data or {}).get('verdict')
|
||||||
|
if verdict not in dict(LessonChunk.VERDICT_CHOICES):
|
||||||
|
raise ValueError(f'verdictِ نامعتبر: {verdict!r}')
|
||||||
|
return chunk, data, None
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
last = exc
|
||||||
|
text = str(exc).lower()
|
||||||
|
if is_account_limit(exc):
|
||||||
|
stop.set()
|
||||||
|
return chunk, None, AccountLimitReached(str(exc)[:200])
|
||||||
|
if '429' not in text and 'rate' not in text:
|
||||||
|
break
|
||||||
|
if stop.is_set():
|
||||||
|
break
|
||||||
|
time.sleep(delay)
|
||||||
|
delay *= 2
|
||||||
|
return chunk, None, last
|
||||||
|
|
||||||
|
counts = {v: 0 for v, _ in LessonChunk.VERDICT_CHOICES}
|
||||||
|
failed = 0
|
||||||
|
aborted: AccountLimitReached | None = None
|
||||||
|
batch: list[LessonChunk] = []
|
||||||
|
t0 = time.time()
|
||||||
|
done = 0
|
||||||
|
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||||||
|
for chunk, data, err in pool.map(ask, rows):
|
||||||
|
done += 1
|
||||||
|
if isinstance(err, AccountLimitReached):
|
||||||
|
aborted = err
|
||||||
|
continue
|
||||||
|
if err is None and data is None:
|
||||||
|
continue # بعد از توقف، نوبتش نرسید
|
||||||
|
if err is not None:
|
||||||
|
failed += 1
|
||||||
|
self.stdout.write(self.style.ERROR(f' ✗ #{chunk.pk}: {str(err)[:75]}'))
|
||||||
|
else:
|
||||||
|
chunk.solution_verdict = data['verdict']
|
||||||
|
chunk.checked_answer = str(data.get('answer') or '')[:2000]
|
||||||
|
chunk.check_note = str(data.get('why') or '')[:300]
|
||||||
|
chunk.checked_by = model
|
||||||
|
chunk.solution_checked_at = timezone.now()
|
||||||
|
batch.append(chunk)
|
||||||
|
counts[data['verdict']] += 1
|
||||||
|
if data['verdict'] == LessonChunk.VERDICT_DISAGREES:
|
||||||
|
self.stdout.write(
|
||||||
|
f' ⚠ {chunk.collection} ص{chunk.source_page}: '
|
||||||
|
f'{chunk.problem[:50]} → {chunk.checked_answer[:40]}')
|
||||||
|
if len(batch) >= 40:
|
||||||
|
self._flush(batch)
|
||||||
|
if done % 100 == 0:
|
||||||
|
rate = done / max(time.time() - t0, 1)
|
||||||
|
self.stdout.write(
|
||||||
|
f' … {done}/{len(rows)} (~{(len(rows)-done)/max(rate,.001)/60:.0f} دقیقه مانده)')
|
||||||
|
self._flush(batch)
|
||||||
|
self.stdout.write(self.style.SUCCESS(
|
||||||
|
f"\n{counts['agrees']} درست، {counts['disagrees']} مشکوک، "
|
||||||
|
f"{counts['unjudgeable']} غیرقابلِ قضاوت، {failed} خطا — {time.time()-t0:.0f}s"))
|
||||||
|
if aborted is not None:
|
||||||
|
# آنچه تا اینجا سنجیده شده ذخیره شده و اجرای بعدی از همینجا ادامه
|
||||||
|
# میدهد، چون فقط بررسینشدهها برداشته میشوند.
|
||||||
|
raise CommandError(
|
||||||
|
'سقفِ مصرفِ حسابِ سرویس پر شده و اجرا همینجا متوقف شد.\n'
|
||||||
|
f' پیامِ سرویس: {aborted}\n'
|
||||||
|
' تا باز شدنِ سقف دوباره تلاش نکنید؛ هر تلاش مهلت را جلوتر میبرد.\n'
|
||||||
|
' وضعیت با: python manage.py rag_verify_solutions --report'
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _flush(batch):
|
||||||
|
if batch:
|
||||||
|
LessonChunk.objects.bulk_update(
|
||||||
|
batch, ['solution_verdict', 'checked_answer', 'check_note',
|
||||||
|
'checked_by', 'solution_checked_at'])
|
||||||
|
batch.clear()
|
||||||
|
|
||||||
|
def _sync_qdrant(self, opts):
|
||||||
|
"""
|
||||||
|
برچسبها را روی نقاطِ موجود مینویسد، بدونِ بردار و بدونِ هزینه.
|
||||||
|
|
||||||
|
نقطهها بر اساسِ مقدارِ برچسب دسته میشوند، پس برای هر collection سه
|
||||||
|
فراخوانی کافی است نه هزارانتا.
|
||||||
|
"""
|
||||||
|
from rag.services.qdrant_store import set_payload_fields
|
||||||
|
|
||||||
|
qs = LessonChunk.objects.exclude(solution_verdict='')
|
||||||
|
if opts['collection']:
|
||||||
|
qs = qs.filter(collection=opts['collection'])
|
||||||
|
|
||||||
|
buckets: dict[tuple[str, str], list[str]] = {}
|
||||||
|
for collection, verdict, point_id in qs.values_list(
|
||||||
|
'collection', 'solution_verdict', 'qdrant_point_id'):
|
||||||
|
buckets.setdefault((collection, verdict), []).append(str(point_id))
|
||||||
|
|
||||||
|
if not buckets:
|
||||||
|
self.stdout.write(self.style.WARNING('برچسبی برای همگامسازی نیست.'))
|
||||||
|
return
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
for (collection, verdict), ids in sorted(buckets.items()):
|
||||||
|
set_payload_fields(collection, ids, {'solution_verdict': verdict})
|
||||||
|
total += len(ids)
|
||||||
|
self.stdout.write(f' {collection:14s} {verdict:12s} {len(ids):4d} نقطه')
|
||||||
|
self.stdout.write(self.style.SUCCESS(f'\n{total} نقطه در Qdrant بهروز شد.'))
|
||||||
|
|
||||||
|
def _report(self):
|
||||||
|
rows = (LessonChunk.objects.exclude(problem='').exclude(solution='')
|
||||||
|
.values('collection')
|
||||||
|
.annotate(n=Count('id'),
|
||||||
|
ok=Count('id', filter=Q(solution_verdict='agrees')),
|
||||||
|
bad=Count('id', filter=Q(solution_verdict='disagrees')),
|
||||||
|
unk=Count('id', filter=Q(solution_verdict='unjudgeable')),
|
||||||
|
todo=Count('id', filter=Q(solution_checked_at__isnull=True)))
|
||||||
|
.order_by('collection'))
|
||||||
|
self.stdout.write(f"\n{'کتاب':14s} {'کل':>5s} {'درست':>6s} {'مشکوک':>7s} "
|
||||||
|
f"{'بیقضاوت':>9s} {'مانده':>6s}")
|
||||||
|
for r in rows:
|
||||||
|
self.stdout.write(f"{r['collection']:14s} {r['n']:5d} {r['ok']:6d} "
|
||||||
|
f"{r['bad']:7d} {r['unk']:9d} {r['todo']:6d}")
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 5.2.17 on 2026-09-15 22:34
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('rag', '0004_ingestedpage'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='lessonchunk',
|
||||||
|
name='checked_answer',
|
||||||
|
field=models.TextField(blank=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='lessonchunk',
|
||||||
|
name='checked_by',
|
||||||
|
field=models.CharField(blank=True, max_length=60),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='lessonchunk',
|
||||||
|
name='solution_agrees',
|
||||||
|
field=models.BooleanField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='lessonchunk',
|
||||||
|
name='solution_checked_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Generated by Django 5.2.17 on 2026-09-15 23:22
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('rag', '0005_lessonchunk_checked_answer_lessonchunk_checked_by_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='lessonchunk',
|
||||||
|
name='solution_agrees',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='lessonchunk',
|
||||||
|
name='check_note',
|
||||||
|
field=models.CharField(blank=True, max_length=300),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='lessonchunk',
|
||||||
|
name='solution_verdict',
|
||||||
|
field=models.CharField(blank=True, choices=[('agrees', 'راه\u200cحلِ کتاب درست است'), ('disagrees', 'راه\u200cحلِ کتاب غلط است'), ('unjudgeable', 'بدونِ شکل/جدول قابلِ قضاوت نیست')], db_index=True, max_length=12),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -53,6 +53,36 @@ class LessonChunk(models.Model):
|
|||||||
# اجرای دوباره پس از قطعی، نقاطِ تکراری در Qdrant میساخت.
|
# اجرای دوباره پس از قطعی، نقاطِ تکراری در Qdrant میساخت.
|
||||||
source_page = models.PositiveIntegerField(null=True, blank=True, db_index=True)
|
source_page = models.PositiveIntegerField(null=True, blank=True, db_index=True)
|
||||||
|
|
||||||
|
# --- راستیآزماییِ راهحل ------------------------------------------
|
||||||
|
# vision گاهی راهحلِ غلط از کتاب درمیآورد: در پایه نهم ۱۷ مورد پیدا شد،
|
||||||
|
# مثلِ «۱۰۸×۱۰¹³» بهجای «۱٫۰۸×۱۰¹⁴» یا دستگاهی بیجواب که جوابِ (۸,۵)
|
||||||
|
# گرفته بود. این خطا در بازیابی دیده نمیشود ولی هر مصرفکنندهای که به
|
||||||
|
# `solution` اعتماد کند را خراب میکند — از ساختِ سؤال تا خودِ دستیار.
|
||||||
|
#
|
||||||
|
# `solution` عمداً هیچوقت بازنویسی نمیشود: کتاب باید کتاب بماند. اگر
|
||||||
|
# جوابِ مدل را جایش میگذاشتیم و مدل اشتباه میکرد، منبعِ حقیقت بیصدا
|
||||||
|
# خراب میشد و راهِ برگشتی نبود. پس فقط برچسب میخورد و جوابِ مدل جدا
|
||||||
|
# ذخیره میشود تا آدم بتواند داوری کند.
|
||||||
|
# نتیجه سهحالته است، نه بله/خیر. پاسِ اول با بولی اجرا شد و ۱۲٫۵٪ خطا
|
||||||
|
# گزارش کرد؛ خواندنِ دستیِ نمونهها نشان داد نرخِ واقعی ~۴٪ است و بقیه نویز:
|
||||||
|
# ۳۵٪ مسئلههایی که به شکل یا جدولِ کتاب ارجاع میدهند و اصلاً قابلِ قضاوت
|
||||||
|
# نیستند، و ۲۴٪ موردی که «۳/۱۰۴» را کسرِ ۳÷۱۰۴ خوانده بود نه عددِ اعشاریِ
|
||||||
|
# ۳٫۱۰۴. با دو حالت، «نمیدانم» ناچار به «غلط است» ترجمه میشد.
|
||||||
|
VERDICT_AGREES = 'agrees'
|
||||||
|
VERDICT_DISAGREES = 'disagrees'
|
||||||
|
VERDICT_UNJUDGEABLE = 'unjudgeable'
|
||||||
|
VERDICT_CHOICES = [
|
||||||
|
(VERDICT_AGREES, 'راهحلِ کتاب درست است'),
|
||||||
|
(VERDICT_DISAGREES, 'راهحلِ کتاب غلط است'),
|
||||||
|
(VERDICT_UNJUDGEABLE, 'بدونِ شکل/جدول قابلِ قضاوت نیست'),
|
||||||
|
]
|
||||||
|
solution_checked_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
solution_verdict = models.CharField(max_length=12, blank=True,
|
||||||
|
choices=VERDICT_CHOICES, db_index=True)
|
||||||
|
checked_answer = models.TextField(blank=True)
|
||||||
|
check_note = models.CharField(max_length=300, blank=True)
|
||||||
|
checked_by = models.CharField(max_length=60, blank=True)
|
||||||
|
|
||||||
collection = models.CharField(max_length=200, db_index=True)
|
collection = models.CharField(max_length=200, db_index=True)
|
||||||
qdrant_point_id = models.UUIDField(default=uuid.uuid4, editable=False)
|
qdrant_point_id = models.UUIDField(default=uuid.uuid4, editable=False)
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ def get_qdrant_client():
|
|||||||
url=settings.QDRANT_URL,
|
url=settings.QDRANT_URL,
|
||||||
api_key=settings.QDRANT_API_KEY,
|
api_key=settings.QDRANT_API_KEY,
|
||||||
check_compatibility=False,
|
check_compatibility=False,
|
||||||
|
timeout=settings.QDRANT_TIMEOUT_SECONDS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,14 @@ def _payload(chunk: LessonChunk) -> dict:
|
|||||||
'chapter_number': chunk.chapter,
|
'chapter_number': chunk.chapter,
|
||||||
'chunk_order': chunk.order,
|
'chunk_order': chunk.order,
|
||||||
}
|
}
|
||||||
|
# نتیجهی بازبینیِ راهحل. عمداً فقط همین یک کلیدِ کوتاه میرود و نه
|
||||||
|
# `checked_answer`/`check_note`: آن دو در دیتابیس در دسترساند و اینجا فقط
|
||||||
|
# حجم اضافه میکردند. نامش هم عمداً با هیچکدام از کلیدهایی که
|
||||||
|
# `retrieval._payload_text`/`_payload_title` میخوانند یکی نیست، پس یک
|
||||||
|
# نویسه هم به متنی که به مدل میرسد اضافه نمیکند — همان اشتباهی که یک بار
|
||||||
|
# با `summary` رخ داد و context را خراب کرد.
|
||||||
|
if chunk.solution_verdict:
|
||||||
|
payload['solution_verdict'] = chunk.solution_verdict
|
||||||
if chunk.lesson_id is not None:
|
if chunk.lesson_id is not None:
|
||||||
payload['lesson_id'] = chunk.lesson_id
|
payload['lesson_id'] = chunk.lesson_id
|
||||||
if chunk.source_page is not None:
|
if chunk.source_page is not None:
|
||||||
|
|||||||
@@ -103,6 +103,16 @@ def extract_problem_types(image, grade: str, chapter_name: str) -> dict:
|
|||||||
"""
|
"""
|
||||||
یک عکسِ صفحهی کتاب → ساختارِ JSONِ انواعِ مسئله (فارسی)، با مدلِ vision.
|
یک عکسِ صفحهی کتاب → ساختارِ JSONِ انواعِ مسئله (فارسی)، با مدلِ vision.
|
||||||
خروجی همون شکلیست که uploadِ ریاضیِ تیمِ دیگه هم تولید میکرد.
|
خروجی همون شکلیست که uploadِ ریاضیِ تیمِ دیگه هم تولید میکرد.
|
||||||
|
|
||||||
|
⚠️ قاعدهی «self-contained» در پرامپت تازه اضافه شده و کتابهایی که تا امروز
|
||||||
|
وارد شدهاند بدونِ آن استخراج شدهاند. اثرش با عدد: از ۲٬۱۱۹ مسئلهی موجود،
|
||||||
|
۸۸۱ تا (۴۲٪) به «شکلِ زیر» یا «جدولِ روبهرو» ارجاع میدهند و بدونِ آن تصویر
|
||||||
|
قابلِ حل نیستند — نه برای دانشآموز، نه برای دستیار، نه برای ساختِ سؤال.
|
||||||
|
|
||||||
|
نکته این است که مدل آن شکل را **دیده بود**؛ محدودیتِ vision نبود، فقط از او
|
||||||
|
خواسته نشده بود آنچه میبیند را بنویسد. برای همین با ingestِ دوبارهی همان
|
||||||
|
صفحهها قابلِ جبران است، ولی چون هزینه دارد فعلاً انجام نشده. هر کتابِ
|
||||||
|
تازهای که از این پس وارد شود این مشکل را ندارد.
|
||||||
"""
|
"""
|
||||||
prompt = f"""
|
prompt = f"""
|
||||||
You are {teacher_persona(grade)}.
|
You are {teacher_persona(grade)}.
|
||||||
@@ -121,6 +131,15 @@ Rules:
|
|||||||
- exampleFromImage must be a real problem from the image (not invented).
|
- exampleFromImage must be a real problem from the image (not invented).
|
||||||
- Keep the mathematical notation of the textbook; do not simplify the concept
|
- Keep the mathematical notation of the textbook; do not simplify the concept
|
||||||
to a lower grade level than "{grade}".
|
to a lower grade level than "{grade}".
|
||||||
|
- SELF-CONTAINED PROBLEMS. You can see the figures, tables and diagrams on this
|
||||||
|
page, but the reader of your output cannot. Whenever a problem refers to one
|
||||||
|
("the shape below", "the table opposite", "the given diagram"), write what it
|
||||||
|
shows INTO the problem text itself: the measurements, the vertex labels, the
|
||||||
|
angles, the table's values, what is shaded. Someone with only your text must
|
||||||
|
be able to solve it. If a figure genuinely cannot be put into words (a freehand
|
||||||
|
drawing task, a colouring exercise), skip that problem instead of writing a
|
||||||
|
reference to something the reader will never see.
|
||||||
|
- The same applies to the solution: no step may depend on looking at the page.
|
||||||
- If the page has no math problem at all (cover, foreword, table of contents,
|
- If the page has no math problem at all (cover, foreword, table of contents,
|
||||||
a page of pure text), return an empty "problemTypes" list instead of inventing one.
|
a page of pure text), return an empty "problemTypes" list instead of inventing one.
|
||||||
- Output ONLY the JSON object. No markdown, no backticks.
|
- Output ONLY the JSON object. No markdown, no backticks.
|
||||||
|
|||||||
@@ -54,6 +54,25 @@ def delete_points(collection: str, point_ids: list[str]) -> int:
|
|||||||
return len(point_ids)
|
return len(point_ids)
|
||||||
|
|
||||||
|
|
||||||
|
def set_payload_fields(collection: str, point_ids: list[str], fields: dict) -> int:
|
||||||
|
"""
|
||||||
|
فقط چند کلیدِ payload را روی نقاطِ موجود مینویسد، بدونِ دست زدن به بردار.
|
||||||
|
|
||||||
|
برای همگامسازیِ برچسبها همین کافی است و از upsertِ کامل خیلی ارزانتر:
|
||||||
|
نه بردار دوباره فرستاده میشود نه embedding دوباره خریده میشود.
|
||||||
|
"""
|
||||||
|
if not point_ids or not fields:
|
||||||
|
return 0
|
||||||
|
client = clients.get_qdrant_client()
|
||||||
|
client.set_payload(
|
||||||
|
collection_name=collection,
|
||||||
|
payload=fields,
|
||||||
|
points=[str(i) for i in point_ids],
|
||||||
|
wait=True,
|
||||||
|
)
|
||||||
|
return len(point_ids)
|
||||||
|
|
||||||
|
|
||||||
def search(collection: str, query_vector: list[float], *, lesson_filter_value=None,
|
def search(collection: str, query_vector: list[float], *, lesson_filter_value=None,
|
||||||
lesson_filter_key: str | None = None, limit: int = 4) -> list[dict]:
|
lesson_filter_key: str | None = None, limit: int = 4) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
94
rag/tests.py
94
rag/tests.py
@@ -6,6 +6,7 @@ from types import SimpleNamespace
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
from django.core.management import call_command
|
||||||
from django.test import TestCase, override_settings
|
from django.test import TestCase, override_settings
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
@@ -911,3 +912,96 @@ class IngestUpsertRollbackTests(TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(LessonChunk.objects.filter(collection='ریاضی نهم').count(), 0)
|
self.assertEqual(LessonChunk.objects.filter(collection='ریاضی نهم').count(), 0)
|
||||||
|
|
||||||
|
|
||||||
|
class VerifySolutionsLimitTests(TestCase):
|
||||||
|
"""
|
||||||
|
دو خطای ۴۲۹ هست و نباید یکسان با آنها رفتار کرد.
|
||||||
|
|
||||||
|
فشارِ لحظهایِ موازیکاری گذراست و عقبنشینی حلش میکند؛ ولی سقفِ مصرفِ
|
||||||
|
حساب ساعتها باز نمیشود. یک اجرا ۱۱۹ بار دوباره تلاش کرد، ۶۵ دقیقه طول
|
||||||
|
کشید، هیچ chunkی سنجیده نشد، و مهلتِ باز شدن را هم جلوتر برد.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.chunk = LessonChunk.objects.create(
|
||||||
|
subject='ریاضی', lesson_number=1, order=1, title='توان',
|
||||||
|
original_text='x', summary='x', collection='ریاضی نهم',
|
||||||
|
problem='حاصل ۲^۳ چند است؟', solution='۸',
|
||||||
|
)
|
||||||
|
|
||||||
|
@override_settings(**RAG_ON)
|
||||||
|
def test_account_limit_stops_at_once_instead_of_retrying(self):
|
||||||
|
from django.core.management.base import CommandError
|
||||||
|
|
||||||
|
error = Exception("Error code: 429 - {'code': 'initial_usage_limit_exceeded'}")
|
||||||
|
client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(
|
||||||
|
create=lambda **kw: (_ for _ in ()).throw(error))))
|
||||||
|
|
||||||
|
with patch('rag.services.clients.get_openai_client', return_value=client), \
|
||||||
|
patch('rag.management.commands.rag_verify_solutions.time.sleep') as slept:
|
||||||
|
with self.assertRaises(CommandError) as caught:
|
||||||
|
call_command('rag_verify_solutions', '--collection', 'ریاضی نهم')
|
||||||
|
|
||||||
|
self.assertIn('سقفِ مصرف', str(caught.exception))
|
||||||
|
slept.assert_not_called() # حتی یک بار هم نباید صبر کرده باشد
|
||||||
|
|
||||||
|
@override_settings(**RAG_ON)
|
||||||
|
def test_an_exhausted_quota_also_stops_at_once(self):
|
||||||
|
"""
|
||||||
|
۴۰۳ با متنِ «insufficient user quota» — با فاصله، نه زیرخط.
|
||||||
|
|
||||||
|
محافظِ اول دنبالِ `insufficient_quota` میگشت و این را نمیگرفت؛ یک
|
||||||
|
اجرا ۷۷۰ فراخوانیِ محکومبهشکست زد بهجای اینکه سرِ اولی بایستد.
|
||||||
|
"""
|
||||||
|
from django.core.management.base import CommandError
|
||||||
|
|
||||||
|
error = Exception("Error code: 403 - {'message': 'insufficient user quota, "
|
||||||
|
"remaining quota: $-0.1'}")
|
||||||
|
client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(
|
||||||
|
create=lambda **kw: (_ for _ in ()).throw(error))))
|
||||||
|
|
||||||
|
with patch('rag.services.clients.get_openai_client', return_value=client), \
|
||||||
|
patch('rag.management.commands.rag_verify_solutions.time.sleep') as slept:
|
||||||
|
with self.assertRaises(CommandError):
|
||||||
|
call_command('rag_verify_solutions', '--collection', 'ریاضی نهم')
|
||||||
|
slept.assert_not_called()
|
||||||
|
|
||||||
|
@override_settings(**RAG_ON)
|
||||||
|
def test_a_transient_429_is_still_retried(self):
|
||||||
|
calls = {'n': 0}
|
||||||
|
|
||||||
|
def flaky(**kwargs):
|
||||||
|
calls['n'] += 1
|
||||||
|
if calls['n'] == 1:
|
||||||
|
raise Exception('Error code: 429 - rate limit, slow down')
|
||||||
|
return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(
|
||||||
|
content='{"verdict": "agrees", "answer": "۸", "why": ""}'))])
|
||||||
|
|
||||||
|
client = SimpleNamespace(chat=SimpleNamespace(
|
||||||
|
completions=SimpleNamespace(create=flaky)))
|
||||||
|
with patch('rag.services.clients.get_openai_client', return_value=client), \
|
||||||
|
patch('rag.management.commands.rag_verify_solutions.time.sleep'):
|
||||||
|
call_command('rag_verify_solutions', '--collection', 'ریاضی نهم')
|
||||||
|
|
||||||
|
self.chunk.refresh_from_db()
|
||||||
|
self.assertEqual(self.chunk.solution_verdict, LessonChunk.VERDICT_AGREES)
|
||||||
|
self.assertEqual(calls['n'], 2, 'باید دقیقاً یک بار دوباره تلاش میکرد.')
|
||||||
|
|
||||||
|
@override_settings(**RAG_ON)
|
||||||
|
def test_figure_dependent_problems_are_labelled_without_any_call(self):
|
||||||
|
"""هزینهی صفر برای چیزی که جوابش از متن درنمیآید."""
|
||||||
|
self.chunk.problem = 'مساحت شکلِ زیر را حساب کنید.'
|
||||||
|
self.chunk.save(update_fields=['problem'])
|
||||||
|
|
||||||
|
def boom(**kwargs):
|
||||||
|
raise AssertionError('نباید هیچ فراخوانیای میشد.')
|
||||||
|
|
||||||
|
client = SimpleNamespace(chat=SimpleNamespace(
|
||||||
|
completions=SimpleNamespace(create=boom)))
|
||||||
|
with patch('rag.services.clients.get_openai_client', return_value=client):
|
||||||
|
call_command('rag_verify_solutions', '--collection', 'ریاضی نهم')
|
||||||
|
|
||||||
|
self.chunk.refresh_from_db()
|
||||||
|
self.assertEqual(self.chunk.solution_verdict, LessonChunk.VERDICT_UNJUDGEABLE)
|
||||||
|
self.assertEqual(self.chunk.checked_by, 'pre-filter')
|
||||||
|
|||||||
Reference in New Issue
Block a user