Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .tdd/spec-mirror-teacher-continuity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Mirror teacher continuity v1

## Objective

Turn Mirror into a continuous, structured language-teacher chat without making
AI a dependency of deterministic lessons. Learner answers should advance the
exercise instead of restarting it, catalog words used in the conversation
should become visible learning history, and a short recent-history view should
be available in the authenticated Mini App profile.

## Acceptance criteria

1. The Russian phrase `что я проходил` is handled as a deterministic progress
request. The reply contains at most three clearly labelled sections and, when
available, names recently practised terms.
2. A short learner answer after a prior learning challenge is classified as
practice/correction, while an unrelated greeting remains general
conversation.
3. Learning replies are rendered as concise teacher turns: result, explanation
or example, and next action. Raw provider Markdown decoration is removed and
no more than three application-owned section markers are shown.
4. Catalog terms mentioned by the learner or tutor are recorded as exposures in
the existing word-progress namespace. Exposure updates `last_seen` and makes
the term visible in My words, but does not increment correct answers, wrong
answers, XP, or sessions.
5. The authenticated Mini App bootstrap exposes at most three complete recent
Mirror exchanges only when AI consent is granted and Mirror memory is
enabled. The history is rendered with `textContent` inside the existing
collapsed More statistics block and remains hidden when empty.
6. The Mirror provider contract explicitly forbids reusing an example or task
found in recent dialogue and advances after a correct answer.

## Edge cases

- Duplicate term matches are written only once per turn and exposure writes are
bounded to twelve terms.
- Erased or blocked users cannot record word exposure.
- Expired or incomplete Mirror exchanges are not exposed by the read-only
profile history query.
- Storage failures while recording exposure or dialogue history never suppress
an otherwise successful chat reply and never log learner text.

## Error behavior

- Read-only profile bootstrap must not prune, migrate, or otherwise mutate
dialogue rows.
- Missing history or a store without the optional read-only history capability
yields an empty history list.

## Out of scope

- An immutable full chat transcript archive.
- Automatic correctness scoring or XP based solely on an LLM response.
- Redesigning ordinary greetings or replacing deterministic cards, quizzes,
written practice, pronunciation, or spaced repetition.
33 changes: 33 additions & 0 deletions bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,10 @@
build_companion_learner_context,
build_mirror_progress_summary,
build_mirror_provider_payload,
catalog_word_exposures,
classify_mirror_intent,
classify_mirror_task,
classify_mirror_turn_task,
direct_mirror_capability_greeting_locale,
direct_mirror_daily_plan_locale,
direct_mirror_progress_locale,
Expand Down Expand Up @@ -5998,6 +6000,11 @@ async def start_thinking() -> None:
dialogue = recent_mirror_dialogue(context.user_data)
else:
dialogue = recent_mirror_dialogue(context.user_data)
if task_kind is None:
selected_task_kind = classify_mirror_turn_task(
question,
recent_dialogue=dialogue,
)
persona = str(
mirror_profile.get(
"mirror_persona_guidance",
Expand Down Expand Up @@ -6129,6 +6136,32 @@ async def start_thinking() -> None:
voice_renderer=voice_renderer,
locale=reply_locale,
)
if intent not in {"greeting", "capabilities"}:
try:
active_pack = CATALOG.get(str(profile.get("active_pack_id") or ""))
if active_pack is None:
active_pack = CATALOG.pack_for_language(
str(profile.get("active_lang") or ""),
str(profile.get("role") or "learner"),
)
exposure_writer = getattr(store, "record_word_exposures", None)
if active_pack is not None and callable(exposure_writer):
exposures = catalog_word_exposures(
CATALOG.words(active_pack),
question,
response,
)
if exposures:
exposure_writer(
user_id,
language=active_pack.storage_key,
entries=exposures,
)
except Exception as exc:
logger.warning(
"Mirror word exposure write failed: error_type=%s",
type(exc).__name__,
)
if (
intent not in {"greeting", "capabilities"}
and not deterministic_capability_greeting
Expand Down
3 changes: 3 additions & 0 deletions mydictionary/ai_tutor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1034,6 +1034,9 @@ def render_mirror_answer(

def clean(value: str) -> str:
plain = re.sub(r"```[A-Za-z0-9_-]*", "", str(value))
plain = re.sub(r"\*\*(.+?)\*\*", r"\1", plain)
plain = re.sub(r"__(.+?)__", r"\1", plain)
plain = plain.replace("**", "").replace("__", "")
plain = _MIRROR_APPLICATION_SECTION_MARKER_PATTERN.sub("", plain)
field_names = tuple(MIRROR_RESPONSE_SCHEMA["required"])
for field_name in field_names:
Expand Down
8 changes: 8 additions & 0 deletions mydictionary/localization.py
Original file line number Diff line number Diff line change
Expand Up @@ -4075,55 +4075,63 @@
_ZERKALO_COMMUNICATION_COPY = {
"en": {
"mirror_progress_facts": "Accuracy {accuracy}% · {tracked} words · {due} due · {streak}-day streak.",
"mirror_progress_recent_terms": "Recently practised: {terms}.",
"mirror_progress_focus_weak": "Focus now: review “{term}”.",
"mirror_progress_focus_due": "Focus now: complete {due} due reviews.",
"mirror_progress_no_history": "There is not enough learning history yet.",
"mirror_progress_focus_starter": "Start one short five-word lesson.",
},
"fr": {
"mirror_progress_facts": "Précision {accuracy} % · {tracked} mots · {due} à réviser · série de {streak} jours.",
"mirror_progress_recent_terms": "Travaillés récemment : {terms}.",
"mirror_progress_focus_weak": "Priorité : révisez « {term} ».",
"mirror_progress_focus_due": "Priorité : terminez les {due} révisions prévues.",
"mirror_progress_no_history": "Il n’y a pas encore assez d’historique d’apprentissage.",
"mirror_progress_focus_starter": "Commencez une courte leçon de cinq mots.",
},
"de": {
"mirror_progress_facts": "Genauigkeit {accuracy} % · {tracked} Wörter · {due} fällig · Serie: {streak} Tage.",
"mirror_progress_recent_terms": "Zuletzt geübt: {terms}.",
"mirror_progress_focus_weak": "Fokus jetzt: „{term}“ wiederholen.",
"mirror_progress_focus_due": "Fokus jetzt: {due} fällige Wiederholungen abschließen.",
"mirror_progress_no_history": "Es gibt noch nicht genug Lernverlauf.",
"mirror_progress_focus_starter": "Starte eine kurze Lektion mit fünf Wörtern.",
},
"ja": {
"mirror_progress_facts": "正答率 {accuracy}%・学習語 {tracked}・復習 {due}・連続 {streak}日。",
"mirror_progress_recent_terms": "最近練習した語:{terms}。",
"mirror_progress_focus_weak": "今の重点:「{term}」を復習しましょう。",
"mirror_progress_focus_due": "今の重点:期限の来た復習を {due} 件終えましょう。",
"mirror_progress_no_history": "学習履歴はまだ十分にありません。",
"mirror_progress_focus_starter": "5語の短いレッスンを始めましょう。",
},
"ar": {
"mirror_progress_facts": "الدقة {accuracy}% · الكلمات {tracked} · للمراجعة {due} · السلسلة {streak} أيام.",
"mirror_progress_recent_terms": "تم التدريب مؤخراً: {terms}.",
"mirror_progress_focus_weak": "التركيز الآن: راجع «{term}».",
"mirror_progress_focus_due": "التركيز الآن: أكمل {due} مراجعات مستحقة.",
"mirror_progress_no_history": "لا يوجد سجل تعلم كافٍ بعد.",
"mirror_progress_focus_starter": "ابدأ درساً قصيراً من خمس كلمات.",
},
"zh": {
"mirror_progress_facts": "正确率 {accuracy}% · 已学 {tracked} 词 · 待复习 {due} · 连续 {streak} 天。",
"mirror_progress_recent_terms": "最近练习:{terms}。",
"mirror_progress_focus_weak": "当前重点:复习“{term}”。",
"mirror_progress_focus_due": "当前重点:完成 {due} 项到期复习。",
"mirror_progress_no_history": "目前还没有足够的学习记录。",
"mirror_progress_focus_starter": "先开始一节五词短课。",
},
"ru": {
"mirror_progress_facts": "Точность {accuracy}% · слов {tracked} · к повторению {due} · серия {streak} дн.",
"mirror_progress_recent_terms": "Недавно повторяли: {terms}.",
"mirror_progress_focus_weak": "Сейчас фокус: повтори «{term}».",
"mirror_progress_focus_due": "Сейчас фокус: пройди {due} запланированных повторения.",
"mirror_progress_no_history": "Данных об обучении пока недостаточно.",
"mirror_progress_focus_starter": "Начни короткий урок из пяти слов.",
},
"es": {
"mirror_progress_facts": "Precisión {accuracy}% · {tracked} palabras · {due} pendientes · racha de {streak} días.",
"mirror_progress_recent_terms": "Practicadas recientemente: {terms}.",
"mirror_progress_focus_weak": "Enfócate ahora en repasar «{term}».",
"mirror_progress_focus_due": "Enfócate ahora en completar {due} repasos pendientes.",
"mirror_progress_no_history": "Todavía no hay suficiente historial de aprendizaje.",
Expand Down
61 changes: 61 additions & 0 deletions mydictionary/miniapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,31 @@ def miniapp_text_direction(locale: str | None) -> str:
},
}

_TUTOR_HISTORY_COPY = {
"en": (
"History with Lexi",
"Recent topics and replies are kept for a limited time.",
),
"fr": (
"Historique avec Lexi",
"Les sujets et réponses récents sont conservés pour une durée limitée.",
),
"de": (
"Verlauf mit Lexi",
"Letzte Themen und Antworten werden nur begrenzte Zeit gespeichert.",
),
"ja": ("Lexiとの履歴", "最近のトピックと回答は一定期間のみ保存されます。"),
"ar": ("السجل مع Lexi", "تُحفظ المواضيع والإجابات الأخيرة لمدة محدودة."),
"zh": ("与 Lexi 的记录", "最近的话题和回答只会保存有限时间。"),
"ru": ("История с Lexi", "Последние темы и ответы хранятся ограниченное время."),
"es": ("Historial con Lexi", "Los temas y respuestas recientes se guardan por tiempo limitado."),
}
for _locale, (_title, _hint) in _TUTOR_HISTORY_COPY.items():
MINIAPP_COPY[_locale].update(
tutor_history_title=_title,
tutor_history_hint=_hint,
)

_DICTIONARY_COMPANION_COPY = {
"en": ("Dictionary", "Open dictionary", "Search, saved words and offline practice · opens in your browser", "Download dictionary", "Your Telegram learning words"),
"fr": ("Dictionnaire", "Ouvrir le dictionnaire", "Recherche, mots enregistrés et pratique hors ligne · dans le navigateur", "Télécharger le dictionnaire", "Vos mots étudiés dans Telegram"),
Expand Down Expand Up @@ -1072,6 +1097,28 @@ def _bounded_text(value: object, maximum: int = 160) -> str:
return str(value or "").strip()[:maximum]


def _tutor_history_from_dialogue(
dialogue: object,
) -> list[dict[str, str]]:
if not isinstance(dialogue, list):
return []
exchanges: list[dict[str, str]] = []
for index in range(0, len(dialogue) - 1):
question = dialogue[index]
answer = dialogue[index + 1]
if not isinstance(question, Mapping) or not isinstance(answer, Mapping):
continue
if question.get("role") != "user" or answer.get("role") != "assistant":
continue
exchanges.append(
{
"question": _bounded_text(question.get("text"), 120),
"answer": _bounded_text(answer.get("text"), 220),
}
)
return [item for item in exchanges if item["question"] and item["answer"]][-3:]


def visible_credit_products(
products: list[Mapping[str, Any]] | tuple[Mapping[str, Any], ...],
*,
Expand Down Expand Up @@ -1498,6 +1545,19 @@ def build_bootstrap(
),
},
}
tutor_history: list[dict[str, str]] = []
history_loader = getattr(store, "peek_mirror_dialogue", None)
if (
mirror_memory_enabled
and ai_consent == "granted"
and callable(history_loader)
):
try:
tutor_history = _tutor_history_from_dialogue(
history_loader(int(user_id), limit=6)
)
except Exception:
tutor_history = []

return {
"locale": selected_locale,
Expand All @@ -1518,6 +1578,7 @@ def build_bootstrap(
},
"words": words,
"custom_words": custom_words,
"tutor_history": tutor_history,
"credits": {
"available": max(0, int(usage.get("available_credits") or 0)),
"reserved": max(0, int(usage.get("reserved_credits") or 0)),
Expand Down
Loading
Loading