Files
MathApp/rag/services/retrieval.py
Zareian 42783f9ba4 tutor answers step by step in plain text, islands finish, and the audit's money and login holes are closed
Tutor (prompt tutor-v3-stepwise-plain): plain text instead of LaTeX/markdown,
with rag/services/mathtext.py converting whatever still slips through; full
step-by-step solutions with a check before the final answer; three tones by
grade; the last 6 messages of the conversation go to the model and follow-ups
are no longer served from another user's cache; book solutions the checker
disputes are kept out of the context; a questionId gives the tutor the
practice question and the student's wrong answer. /api/health reports when
the AI credit runs out. rag_model_benchmark compares chat models on verified
book problems (run on the server; credit ran out mid-run).

Map: only lessons that have questions count toward finishing an island,
lessonIds[0] / nextLessonId is always the next lesson to play, and a finished
island stays finished. The Seyar characters (sigma/delta/pi) are seeded.

Audit fixes, each reproduced before fixing:
- the plan is stored on the order, so paying a monthly order after starting
  a yearly checkout no longer grants a year
- opening checkout no longer demotes an active subscription; renewals extend
  from the current expiry; expired subscriptions are not reported active
- one discount code per order and only before the gateway; a 100% code
  completes the purchase; lost Zarinpal confirmations are re-verified
- the unused /api/payment/webhook "mark paid" endpoint is removed
- XP counts only answers recorded on the server
- 6-digit OTP, each code dies after 5 wrong guesses; POST /api/auth/logout
- a guest who signs up keeps their account and progress (test phones excluded)
- affiliate commission on the first real payment; guests excluded
- parents with several children no longer get a 500
- SQLite runs transactions IMMEDIATE, since it ignores select_for_update
- admin login is rate-limited; display names drop phone numbers and links;
  userQuestion is capped at 2000 characters
- server-side heart enforcement exists behind HEARTS_ENFORCED (off until the
  frontend handles 403 no_hearts)

Not done on purpose: rotating the server's secret key and moving to Postgres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 06:24:13 +03:30

138 lines
5.6 KiB
Python

"""
مرحله‌ی 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`
⚠️ راه‌حلی که `rag_verify_solutions` با آن مخالفت کرده (`disagrees`) به مدل
نمی‌رسد؛ فقط روش و صورتِ مسئله می‌رود. پیش‌تر همان راه‌حلِ غلط (مثلاً «در
[−π, π] دو نوسانِ کامل») به‌عنوانِ «متنِ درس» به دستیار داده می‌شد و دستیار
تکرارش می‌کرد — حدودِ ۱۳٪ تکه‌های کتاب‌های دهم تا دوازدهم این‌طورند.
"""
if payload.get('summary'):
return payload['summary']
solution = payload.get('solution', '')
if payload.get('solution_verdict') == 'disagrees':
solution = ''
parts = [
('روش', payload.get('generalSolution', '')),
('مسئله', payload.get('problem', '')),
('راه‌حلِ کتاب', solution),
]
joined = '\n'.join(f'{label}: {text}' for label, text in parts if text).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 ''
)