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:
0
rag/services/__init__.py
Normal file
0
rag/services/__init__.py
Normal file
42
rag/services/answer_cache.py
Normal file
42
rag/services/answer_cache.py
Normal 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
92
rag/services/chunking.py
Normal 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
55
rag/services/clients.py
Normal 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
44
rag/services/config.py
Normal 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
18
rag/services/embedding.py
Normal 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
|
||||
98
rag/services/generation.py
Normal file
98
rag/services/generation.py
Normal 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
138
rag/services/ingest.py
Normal 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],
|
||||
}
|
||||
102
rag/services/known_collections.py
Normal file
102
rag/services/known_collections.py
Normal 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
126
rag/services/pdf.py
Normal 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()
|
||||
66
rag/services/qdrant_store.py
Normal file
66
rag/services/qdrant_store.py
Normal 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
129
rag/services/retrieval.py
Normal 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
35
rag/services/tutor.py
Normal 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
|
||||
Reference in New Issue
Block a user