senior's post-demo changes: parent report in the student's own account, Jalali birth date, invoices, math-only tutor, priced quotas
- remove the family app from the root (separate parent accounts, invite codes, parental settings); progress.0006 drops its tables, permissions and migration history, and Profile.parent_phone, the two family notification types and ?userId= on /api/progress/nodes go with it - GET /api/user/weekly-report: last 7 days next to the 7 before (estimated minutes, questions, accuracy, islands, tutor questions) for the "parent report" page - profile birthDate in Jalali (accounts/jalali.py), ages 5-99, birthYear derived from it - GET /api/payment/invoices; orders now snapshot list price and plan title - tutor answers only math: server pre-filter, scope rule in the prompt (tutor-v11), refusal marker replaced with fixed text; self-harm gets a help line instead - streamed answers now record token usage (it was always 0) - daily tutor quota: AI_DAILY_QUOTA_FREE (default 3) vs Plan.ai_daily_quota - delete_users management command for wiping test accounts across every app - docs/ai-cost-and-guardrails.md: measured cost per question and guardrail results Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,7 @@ from django.conf import settings
|
||||
|
||||
from . import clients
|
||||
from . import diagram as diagram_mod
|
||||
from . import guardrails
|
||||
from .mathtext import StreamSanitizer, to_plain
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -38,7 +39,7 @@ _RAW_MARKUP = re.compile(r'\\[A-Za-z(\[]|\$|\*\*|^\s*#{1,6}\s|^\s*\|\s*:?-{3,}',
|
||||
# بشود گفت «کدام نسخهی پرامپت جوابِ بهتری میداد» — بدونِ این، همهی فیدبکها
|
||||
# در یک کاسه میریزند و مقایسهی نسخهها ممکن نیست. کشِ پاسخ هم با همین نسخه
|
||||
# کلید میخورد، پس جوابِ نسخهی قبلی بعد از دیپلوی دوباره سرو نمیشود.
|
||||
PROMPT_VERSION = 'tutor-v10-shape-anchor'
|
||||
PROMPT_VERSION = 'tutor-v11-math-only'
|
||||
|
||||
_PRIMARY = {'اول', 'دوم', 'سوم', 'چهارم', 'پنجم', 'ششم'}
|
||||
_MIDDLE = {'هفتم', 'هشتم', 'نهم'}
|
||||
@@ -47,6 +48,8 @@ _UPPER = {'دهم', 'یازدهم', 'دوازدهم'}
|
||||
# چند پیامِ آخرِ گفتگو که همراهِ سوالِ تازه فرستاده میشود. سه رفتوبرگشت
|
||||
# برای «نفهمیدم» و «یه مثال دیگه» کافی است و هزینهی توکن را محدود نگه میدارد.
|
||||
HISTORY_TURNS = 6
|
||||
# stream_answer پیش از فرستادنِ اولین تکه اینقدر حرف جمع میکند (برای تشخیصِ علامتِ رد).
|
||||
_HOLD_CHARS = 40
|
||||
_HISTORY_CHARS = 1500
|
||||
|
||||
|
||||
@@ -142,9 +145,7 @@ _METHOD = (
|
||||
'- اگر دانشآموز میگوید نفهمیده یا توضیحِ بیشتر میخواهد، از گفتگوی قبلی '
|
||||
'بفهم دربارهی چیست و همان را سادهتر، با راهی دیگر یا مثالی تازه توضیح بده.\n'
|
||||
'- در پایانِ حلِ یک مسئله، یک تمرینِ کوتاهِ مشابه بده: «حالا خودت امتحان کن: …» '
|
||||
'(بدونِ جوابش).\n'
|
||||
'- اگر سوال ربطی به درس و مدرسه ندارد، مؤدبانه و کوتاه بگو که کارت کمک در '
|
||||
'ریاضی است و بچه را به درس برگردان.'
|
||||
'(بدونِ جوابش).'
|
||||
)
|
||||
|
||||
|
||||
@@ -158,6 +159,7 @@ def system_prompt(grade_title: str = '') -> str:
|
||||
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'\n\n{diagram_mod.DIAGRAM_RULE}'
|
||||
)
|
||||
@@ -297,6 +299,9 @@ def generate_answer(question: str, context_chunks: list[dict] | None,
|
||||
# سوالِ ادامهای («حالا ۱۰ دقیقه بعد؟») با سوالِ قبلی سنجیده میشود
|
||||
diagram = diagram_mod.fits(f'{question}\n{focus}\n{_previous_question(history)}', diagram)
|
||||
answer = to_plain(raw, fraction_word=grade_tier(grade_title) == 'primary')
|
||||
refused = guardrails.OFF_TOPIC_MARKER in raw
|
||||
if refused:
|
||||
answer, diagram = guardrails.REFUSAL, None
|
||||
if not answer:
|
||||
answer = 'متأسفم، الان نتونستم پاسخِ مناسبی بسازم. لطفاً دوباره بپرس.'
|
||||
|
||||
@@ -320,8 +325,9 @@ def generate_answer(question: str, context_chunks: list[dict] | None,
|
||||
# providerی که نفرستد None میدهد.
|
||||
'costUsd': getattr(usage, 'cost', None),
|
||||
'diagram': diagram,
|
||||
'refused': refused,
|
||||
}
|
||||
return answer, list(_DEFAULT_FOLLOW_UPS), meta
|
||||
return answer, ([] if refused else list(_DEFAULT_FOLLOW_UPS)), meta
|
||||
|
||||
|
||||
def stream_answer(question: str, context_chunks: list[dict] | None,
|
||||
@@ -343,21 +349,56 @@ def stream_answer(question: str, context_chunks: list[dict] | None,
|
||||
model=model,
|
||||
messages=build_messages(question, context_chunks, grade_title, history, focus),
|
||||
stream=True,
|
||||
# آخرین تکهی استریم usage را میآورد؛ بدونِ این، هزینهی جوابهای اپ ثبت نمیشد.
|
||||
stream_options={'include_usage': True},
|
||||
**completion_kwargs(model),
|
||||
)
|
||||
sanitizer = StreamSanitizer(fraction_word=grade_tier(grade_title) == 'primary')
|
||||
splitter = diagram_mod.StreamSplitter()
|
||||
# چند حرفِ اول نگه داشته میشود تا اگر مدل با علامتِ «خارج از ریاضی» جواب داد،
|
||||
# حتی یک تکه از آن به کاربر نرسد و جایش متنِ ثابتِ رد برود.
|
||||
held = ''
|
||||
released = False
|
||||
refused = False
|
||||
for event in stream:
|
||||
if not event.choices:
|
||||
usage = getattr(event, 'usage', None)
|
||||
if usage is not None and result is not None:
|
||||
result['usage'] = {
|
||||
'promptTokens': getattr(usage, 'prompt_tokens', 0) or 0,
|
||||
'completionTokens': getattr(usage, 'completion_tokens', 0) or 0,
|
||||
}
|
||||
if not event.choices or refused:
|
||||
continue
|
||||
delta = event.choices[0].delta.content or ''
|
||||
if delta:
|
||||
clean = sanitizer.feed(splitter.feed(delta))
|
||||
if clean:
|
||||
yield clean
|
||||
if not delta:
|
||||
continue
|
||||
clean = sanitizer.feed(splitter.feed(delta))
|
||||
if not clean:
|
||||
continue
|
||||
if released:
|
||||
yield clean.replace(guardrails.OFF_TOPIC_MARKER, '')
|
||||
continue
|
||||
held += clean
|
||||
if guardrails.OFF_TOPIC_MARKER in held:
|
||||
refused = True
|
||||
elif len(held) >= _HOLD_CHARS:
|
||||
released = True
|
||||
yield held
|
||||
tail = sanitizer.feed(splitter.flush()) + sanitizer.flush()
|
||||
if not released and not refused:
|
||||
held += tail
|
||||
tail = ''
|
||||
refused = guardrails.OFF_TOPIC_MARKER in held
|
||||
if result is not None:
|
||||
result['diagram'] = diagram_mod.fits(f'{question}\n{focus}\n{_previous_question(history)}', splitter.diagram)
|
||||
result['refused'] = refused
|
||||
result['diagram'] = None if refused else diagram_mod.fits(
|
||||
f'{question}\n{focus}\n{_previous_question(history)}', splitter.diagram)
|
||||
if refused:
|
||||
yield guardrails.REFUSAL
|
||||
return
|
||||
if not released and held.strip():
|
||||
yield held
|
||||
tail = tail.replace(guardrails.OFF_TOPIC_MARKER, '')
|
||||
if tail.strip():
|
||||
yield tail
|
||||
|
||||
|
||||
Reference in New Issue
Block a user