new app — cleaned-up RAG pipeline (Qdrant retrieval + GPT), ported from Rag_backend

Self-contained app; wired into settings/urls in the next commit. Data
stays in the parallel team's live Qdrant — nothing is copied into our DB.

apps.py
  - RagConfig.
models.py
  - LessonChunk: DB-of-record for one summarized lesson chunk
    (original_text + summary + embedding_vector) mirrored as a single
    Qdrant point; loose lesson_id/grade_id ints, no cross-app FK.
    LessonChunkImage for future multimodal lessons.
migrations/0001_initial.py
  - initial tables for the two models.
services/clients.py
  - lazy, lru_cache'd OpenAI + Qdrant clients (Rag_backend builds them at
    import time and crashes without keys). RagNotConfigured raised when a
    key/lib is missing; Qdrant client uses check_compatibility=False for
    the 1.19-client / 1.17-server minor version gap.
services/config.py
  - rag_is_configured(), collection-name template, grade-title resolution
    (RAG_GRADE_TITLE_OVERRIDES -> content.Grade.title), retrieval top-k.
services/known_collections.py
  - exact live Qdrant catalog: 6 collection names, math chapter-number ->
    exact "فصل N: ..." string tables per (subject, grade), and the
    علوم/اجتماعی lesson-number -> title maps. filter_key_for_subject()
    picks `chapter` for math, `lesson_number` otherwise;
    resolve_math_chapter() maps a plain chapter number (Persian or Latin
    digits) to the stored string.
services/embedding.py
  - embed_text() pinned to OPENAI_EMBEDDING_MODEL (text-embedding-3-small,
    1536d) so vectors match the other team's existing collections.
services/chunking.py
  - split_text_into_chunks(), summarize_chunk() (gpt-4o-mini, JSON out);
    plain exceptions instead of ninja HttpError.
services/qdrant_store.py
  - ensure_collection(), upsert_points(), search(): the lesson filter
    matches the value both as str and native, since Qdrant stores
    lesson_number as int and chapter as string.
services/retrieval.py
  - resolve_target() -> (collection, filter_key, filter_value), reused by
    the cache key and debug block.
  - retrieve_context() -> (chunks, meta): embed -> Qdrant search scoped to
    one lesson/chapter -> top-k. Math payloads (no `summary`) fall back to
    generalSolution/problem/solution; a 404 collection returns empty.
services/generation.py
  - generate_answer() and stream_answer() (token iterator for SSE);
    context-grounded Persian prompt that explains from retrieved text
    rather than refusing.
services/pdf.py
  - textbook PDF -> page images -> gpt-4o vision problem-type extraction
    (adapted from Rag_backend mathapp); returns dicts, no on-disk JSON.
    pymupdf/pdf2image optional — missing libs raise RagNotConfigured.
services/ingest.py
  - ingest_text() / ingest_pdf(): chunk -> summarize -> embed -> persist
    LessonChunk + upsert Qdrant, in one transaction.
services/answer_cache.py
  - short-TTL cache of the final answer keyed by
    sha1(collection | filter_value | normalized-question).
services/tutor.py
  - thin answer_question() (retrieve -> generate) for direct callers.
serializers.py, urls.py, views.py
  - POST /api/rag/ingest/text, POST /api/rag/ingest/pdf (HasInternalServiceKey,
    throttle scope rag_ingest), GET /api/rag/chunks review list.
    RagProviderUnavailable -> clean 503.
admin.py
  - LessonChunk admin with an inline for its images.
management/commands/rag_catalog.py
  - read-only live Qdrant listing (--values, --json); never writes.
tests.py
  - mocked-client tests: ask-with-retrieval, ingest, SSE stream, answer
    cache, and rag_is_configured() gating.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 02:29:54 +03:30
parent 38999f3541
commit ac93e17305
26 changed files with 1661 additions and 0 deletions

0
rag/services/__init__.py Normal file
View File

View File

@@ -0,0 +1,42 @@
"""
کشِ پاسخِ RAG برای سوالِ عیناً تکراری.
کلید = هشِ (collection + مقدارِ فیلترِ درس/فصل + متنِ نرمال‌شده‌ی سوال).
فقط مرحله‌ی گران (embedding + جستجوی Qdrant + تولیدِ GPT) رو رد می‌کنه؛
کوتای روزانه، idempotency و ثبتِ Message همچنان در view اجرا می‌شن.
"""
import hashlib
from django.conf import settings
from django.core.cache import cache
_PREFIX = 'rag:ans:v1:'
def ttl() -> int:
return int(getattr(settings, 'RAG_ANSWER_CACHE_TTL', 0))
def enabled() -> bool:
return ttl() > 0
def _norm(text: str) -> str:
return ' '.join((text or '').split()).strip().lower()
def make_key(collection, filter_value, question: str) -> str:
raw = f'{collection}|{filter_value}|{_norm(question)}'
return _PREFIX + hashlib.sha1(raw.encode('utf-8')).hexdigest()
def get(key: str) -> dict | None:
if not enabled():
return None
return cache.get(key)
def set(key: str, payload: dict) -> None: # noqa: A003
if not enabled():
return
cache.set(key, payload, ttl())

92
rag/services/chunking.py Normal file
View File

@@ -0,0 +1,92 @@
"""
تقسیمِ متنِ درس به تکه‌های مستقل + خلاصه‌سازیِ آموزشیِ هر تکه.
برداشتِ پاک‌سازی‌شده از subjects/services/chunker.py مخزنِ Rag_backend:
- به‌جای HttpError نینجا، از استثناهای معمولی استفاده می‌کنیم (لایه‌ی view
در DRF تبدیلش می‌کنه).
- کلاینت lazy است.
"""
import json
from django.conf import settings
from . import clients
class ChunkingError(RuntimeError):
pass
def split_text_into_chunks(text: str, chunk_size: int = 400, overlap: int = 100) -> list[str]:
if not text:
return []
chunks: list[str] = []
start = 0
text_length = len(text)
step = max(chunk_size - overlap, 1)
while start < text_length:
end = min(start + chunk_size, text_length)
chunks.append(text[start:end])
start += step
return chunks
def _strip_code_fence(raw: str) -> str:
raw = raw.strip()
if raw.startswith('```json'):
raw = raw[len('```json'):]
elif raw.startswith('```'):
raw = raw[len('```'):]
if raw.endswith('```'):
raw = raw[:-3]
return raw.strip()
def summarize_chunk(chunk_text: str) -> str:
"""خلاصه‌ی مستقل و آموزشیِ یک تکه به فارسی."""
prompt = f"""
You are an expert Persian educational summarizer.
Summarize the following text clearly and independently.
- The summary must be self-contained.
- Capture all key educational points.
- Write in natural Persian (Farsi).
Text:
{chunk_text}
Return JSON only:
{{"summary": "خلاصه کامل این بخش در فارسی"}}
"""
client = clients.get_openai_client()
response = client.chat.completions.create(
model=settings.OPENAI_CHAT_MODEL,
messages=[
{'role': 'system', 'content': 'You are a helpful assistant that always outputs valid JSON.'},
{'role': 'user', 'content': prompt},
],
max_tokens=400,
temperature=0.4,
top_p=0.9,
)
raw = _strip_code_fence(response.choices[0].message.content or '')
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
raise ChunkingError(f'خروجیِ خلاصه‌سازی JSON معتبر نبود: {raw[:300]}') from exc
summary = data.get('summary')
if not summary:
raise ChunkingError(f'کلیدِ summary در خروجی نبود: {raw[:300]}')
return summary
def chunk_and_summarize(text: str, chunk_size: int = 400, overlap: int = 100) -> list[dict]:
"""[{order, original_text, summary}, ...]"""
result = []
for index, original in enumerate(split_text_into_chunks(text, chunk_size, overlap), start=1):
result.append({
'order': index,
'original_text': original,
'summary': summarize_chunk(original),
})
return result

55
rag/services/clients.py Normal file
View File

@@ -0,0 +1,55 @@
"""
کلاینت‌های OpenAI و Qdrant به‌صورتِ lazy (نه در سطحِ ماژول).
مخزنِ Rag_backend این‌ها رو موقعِ import می‌ساخت؛ نتیجه این بود که بدونِ
کلید/شبکه، خودِ import هم crash می‌کرد. اینجا کلاینت فقط وقتی واقعاً لازم
بشه ساخته می‌شه و بینِ فراخوانی‌ها cache می‌مونه.
"""
from functools import lru_cache
from django.conf import settings
class RagNotConfigured(RuntimeError):
"""کلید/کتابخانه‌ی لازم برای مسیرِ RAG موجود نیست."""
@lru_cache(maxsize=1)
def get_openai_client():
if not settings.OPENAI_API_KEY:
raise RagNotConfigured('OPENAI_API_KEY تنظیم نشده است.')
try:
from openai import OpenAI
except ModuleNotFoundError as exc:
raise RagNotConfigured('پکیجِ openai نصب نیست (pip install -r requirements.txt).') from exc
return OpenAI(
base_url=settings.OPENAI_BASE_URL,
api_key=settings.OPENAI_API_KEY,
timeout=settings.OPENAI_TIMEOUT_SECONDS,
)
@lru_cache(maxsize=1)
def get_qdrant_client():
if not settings.QDRANT_API_KEY:
raise RagNotConfigured('QDRANT_API_KEY تنظیم نشده است.')
try:
from qdrant_client import QdrantClient
except ModuleNotFoundError as exc:
raise RagNotConfigured('پکیجِ qdrant-client نصب نیست (pip install -r requirements.txt).') from exc
# check_compatibility=False: کلاینتِ نصب‌شده (1.19) با سرورِ تیمِ دیگه
# (1.17) اختلافِ minor داره و کلاینت warning می‌ده؛ API‌هایی که استفاده
# می‌کنیم (get_collections/upsert/query_points/scroll) پایدارن.
return QdrantClient(
url=settings.QDRANT_URL,
api_key=settings.QDRANT_API_KEY,
check_compatibility=False,
)
def reset_clients_cache():
"""برای تست‌ها / بعد از تغییرِ settings در زمانِ اجرا."""
get_openai_client.cache_clear()
get_qdrant_client.cache_clear()

44
rag/services/config.py Normal file
View File

@@ -0,0 +1,44 @@
"""
هم‌خوانیِ نامِ collection و کلیدِ فیلترِ Qdrant با دادهٔ موجودِ تیمِ دیگر.
در Rag_backend نامِ collection این‌طور ساخته می‌شه:
f"{subject.name} {subject.grade.title}".lower()
و فیلترِ درس با کلیدِ payloadِ "lesson_number" (برای درس‌های متنی) یا
"chapter" (برای ریاضی) انجام می‌شه. اینجا هر دو از settings قابلِ تنظیم‌ان
چون اسمِ دقیقِ گرید حساسه (کاربر قبلاً با اسمِ نادرست به مشکل خورده بود).
"""
from django.conf import settings
def rag_is_configured() -> bool:
return bool(
settings.OPENAI_API_KEY
and settings.QDRANT_API_KEY
and not settings.RAG_FORCE_MOCK
)
def resolve_grade_title(grade_id, fallback: str = '') -> str:
"""gradeId → عنوانِ گرید همان‌طور که در نامِ collectionِ تیمِ دیگه اومده."""
overrides = getattr(settings, 'RAG_GRADE_TITLE_OVERRIDES', {}) or {}
if grade_id in overrides:
return str(overrides[grade_id])
if grade_id is not None:
try:
from content.models import Grade
grade = Grade.objects.filter(pk=grade_id).only('title').first()
if grade is not None:
return grade.title
except Exception:
pass
return fallback
def build_collection_name(subject: str, grade_title: str) -> str:
template = getattr(settings, 'RAG_COLLECTION_NAME_TEMPLATE', '{subject} {grade}')
return template.format(subject=(subject or '').strip(), grade=(grade_title or '').strip()).lower()
def retrieval_top_k() -> int:
return int(getattr(settings, 'RAG_RETRIEVAL_TOP_K', 4))

18
rag/services/embedding.py Normal file
View File

@@ -0,0 +1,18 @@
"""تولیدِ embedding با همون مدلی که collectionهای Qdrant باهاش ساخته شدن."""
from django.conf import settings
from . import clients
def embed_text(text: str) -> list[float]:
"""
برداریِ یک متن. مدل باید دقیقاً OPENAI_EMBEDDING_MODEL باشه (پیش‌فرض
text-embedding-3-small، بُعد ۱۵۳۶) وگرنه با نقاطِ موجودِ collection
هم‌خوان نیست.
"""
client = clients.get_openai_client()
response = client.embeddings.create(
model=settings.OPENAI_EMBEDDING_MODEL,
input=text[:8000],
)
return response.data[0].embedding

View File

@@ -0,0 +1,98 @@
"""
مرحله‌ی generation: پرسش + context بازیابی‌شده → پاسخِ فارسی + چند پیشنهادِ
ادامه.
اگه context خالی باشه، مدل به‌عنوانِ معلمِ عمومی جواب می‌ده (بدونِ ادعای
استناد به کتاب) — این حالت وقتی پیش می‌آد که subject/lessonNumber ارسال
نشده یا اون درس هنوز در Qdrant نیست.
"""
import logging
from collections.abc import Iterator
from django.conf import settings
from . import clients
logger = logging.getLogger(__name__)
_DEFAULT_FOLLOW_UPS = ['یک مثال دیگه بزن', 'بیشتر توضیح بده']
_SYSTEM = 'تو یک معلمِ فارسی‌زبانِ دبستانی هستی و پاسخ‌ها را روشن، ساده و کوتاه می‌دهی.'
def _prompt_with_context(question: str, contexts: list[str]) -> str:
joined = '\n\n---\n\n'.join(contexts)
return f"""
تو معلمِ دبستانی هستی. با تکیه بر «متنِ درس» زیر و با زبانِ خودت، به سوالِ
دانش‌آموز پاسخِ روشن و ساده بده. لازم نیست پاسخ عیناً در متن باشد؛ می‌توانی
از اطلاعاتِ متن نتیجه‌گیری و توضیح بدهی.
فقط اگر متنِ درس آشکارا درباره‌ی موضوعِ دیگری‌ست و هیچ ربطی به سوال ندارد،
بنویس: «این موضوع در این درس پوشش داده نشده است.»
متنِ درس:
{joined}
سوالِ دانش‌آموز: {question}
"""
def _prompt_without_context(question: str) -> str:
return f"""
به این سوالِ یک دانش‌آموزِ دبستانی به فارسی، ساده و کوتاه پاسخ بده.
سوال: {question}
"""
def generate_answer(question: str, context_chunks: list[dict] | None) -> tuple[str, list[str]]:
"""
context_chunks: خروجیِ retrieve_context ([{title, summary, score}, ...]) یا None/[].
خروجی: (answer, follow_ups)
"""
contexts = [c['summary'] for c in (context_chunks or []) if c.get('summary')]
prompt = _prompt_with_context(question, contexts) if contexts else _prompt_without_context(question)
client = clients.get_openai_client()
completion = client.chat.completions.create(
model=settings.OPENAI_CHAT_MODEL,
messages=[
{'role': 'system', 'content': _SYSTEM},
{'role': 'user', 'content': prompt},
],
temperature=0.3,
)
answer = (completion.choices[0].message.content or '').strip()
if not answer:
answer = 'متأسفم، الان نتونستم پاسخِ مناسبی بسازم. لطفاً دوباره بپرس.'
return answer, list(_DEFAULT_FOLLOW_UPS)
def stream_answer(question: str, context_chunks: list[dict] | None) -> Iterator[str]:
"""
همون generate_answer ولی توکن‌به‌توکن (برای SSE). فقط قطعه‌های متنِ پاسخ
را yield می‌کند؛ follow_upها ثابت‌اند (_DEFAULT_FOLLOW_UPS) و caller خودش
اضافه‌شان می‌کند.
"""
contexts = [c['summary'] for c in (context_chunks or []) if c.get('summary')]
prompt = _prompt_with_context(question, contexts) if contexts else _prompt_without_context(question)
client = clients.get_openai_client()
stream = client.chat.completions.create(
model=settings.OPENAI_CHAT_MODEL,
messages=[
{'role': 'system', 'content': _SYSTEM},
{'role': 'user', 'content': prompt},
],
temperature=0.3,
stream=True,
)
for event in stream:
if not event.choices:
continue
delta = event.choices[0].delta.content or ''
if delta:
yield delta
def default_follow_ups() -> list[str]:
return list(_DEFAULT_FOLLOW_UPS)

138
rag/services/ingest.py Normal file
View File

@@ -0,0 +1,138 @@
"""
هماهنگ‌کننده‌ی ingestion: متن/PDF درس → chunk/خلاصه/embedding → ذخیره در
DB (LessonChunk، منبعِ حقیقت) + upsert به Qdrant (index).
نامِ collection و کلیدهای payload عمداً همون قراردادِ Rag_backend را دنبال
می‌کنن تا با دادهٔ موجودِ تیمِ دیگه سازگار بمونه.
"""
import logging
import os
import tempfile
from django.db import transaction
from ..models import LessonChunk
from .chunking import chunk_and_summarize
from .config import build_collection_name, resolve_grade_title
from .embedding import embed_text
from . import pdf as pdf_service
from .qdrant_store import ensure_collection, upsert_points
logger = logging.getLogger(__name__)
def _payload(chunk: LessonChunk) -> dict:
return {
'lesson_id': chunk.lesson_id,
'lesson_number': chunk.lesson_number,
'chapter_number': chunk.chapter,
'chunk_order': chunk.order,
'lesson_title': chunk.title,
'summary': chunk.summary,
}
def _persist_and_upsert(rows: list[dict], *, lesson_id, grade_id, subject,
lesson_number, chapter, title, collection) -> list[LessonChunk]:
ensure_collection(collection)
created: list[LessonChunk] = []
points: list[dict] = []
with transaction.atomic():
for row in rows:
vector = embed_text(row['summary'])
chunk = LessonChunk.objects.create(
lesson_id=lesson_id,
grade_id=grade_id,
subject=subject,
lesson_number=lesson_number,
chapter=chapter,
order=row['order'],
title=title,
original_text=row['original_text'],
summary=row['summary'],
embedding_vector=vector,
collection=collection,
)
created.append(chunk)
points.append({'id': chunk.qdrant_point_id, 'vector': vector, 'payload': _payload(chunk)})
upsert_points(collection, points)
return created
def ingest_text(*, lesson_id: int, grade_id, subject: str, lesson_number: int,
text: str, chapter: int | None = None, title: str = '') -> dict:
grade_title = resolve_grade_title(grade_id)
collection = build_collection_name(subject, grade_title)
title = title or f'lesson_{lesson_number}'
rows = chunk_and_summarize(text)
if not rows:
return {'status': 'empty', 'chunks_created': 0, 'collection': collection}
created = _persist_and_upsert(
rows, lesson_id=lesson_id, grade_id=grade_id, subject=subject,
lesson_number=lesson_number, chapter=chapter, title=title, collection=collection,
)
return {
'status': 'success',
'collection': collection,
'chunks_created': len(created),
'chunk_ids': [c.id for c in created],
}
def ingest_pdf(*, lesson_id: int, grade_id, subject: str, lesson_number: int,
pdf_bytes: bytes, chapter: int | None = None, title: str = '',
chapter_name: str = '') -> dict:
"""
هر صفحه‌ی PDF → عکس → استخراجِ انواعِ مسئله با vision → هر problemType یک
chunk. برای درس‌های متنی به‌جای این از ingest_text استفاده کن.
"""
grade_title = resolve_grade_title(grade_id)
collection = build_collection_name(subject, grade_title)
title = title or f'lesson_{lesson_number}'
chapter_name = chapter_name or title
fd, tmp_path = tempfile.mkstemp(suffix='.pdf')
try:
with os.fdopen(fd, 'wb') as fh:
fh.write(pdf_bytes)
start_page, detected_grade = pdf_service.detect_grade_and_start_page(tmp_path)
images = pdf_service.extract_page_images(tmp_path, first_page=start_page)
rows: list[dict] = []
order = 0
for image in images:
try:
parsed = pdf_service.extract_problem_types(image, detected_grade or grade_title, chapter_name)
except Exception:
logger.exception('rag.ingest_pdf: تحلیلِ یک صفحه شکست خورد؛ رد شد')
continue
for problem_type in parsed.get('problemTypes', []) or []:
text = pdf_service.problem_type_to_text(problem_type)
if not text:
continue
order += 1
rows.append({'order': order, 'original_text': text, 'summary': text})
finally:
try:
os.remove(tmp_path)
except OSError:
pass
if not rows:
return {'status': 'empty', 'chunks_created': 0, 'collection': collection}
created = _persist_and_upsert(
rows, lesson_id=lesson_id, grade_id=grade_id, subject=subject,
lesson_number=lesson_number, chapter=chapter, title=title, collection=collection,
)
return {
'status': 'success',
'collection': collection,
'chunks_created': len(created),
'chunk_ids': [c.id for c in created],
}

View File

@@ -0,0 +1,102 @@
"""
کاتالوگِ دقیقِ collectionهای موجود روی Qdrant تیمِ دیگر
(qdrant.tadrisino2.ir) — استخراج‌شده و بررسی‌شده در ۱۴۰۵/۰۶/۰۶ (2026-08-28).
هدف: کاربر لازم نباشه رشته‌های دقیقِ گرید/فصل رو حفظ کنه. با داشتنِ subject و
یک شماره‌ی ساده (شماره‌ی درس یا شماره‌ی فصل)، retrieval خودش:
- کلیدِ فیلترِ درست رو انتخاب می‌کنه (ریاضی → chapter ، بقیه → lesson_number)
- برای ریاضی، شماره‌ی فصل رو به رشته‌ی دقیقِ فصل تبدیل می‌کنه.
اگه تیمِ دیگه collection جدید اضافه/تغییر داد، فقط همین فایل به‌روز بشه.
"""
# --- درس‌های متنی: payload کلیدِ `lesson_number` (رشته) + `summary` دارد ---
# نامِ collection = f"{subject} {grade}".lower()
TEXT_SUBJECT_LESSON_FILTER_KEY = 'lesson_number'
# --- ریاضی: payload کلیدِ `chapter` (رشته) + `generalSolution` دارد، `summary` ندارد ---
MATH_LESSON_FILTER_KEY = 'chapter'
# subjectهایی که ساختارِ «ریاضی» دارند (فیلتر با chapter، متن از generalSolution)
MATH_SUBJECTS = {'ریاضی'}
# فصول ریاضی به تفکیکِ (subject, grade) — کلیدِ داخلی هم شماره‌ی فصل و هم
# رشته‌ی کامل رو می‌پذیره.
MATH_CHAPTERS = {
('ریاضی', 'سوم'): {
1: 'فصل ۱: الگوها',
2: 'فصل ۲: عددهای چهار رقمی',
3: 'فصل ۳: عددهای کسری',
4: 'فصل ۴: ضرب و تقسیم',
5: 'فصل ۵: محیط و مساحت',
6: 'فصل ۶: جمع و تفریق',
7: 'فصل ۷: آمار و احتمال',
8: 'فصل ۸: ضرب عددها',
},
('ریاضی', 'ششم'): {
1: 'فصل ۱: عدد و الگوهای عددی',
2: 'فصل ۲: کسر',
3: 'فصل ۳: اعداد اعشاری',
4: 'فصل ۴: تقارن و مختصات',
5: 'فصل ۵: اندازه گیری',
6: 'فصل ۶: تناسب و درصد',
7: 'فصل ۷: تقریب',
},
('ریاضی', 'هفتم'): {
1: 'فصل ۱: راهبردهای حل مسئله',
2: 'فصل ۲: عددهای صحیح',
3: 'فصل ۳: جبر و معادله',
4: 'فصل ۴: هندسه و استدلال',
5: 'فصل ۵: شمارنده‌ها و اعداد اول',
6: 'فصل ۶: سطح و حجم',
7: 'فصل ۷: توان و جذر',
8: 'فصل ۸: بردار و مختصات',
9: 'فصل ۹: آمار و احتمال',
},
}
# درس‌های علوم/اجتماعی — فقط برای مرجع/اعتبارسنجی (lesson_number → عنوان).
TEXT_LESSONS = {
('علوم تجربی', 'سوم'): {
1: 'زنگ علوم', 2: 'خوراکی‌ها', 3: 'اندازه‌گیری مواد', 4: 'مواد اطراف ما',
5: 'آب ماده‌ی باارزش', 6: 'زندگی ما و آب', 7: 'نور و مشاهده‌ی اجسام',
8: 'جستجو کنیم و بسازیم', 9: 'نیرو همه‌جا ۱', 10: 'نیرو همه‌جا ۲',
11: 'بکارید و ببینید', 12: 'هرکدام جای خود ۱', 13: 'هرکدام جای خود ۲',
14: 'از گذشته تا آینده(نگهداری مواد غذایی)',
},
('مطالعات اجتماعی', 'ششم'): {1: 'lesson_1', 2: 'lesson_2', 3: 'lesson_3', 9: 'lesson_9'},
('مطالعات اجتماعی', 'چهارم'): {1: 'lesson_1', 2: 'lesson_2', 3: 'lesson_3'},
}
# فهرستِ کاملِ collectionهای موجود (نامِ نهایی، lowercase).
KNOWN_COLLECTIONS = {
'ریاضی سوم', 'ریاضی ششم', 'ریاضی هفتم',
'علوم تجربی سوم', 'مطالعات اجتماعی ششم', 'مطالعات اجتماعی چهارم',
}
def is_math_subject(subject: str) -> bool:
return (subject or '').strip() in MATH_SUBJECTS
def filter_key_for_subject(subject: str) -> str:
return MATH_LESSON_FILTER_KEY if is_math_subject(subject) else TEXT_SUBJECT_LESSON_FILTER_KEY
def resolve_math_chapter(subject: str, grade: str, lesson_or_chapter) -> str | None:
"""
برای ریاضی: ورودی می‌تونه شماره‌ی فصل (2 / '2' / '۲') یا خودِ رشته‌ی
کامل باشه. خروجی رشته‌ی دقیقِ `chapter` در Qdrant یا None.
"""
if lesson_or_chapter is None:
return None
table = MATH_CHAPTERS.get((( subject or '').strip(), (grade or '').strip()))
text = str(lesson_or_chapter).strip()
if text.startswith('فصل'):
return text
# نرمال‌سازی ارقامِ فارسی → لاتین
trans = str.maketrans('۰۱۲۳۴۵۶۷۸۹', '0123456789')
digits = text.translate(trans)
if table and digits.isdigit() and int(digits) in table:
return table[int(digits)]
return text or None

126
rag/services/pdf.py Normal file
View File

@@ -0,0 +1,126 @@
"""
استخراج از PDF کتابِ درسی + تحلیلِ عکسِ صفحه با مدلِ vision.
برداشتِ پاک‌سازی‌شده از mathapp/services/read_pdf.py و info_question.py مخزنِ
Rag_backend. به‌جای نوشتنِ نتیجه در یک فایلِ JSON روی دیسک، دیکشنری
برمی‌گردونه تا لایه‌ی ingest تصمیم بگیره.
pymupdf (fitz) و pdf2image اختیاری‌ان؛ نبودشون فقط این ماژول رو از کار
می‌ندازه (RagNotConfigured)، نه بقیه‌ی پروژه رو.
"""
import base64
import json
from io import BytesIO
from django.conf import settings
from . import clients
from .clients import RagNotConfigured
def extract_page_images(pdf_path: str, first_page: int = 1, dpi: int = 200):
"""لیستی از تصاویرِ PIL برای صفحاتِ PDF از first_page به بعد."""
try:
from pdf2image import convert_from_path
except ModuleNotFoundError as exc:
raise RagNotConfigured('pdf2image نصب نیست یا poppler موجود نیست.') from exc
return convert_from_path(pdf_path, dpi=dpi, first_page=first_page)
def detect_grade_and_start_page(pdf_path: str) -> tuple[int, str | None]:
"""
از متنِ صفحه‌ی اولِ PDF، پایه‌ی تحصیلی و صفحه‌ای که محتوای واقعی از اونجا
شروع می‌شه رو حدس می‌زنه (مثلِ نسخه‌ی Rag_backend).
"""
try:
import fitz # PyMuPDF
except ModuleNotFoundError as exc:
raise RagNotConfigured('pymupdf (fitz) نصب نیست.') from exc
start_pages = {'پنجم دبستان': 11, 'ششم دبستان': 9}
grades = ['هفتم', 'اول دبستان', 'دوم دبستان', 'سوم دبستان',
'چهارم دبستان', 'پنجم دبستان', 'ششم دبستان']
doc = fitz.open(pdf_path)
try:
text = doc[0].get_text()
finally:
doc.close()
grade = next((g for g in grades if g in text), None)
return start_pages.get(grade, 7), grade
_VISION_JSON_TEMPLATE = """
{
"grade": "",
"chapter": "",
"problemTypes": [
{"type": "", "keywords": [], "generalSolution": "",
"exampleFromImage": {"problem": "", "solution": ""}}
]
}
"""
def extract_problem_types(image, grade: str, chapter_name: str) -> dict:
"""
یک عکسِ صفحه‌ی کتاب → ساختارِ JSONِ انواعِ مسئله (فارسی)، با مدلِ vision.
خروجی همون شکلی‌ست که uploadِ ریاضیِ تیمِ دیگه هم تولید می‌کرد.
"""
prompt = f"""
You are a specialist elementary math teacher for children aged 8 to 12.
Analyze the uploaded image and extract every unique math problem type for the given chapter.
For each type produce: type, keywords (3-7, Persian), generalSolution (step-by-step),
exampleFromImage (one real problem from the image + full solution).
Output must follow EXACTLY this JSON structure:
{_VISION_JSON_TEMPLATE}
Rules:
- Everything in Persian (Farsi).
- grade = "{grade}"
- chapter = "{chapter_name}"
- exampleFromImage must be a real problem from the image (not invented).
- Output ONLY the JSON object. No markdown, no backticks.
"""
buffered = BytesIO()
image.save(buffered, format='JPEG')
img_b64 = base64.b64encode(buffered.getvalue()).decode()
client = clients.get_openai_client()
response = client.chat.completions.create(
model=settings.OPENAI_VISION_MODEL,
messages=[
{'role': 'system', 'content': 'You process educational visual content and respond ONLY with valid JSON.'},
{'role': 'user', 'content': [
{'type': 'text', 'text': prompt},
{'type': 'image_url', 'image_url': {'url': f'data:image/jpeg;base64,{img_b64}', 'detail': 'high'}},
]},
],
max_tokens=3000,
temperature=0.1,
)
raw = (response.choices[0].message.content or '').strip()
if raw.startswith('```json'):
raw = raw[len('```json'):].strip()
if raw.startswith('```'):
raw = raw[3:].strip()
if raw.endswith('```'):
raw = raw[:-3].strip()
return json.loads(raw)
def problem_type_to_text(problem_type: dict) -> str:
"""یک problemType → متنِ قابلِ embedding (مثلِ doc_text در Rag_backend)."""
example = problem_type.get('exampleFromImage', {}) or {}
keywords = ' '.join(problem_type.get('keywords', []) or [])
return (
f"Type: {problem_type.get('type', '')}\n"
f"Keywords: {keywords}\n"
f"General solution: {problem_type.get('generalSolution', '')}\n"
f"Example problem: {example.get('problem', '')}\n"
f"Example solution: {example.get('solution', '')}"
).strip()

View File

@@ -0,0 +1,66 @@
"""
لایه‌ی نازک روی Qdrant: ساختِ collection، upsert نقاط، و جستجوی برداری.
برداشتِ پاک‌سازی‌شده از subjects/services/qdrant.py مخزنِ Rag_backend.
هیچ کلاینتِ سطحِ ماژولی ساخته نمی‌شه؛ همه از get_qdrant_client() lazy.
"""
from django.conf import settings
from . import clients
def ensure_collection(name: str, vector_size: int | None = None) -> None:
client = clients.get_qdrant_client()
vector_size = vector_size or settings.OPENAI_EMBEDDING_DIM
existing = {c.name for c in client.get_collections().collections}
if name in existing:
return
from qdrant_client.models import Distance, VectorParams
client.create_collection(
collection_name=name,
vectors_config=VectorParams(size=vector_size, distance=Distance.COSINE),
)
def upsert_points(collection: str, points: list[dict]) -> int:
"""points: [{id, vector, payload}, ...]"""
if not points:
return 0
client = clients.get_qdrant_client()
from qdrant_client.models import PointStruct
structs = [
PointStruct(id=str(p['id']), vector=p['vector'], payload=p['payload'])
for p in points
]
client.upsert(collection_name=collection, points=structs, wait=True)
return len(structs)
def search(collection: str, query_vector: list[float], *, lesson_filter_value=None,
lesson_filter_key: str | None = None, limit: int = 4) -> list[dict]:
"""
نزدیک‌ترین نقاط به بردارِ پرسش، در صورتِ نیاز محدود به یک درس.
خروجی: [{score, payload}, ...] — payload همون چیزیه که تیمِ دیگه ذخیره
کرده (معمولاً شاملِ summary / lesson_title / lesson_number).
"""
client = clients.get_qdrant_client()
query_filter = None
if lesson_filter_value is not None and lesson_filter_key:
from qdrant_client.models import FieldCondition, Filter, MatchValue
# دادهٔ تیمِ دیگه lesson_number/chapter را همیشه به‌صورتِ رشته ذخیره
# کرده ("5")، پس مقدار را به رشته تبدیل می‌کنیم تا فیلتر بخورد.
query_filter = Filter(should=[
FieldCondition(key=lesson_filter_key, match=MatchValue(value=str(lesson_filter_value))),
FieldCondition(key=lesson_filter_key, match=MatchValue(value=lesson_filter_value)),
])
response = client.query_points(
collection_name=collection,
query=query_vector,
limit=limit,
query_filter=query_filter,
)
return [{'score': p.score, 'payload': p.payload or {}} for p in response.points]

129
rag/services/retrieval.py Normal file
View File

@@ -0,0 +1,129 @@
"""
مرحله‌ی retrieval: پرسشِ کاربر → embedding → جستجوی Qdrant → چند خلاصه‌ی
مرتبط به‌عنوانِ context.
از UnexpectedResponseِ Qdrant (مثلاً collection موجود نیست چون این درس
هنوز آپلود نشده) به‌جای crash، لیستِ خالی برمی‌گردونیم تا لایه‌ی بالا
بتونه پاسخِ «این درس هنوز پوشش داده نشده» بده.
"""
import logging
from .config import build_collection_name, resolve_grade_title, retrieval_top_k
from .embedding import embed_text
from .known_collections import filter_key_for_subject, is_math_subject, resolve_math_chapter
from .qdrant_store import search
logger = logging.getLogger(__name__)
def resolve_target(subject: str, grade_title: str, grade_id, lesson_number):
"""
(collection, filter_key, filter_value) — مقصدِ دقیقِ retrieval در Qdrant.
ریاضی با کلیدِ payloadِ `chapter` (رشته‌ی کاملِ فصل) فیلتر می‌شه و شماره‌ی
ساده‌ی فصل به همون رشته تبدیل می‌شه؛ بقیه با `lesson_number` (که در Qdrant
عددِ int ذخیره شده). فقط برای دیباگ/نمایشِ داخلی هم استفاده می‌شه.
"""
grade_title = grade_title or resolve_grade_title(grade_id)
if not subject or not grade_title:
return None, None, None
collection = build_collection_name(subject, grade_title)
filter_key = filter_key_for_subject(subject)
if is_math_subject(subject):
filter_value = resolve_math_chapter(subject, grade_title, lesson_number)
else:
filter_value = lesson_number
return collection, filter_key, filter_value
def retrieve_context(question: str, *, subject: str, grade_id=None, grade_title: str = '',
lesson_number=None, limit: int | None = None) -> tuple[list[dict], dict]:
"""
خروجی: (chunks, meta)
chunks = [{title, summary, score}, ...] (ممکنه خالی باشه، بدونِ خطا)
meta = {collection, filterKey, filterValue, gradeTitle, chunkCount}
فقط برای نمایشِ داخلی/دیباگ (نه برای کاربرِ نهایی).
"""
grade_title = grade_title or resolve_grade_title(grade_id)
collection, filter_key, filter_value = resolve_target(subject, grade_title, grade_id, lesson_number)
meta = {
'collection': collection,
'filterKey': filter_key if filter_value is not None else None,
'filterValue': filter_value,
'gradeTitle': grade_title,
'chunkCount': 0,
}
if collection is None:
logger.info('rag.retrieve: subject/grade ناقص (subject=%r grade=%r) — رد شد', subject, grade_title)
return [], meta
limit = limit or retrieval_top_k()
try:
query_vector = embed_text(question)
except Exception:
logger.exception('rag.retrieve: embedding شکست خورد')
raise
try:
from qdrant_client.http.exceptions import UnexpectedResponse
except ModuleNotFoundError:
UnexpectedResponse = () # type: ignore
try:
hits = search(
collection,
query_vector,
lesson_filter_value=filter_value,
lesson_filter_key=filter_key if filter_value is not None else None,
limit=limit,
)
except Exception as exc: # noqa: BLE001
if UnexpectedResponse and isinstance(exc, UnexpectedResponse) and getattr(exc, 'status_code', None) == 404:
logger.info('rag.retrieve: collection %r موجود نیست', collection)
return [], meta
logger.exception('rag.retrieve: جستجوی Qdrant در collection %r شکست خورد', collection)
raise
context = []
for hit in hits:
payload = hit['payload']
summary = _payload_text(payload)
if not summary:
continue
context.append({
'title': _payload_title(payload),
'summary': summary,
'score': hit.get('score'),
})
meta['chunkCount'] = len(context)
return context, meta
def _payload_text(payload: dict) -> str:
"""
دو شکلِ payload در دادهٔ تیمِ دیگه هست:
- درس‌های متنی (علوم/اجتماعی): کلیدِ `summary`
- ریاضی: کلیدِ `generalSolution` (+ problem/solution) و بدونِ `summary`
"""
if payload.get('summary'):
return payload['summary']
parts = [
payload.get('generalSolution', ''),
payload.get('problem', ''),
payload.get('solution', ''),
]
joined = '\n'.join(p for p in parts if p).strip()
return joined or payload.get('text', '')
def _payload_title(payload: dict) -> str:
return (
payload.get('lesson_title')
or payload.get('title')
or payload.get('type')
or payload.get('chapter')
or ''
)

35
rag/services/tutor.py Normal file
View File

@@ -0,0 +1,35 @@
"""
نقطه‌ی ورودِ RAG برای ai_tutor.
ai_tutor.views.generate_tutor_answer وقتی rag_is_configured() باشه این را
صدا می‌زنه؛ در غیرِ این‌صورت به همون پاسخِ stubِ قبلی برمی‌گرده.
"""
import logging
from .config import rag_is_configured # noqa: F401 (re-export برای ai_tutor)
from .generation import generate_answer
from .retrieval import retrieve_context
logger = logging.getLogger(__name__)
def answer_question(user_question: str, *, subject: str = '', grade_id=None,
lesson_number=None) -> tuple[str, list[str], list[dict]]:
"""
خروجی: (answer, follow_ups, used_chunks)
used_chunks = [{title, summary, score}, ...] — ممکنه خالی باشه وقتی
subject/lesson_number ارسال نشده یا اون درس هنوز در Qdrant نیست؛ در این
حالت پاسخ به‌صورتِ «تدریسِ عمومی» تولید می‌شه (بدونِ استناد به کتاب).
استثناهای شبکه/provider اینجا خفه نمی‌شن؛ لایه‌ی بالا (AskAIView) اون‌ها
رو به 503 AIProviderUnavailable تبدیل می‌کنه.
"""
context, _meta = retrieve_context(
user_question,
subject=subject,
grade_id=grade_id,
lesson_number=lesson_number,
)
answer, follow_ups = generate_answer(user_question, context)
return answer, follow_ups, context