public user IDs, plans priced by tutor cost, age-based tutor tone, self-service account deletion
- profile publicId ("User482917", accounts/public_id.py); admin shows and searches it
- plans API reports aiDailyQuota per plan and freeAiDailyQuota; migration 0007 sets the
temporary prices (monthly 99,000 / yearly 990,000 toman, 5 tutor questions a day) unless
an admin already edited the yearly plan
- tutor tone follows the learner's age (18+ gets an adult tone at any grade); notation
still follows the grade's book; answer cache keyed by tone (prompt tutor-v12-age-tone)
- POST /api/user/delete: phone users confirm with a fresh SMS code (same checks as login,
now shared in verify_phone_otp), guests type "حذف"; payment records are kept, demo
accounts refused
- delete_users --skip-paid
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -39,7 +39,7 @@ _RAW_MARKUP = re.compile(r'\\[A-Za-z(\[]|\$|\*\*|^\s*#{1,6}\s|^\s*\|\s*:?-{3,}',
|
||||
# بشود گفت «کدام نسخهی پرامپت جوابِ بهتری میداد» — بدونِ این، همهی فیدبکها
|
||||
# در یک کاسه میریزند و مقایسهی نسخهها ممکن نیست. کشِ پاسخ هم با همین نسخه
|
||||
# کلید میخورد، پس جوابِ نسخهی قبلی بعد از دیپلوی دوباره سرو نمیشود.
|
||||
PROMPT_VERSION = 'tutor-v11-math-only'
|
||||
PROMPT_VERSION = 'tutor-v12-age-tone'
|
||||
|
||||
_PRIMARY = {'اول', 'دوم', 'سوم', 'چهارم', 'پنجم', 'ششم'}
|
||||
_MIDDLE = {'هفتم', 'هشتم', 'نهم'}
|
||||
@@ -65,9 +65,24 @@ def grade_tier(grade_title: str) -> str:
|
||||
return ''
|
||||
|
||||
|
||||
def _persona(grade_title: str) -> tuple[str, str]:
|
||||
"""(توصیفِ معلم، توصیفِ دانشآموز) متناسب با پایه."""
|
||||
# از این سن به بعد لحن بزرگسالانه است، هر پایهای که انتخاب شده باشد (سینیور، ۲۰۲۶-۱۰-۰۲:
|
||||
# «ممکن است یک بیسواد بخواهد کلاسِ ابتدایی بردارد»). پایه فقط محتوا و نمادگذاری را تعیین میکند.
|
||||
ADULT_AGE = 18
|
||||
|
||||
|
||||
def tone_for(grade_title: str, age: int | None) -> str:
|
||||
"""کلیدِ _TONE: 'adult' برای بزرگسال، وگرنه سطحِ پایه."""
|
||||
if age is not None and age >= ADULT_AGE:
|
||||
return 'adult'
|
||||
return grade_tier(grade_title)
|
||||
|
||||
|
||||
def _persona(grade_title: str, age: int | None = None) -> tuple[str, str]:
|
||||
"""(توصیفِ معلم، توصیفِ یادگیرنده) متناسب با پایه و سن."""
|
||||
grade = (grade_title or '').strip()
|
||||
if tone_for(grade, age) == 'adult':
|
||||
level = f'ریاضیِ پایهی {grade}' if grade else 'ریاضی'
|
||||
return 'معلمِ ریاضیِ بزرگسالان', f'بزرگسالی که {level} را یاد میگیرد'
|
||||
tier = grade_tier(grade)
|
||||
if tier in ('middle', 'upper'):
|
||||
return f'معلمِ ریاضیِ پایهی {grade}', f'دانشآموزِ پایهی {grade}'
|
||||
@@ -98,6 +113,15 @@ _TONE = {
|
||||
'- شرطها را صریح چک کن: دامنه، مخرجِ مخالفِ صفر، زیرِ رادیکالِ نامنفی، '
|
||||
'علامتِ عبارت، واحدِ زاویه.'
|
||||
),
|
||||
'adult': (
|
||||
'با یک بزرگسال حرف میزنی؛ شاید سالها از درس دور بوده، شاید تازه سواد آموخته، شاید '
|
||||
'فقط میخواهد یک مبحث را یاد بگیرد. محترمانه، صبور و بیقضاوت.\n'
|
||||
'- هرگز لحنِ کودکانه نداشته باش: نه «عزیزم» و «آفرین کوچولو»، نه شکلک، نه مثالِ '
|
||||
'شکلات و اسباببازی.\n'
|
||||
'- مثالها از زندگیِ بزرگسال: خرید و تخفیف، قبض، اجاره، دستمزد، آشپزی، مسافت و زمان.\n'
|
||||
'- هیچ پیشدانستهای فرض نکن: هر اصطلاح را اولین بار با زبانِ ساده معنی کن و هر مرحله '
|
||||
'فقط یک کار باشد، حتی اگر مسئله ساده است.'
|
||||
),
|
||||
'': 'دوستانه، روشن و دقیق حرف بزن.',
|
||||
}
|
||||
|
||||
@@ -149,18 +173,19 @@ _METHOD = (
|
||||
)
|
||||
|
||||
|
||||
def system_prompt(grade_title: str = '') -> str:
|
||||
def system_prompt(grade_title: str = '', age: int | None = None) -> str:
|
||||
"""
|
||||
قبلاً «معلمِ دبستانی» ثابت بود؛ بعد فقط نامِ پایه در آن آمد. حالا لحن،
|
||||
روش (حلِ کامل + وارسی) و قالب (متنِ ساده) هر سه صریحاند.
|
||||
"""
|
||||
teacher, student = _persona(grade_title)
|
||||
teacher, student = _persona(grade_title, age)
|
||||
# نمادگذاریِ عدد از کتابِ همان پایه است، نه سنِ خواننده.
|
||||
numbers = _NUMBERS['primary' if grade_tier(grade_title) == 'primary' else 'default']
|
||||
return (
|
||||
f'تو «پی» هستی، دستیارِ ریاضیِ اپِ آموزشیِ تدریسینو، و مثلِ یک {teacher} '
|
||||
f'با یک {student} حرف میزنی. همیشه فارسی جواب بده.\n\n'
|
||||
f'{guardrails.SCOPE_RULE}\n\n'
|
||||
f'{_TONE[grade_tier(grade_title)]}\n\n{_METHOD}\n\n{_FORMAT.format(numbers=numbers)}'
|
||||
f'{_TONE[tone_for(grade_title, age)]}\n\n{_METHOD}\n\n{_FORMAT.format(numbers=numbers)}'
|
||||
f'\n\n{diagram_mod.DIAGRAM_RULE}'
|
||||
)
|
||||
|
||||
@@ -245,7 +270,7 @@ def completion_kwargs(model: str) -> dict:
|
||||
|
||||
def build_messages(question: str, context_chunks: list[dict] | None,
|
||||
grade_title: str = '', history: list[dict] | None = None,
|
||||
focus: str = '') -> list[dict]:
|
||||
focus: str = '', age: int | None = None) -> list[dict]:
|
||||
"""
|
||||
history: [{'role': 'user'|'assistant', 'text': ...}, ...] به ترتیبِ زمان.
|
||||
|
||||
@@ -253,7 +278,7 @@ def build_messages(question: str, context_chunks: list[dict] | None,
|
||||
دکمهی پیشنهادی نشان میدهد) برای مدل هیچ معنایی نداشت.
|
||||
"""
|
||||
contexts = [c['summary'] for c in (context_chunks or []) if c.get('summary')]
|
||||
messages = [{'role': 'system', 'content': system_prompt(grade_title)}]
|
||||
messages = [{'role': 'system', 'content': system_prompt(grade_title, age)}]
|
||||
for turn in (history or [])[-HISTORY_TURNS:]:
|
||||
role = 'assistant' if turn.get('role') == 'assistant' else 'user'
|
||||
text = (turn.get('text') or '').strip()
|
||||
@@ -273,7 +298,8 @@ def _previous_question(history) -> str:
|
||||
|
||||
def generate_answer(question: str, context_chunks: list[dict] | None,
|
||||
grade_title: str = '', history: list[dict] | None = None,
|
||||
model: str | None = None, focus: str = '') -> tuple[str, list[str], dict]:
|
||||
model: str | None = None, focus: str = '',
|
||||
age: int | None = None) -> tuple[str, list[str], dict]:
|
||||
"""
|
||||
context_chunks: خروجیِ retrieve_context ([{title, summary, score}, ...]) یا None/[].
|
||||
خروجی: (answer, follow_ups, meta)
|
||||
@@ -284,7 +310,7 @@ def generate_answer(question: str, context_chunks: list[dict] | None,
|
||||
`model` فقط برای مقایسهی مدلها (`rag_model_benchmark`) است.
|
||||
"""
|
||||
model = model or chat_model_for(grade_title)
|
||||
messages = build_messages(question, context_chunks, grade_title, history, focus)
|
||||
messages = build_messages(question, context_chunks, grade_title, history, focus, age)
|
||||
|
||||
client = clients.get_openai_client()
|
||||
started = time.monotonic()
|
||||
@@ -332,7 +358,7 @@ def generate_answer(question: str, context_chunks: list[dict] | None,
|
||||
|
||||
def stream_answer(question: str, context_chunks: list[dict] | None,
|
||||
grade_title: str = '', history: list[dict] | None = None,
|
||||
focus: str = '', result: dict | None = None) -> Iterator[str]:
|
||||
focus: str = '', result: dict | None = None, age: int | None = None) -> Iterator[str]:
|
||||
"""
|
||||
`result` (اختیاری) بعد از پایانِ استریم کلیدِ 'diagram' را میگیرد: شکلِ
|
||||
اعتبارسنجیشده یا None. بلوکِ شکل هیچوقت بهصورتِ متن yield نمیشود.
|
||||
@@ -347,7 +373,7 @@ def stream_answer(question: str, context_chunks: list[dict] | None,
|
||||
client = clients.get_openai_client()
|
||||
stream = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=build_messages(question, context_chunks, grade_title, history, focus),
|
||||
messages=build_messages(question, context_chunks, grade_title, history, focus, age),
|
||||
stream=True,
|
||||
# آخرین تکهی استریم usage را میآورد؛ بدونِ این، هزینهی جوابهای اپ ثبت نمیشد.
|
||||
stream_options={'include_usage': True},
|
||||
|
||||
@@ -138,3 +138,23 @@ class GuardrailThroughTheApiTests(TestCase):
|
||||
self.assertEqual(response.json()['answer'], guardrails.REFUSAL)
|
||||
self.assertEqual(response.json()['usedChunks'], [])
|
||||
self.assertEqual(Message.objects.get(role='assistant').text, guardrails.REFUSAL)
|
||||
|
||||
|
||||
class AgeToneTests(SimpleTestCase):
|
||||
"""لحن از سن، محتوا از پایه: بزرگسالی که سومِ دبستان برداشته لحنِ کودکانه نمیگیرد."""
|
||||
|
||||
def test_adult_in_a_primary_grade_gets_the_adult_tone_but_the_books_notation(self):
|
||||
from .services.generation import _NUMBERS, _TONE
|
||||
|
||||
prompt = system_prompt('سوم', age=40)
|
||||
self.assertIn(_TONE['adult'], prompt)
|
||||
self.assertNotIn(_TONE['primary'], prompt)
|
||||
self.assertIn(_NUMBERS['primary'], prompt)
|
||||
self.assertIn('بزرگسال', prompt.split('\n')[0])
|
||||
|
||||
def test_child_or_unknown_age_keeps_the_grade_tone(self):
|
||||
from .services.generation import _TONE
|
||||
|
||||
self.assertIn(_TONE['primary'], system_prompt('سوم', age=8))
|
||||
self.assertIn(_TONE['primary'], system_prompt('سوم'))
|
||||
self.assertIn(_TONE['upper'], system_prompt('دوازدهم', age=17))
|
||||
|
||||
Reference in New Issue
Block a user