draw a diagram in tutor answers: validated JSON from the model, rendered by the app; add the fourth companion tedi

The model ends a shape question with <diagram>{json}</diagram>; rag/services/diagram.py
splits it off before mathtext, validates it against a whitelist and stores it on
Message.diagram (ai_tutor.0006). It reaches the app in the SSE done event and in
conversation history. Prompt tutor-v6-diagrams; a hint next to the question (shape
words only) raised gpt-4.1-mini from 3/12 to 12/12 diagrams, 0/8 on arithmetic.

progress.0005 adds tedi (600 xp) to the companion catalog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 17:44:44 +03:30
parent 74c3ee5e86
commit 33d8f43d74
10 changed files with 581 additions and 9 deletions

View File

@@ -25,6 +25,7 @@ from collections.abc import Iterator
from django.conf import settings
from . import clients
from . import diagram as diagram_mod
from .mathtext import StreamSanitizer, to_plain
logger = logging.getLogger(__name__)
@@ -37,7 +38,7 @@ _RAW_MARKUP = re.compile(r'\\[A-Za-z(\[]|\$|\*\*|^\s*#{1,6}\s|^\s*\|\s*:?-{3,}',
# بشود گفت «کدام نسخه‌ی پرامپت جوابِ بهتری می‌داد» — بدونِ این، همه‌ی فیدبک‌ها
# در یک کاسه می‌ریزند و مقایسه‌ی نسخه‌ها ممکن نیست. کشِ پاسخ هم با همین نسخه
# کلید می‌خورد، پس جوابِ نسخه‌ی قبلی بعد از دیپلوی دوباره سرو نمی‌شود.
PROMPT_VERSION = 'tutor-v5-slash-in-question'
PROMPT_VERSION = 'tutor-v6-diagrams'
_PRIMARY = {'اول', 'دوم', 'سوم', 'چهارم', 'پنجم', 'ششم'}
_MIDDLE = {'هفتم', 'هشتم', 'نهم'}
@@ -158,6 +159,7 @@ def system_prompt(grade_title: str = '') -> str:
f'تو «پی» هستی، دستیارِ ریاضیِ اپِ آموزشیِ تدریسینو، و مثلِ یک {teacher} '
f'با یک {student} حرف می‌زنی. همیشه فارسی جواب بده.\n\n'
f'{_TONE[grade_tier(grade_title)]}\n\n{_METHOD}\n\n{_FORMAT.format(numbers=numbers)}'
f'\n\n{diagram_mod.DIAGRAM_RULE}'
)
@@ -176,13 +178,16 @@ _SLASH_READING = (
def _user_prompt(question: str, contexts: list[str], focus: str = '',
primary: bool = False) -> str:
asked = f'سوالِ دانش‌آموز: {question}'
shape_hint = diagram_mod.hint_for(f'{question}\n{focus}')
if shape_hint:
asked = f'{asked}\n\n{shape_hint}'
slash_rule = primary and '/' in question
if slash_rule:
asked = f'{_SLASH_READING}\n\n{asked}'
if focus:
asked = f'{focus}\n\n{asked}'
if not contexts:
return asked if (focus or slash_rule) else question
return asked if (focus or slash_rule or shape_hint) else question
# این جمله برای کتاب‌های دبیرستان نوشته شده بود (۳/۱۰۴ = 3.104) ولی به همه
# می‌رفت، و gpt-4.1-mini با آن هر کسرِ دبستانی را ممیز می‌خواند.
slash_note = (
@@ -273,6 +278,8 @@ def generate_answer(question: str, context_chunks: list[dict] | None,
latency_ms = int((time.monotonic() - started) * 1000)
raw = (completion.choices[0].message.content or '').strip()
# بلوکِ شکل پیش از to_plain جدا می‌شود؛ to_plain کسرها و «*» را در JSON عوض می‌کرد.
raw, diagram = diagram_mod.extract(raw)
answer = to_plain(raw, fraction_word=grade_tier(grade_title) == 'primary')
if not answer:
answer = 'متأسفم، الان نتونستم پاسخِ مناسبی بسازم. لطفاً دوباره بپرس.'
@@ -296,14 +303,18 @@ def generate_answer(question: str, context_chunks: list[dict] | None,
# GapGPT هزینه‌ی دقیقِ هر فراخوانی را (به دلار) در usage می‌فرستد؛
# providerی که نفرستد None می‌دهد.
'costUsd': getattr(usage, 'cost', None),
'diagram': diagram,
}
return answer, list(_DEFAULT_FOLLOW_UPS), meta
def stream_answer(question: str, context_chunks: list[dict] | None,
grade_title: str = '', history: list[dict] | None = None,
focus: str = '') -> Iterator[str]:
focus: str = '', result: dict | None = None) -> Iterator[str]:
"""
`result` (اختیاری) بعد از پایانِ استریم کلیدِ 'diagram' را می‌گیرد: شکلِ
اعتبارسنجی‌شده یا None. بلوکِ شکل هیچ‌وقت به‌صورتِ متن yield نمی‌شود.
همون generate_answer ولی تکه‌به‌تکه (برای SSE). فقط متنِ پاسخ را yield
می‌کند؛ follow_upها ثابت‌اند و caller خودش اضافه‌شان می‌کند.
@@ -319,16 +330,19 @@ def stream_answer(question: str, context_chunks: list[dict] | None,
**completion_kwargs(model),
)
sanitizer = StreamSanitizer(fraction_word=grade_tier(grade_title) == 'primary')
splitter = diagram_mod.StreamSplitter()
for event in stream:
if not event.choices:
continue
delta = event.choices[0].delta.content or ''
if delta:
clean = sanitizer.feed(delta)
clean = sanitizer.feed(splitter.feed(delta))
if clean:
yield clean
tail = sanitizer.flush()
if tail:
tail = sanitizer.feed(splitter.flush()) + sanitizer.flush()
if result is not None:
result['diagram'] = splitter.diagram
if tail.strip():
yield tail