diff --git a/.tdd/spec-mirror-teacher-continuity.md b/.tdd/spec-mirror-teacher-continuity.md new file mode 100644 index 0000000..462c363 --- /dev/null +++ b/.tdd/spec-mirror-teacher-continuity.md @@ -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. diff --git a/bot.py b/bot.py index 5faf6b5..ae7e1da 100644 --- a/bot.py +++ b/bot.py @@ -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, @@ -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", @@ -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 diff --git a/mydictionary/ai_tutor.py b/mydictionary/ai_tutor.py index 5ad3208..b462eef 100644 --- a/mydictionary/ai_tutor.py +++ b/mydictionary/ai_tutor.py @@ -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: diff --git a/mydictionary/localization.py b/mydictionary/localization.py index 1898f87..9ba3647 100644 --- a/mydictionary/localization.py +++ b/mydictionary/localization.py @@ -4075,6 +4075,7 @@ _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.", @@ -4082,6 +4083,7 @@ }, "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.", @@ -4089,6 +4091,7 @@ }, "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.", @@ -4096,6 +4099,7 @@ }, "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": "学習履歴はまだ十分にありません。", @@ -4103,6 +4107,7 @@ }, "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": "لا يوجد سجل تعلم كافٍ بعد.", @@ -4110,6 +4115,7 @@ }, "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": "目前还没有足够的学习记录。", @@ -4117,6 +4123,7 @@ }, "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": "Данных об обучении пока недостаточно.", @@ -4124,6 +4131,7 @@ }, "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.", diff --git a/mydictionary/miniapp.py b/mydictionary/miniapp.py index a6d18c8..199dd02 100644 --- a/mydictionary/miniapp.py +++ b/mydictionary/miniapp.py @@ -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"), @@ -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], ...], *, @@ -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, @@ -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)), diff --git a/mydictionary/mirror_assistant.py b/mydictionary/mirror_assistant.py index 1d695e4..51618da 100644 --- a/mydictionary/mirror_assistant.py +++ b/mydictionary/mirror_assistant.py @@ -10,6 +10,7 @@ import re from types import MappingProxyType from typing import Any, Mapping, Sequence +import unicodedata from sqlalchemy import func, select @@ -339,6 +340,7 @@ "какой сейчас прогресс": "ru", "прогресс": "ru", "что я уже прошел": "ru", + "что я проходил": "ru", "en qué debo enfocarme": "es", "mi progreso": "es", "cómo va mi progreso": "es", @@ -868,6 +870,95 @@ def classify_mirror_task(text: str) -> str: return "general_conversation" +def classify_mirror_turn_task( + text: str, + *, + recent_dialogue: Sequence[Mapping[str, Any]] | None = None, +) -> str: + """Keep a short learner answer inside the previous teaching exercise.""" + base_task = classify_mirror_task(text) + if base_task != "general_conversation": + return base_task + normalized = " ".join(str(text).casefold().strip().split()) + words_only = " ".join(re.findall(r"\w+", normalized, flags=re.UNICODE)) + if not words_only or words_only in _GREETING_PATTERNS: + return base_task + if len(words_only) > 80 or len(words_only.split()) > 8: + return base_task + dialogue = normalize_linked_mirror_dialogue(recent_dialogue) + if not dialogue: + return base_task + previous = next( + ( + str(turn.get("text") or "") + for turn in reversed(dialogue) + if turn.get("role") == "assistant" + ), + "", + ) + challenge_markers = ( + "▶️", + "переведи", + "напиши", + "ответь", + "translate", + "write ", + "réponds", + "traduis", + "übersetze", + "اكتب", + "ترجم", + "翻译", + "訳して", + "traduce", + ) + if any(marker in previous.casefold() for marker in challenge_markers): + return "practice" + return base_task + + +def catalog_word_exposures( + words: Sequence[Mapping[str, Any]], + *texts: str, + limit: int = 12, +) -> list[tuple[int, Mapping[str, Any]]]: + """Match bounded whole catalog terms mentioned in one Mirror exchange.""" + bounded_limit = max(0, min(12, int(limit))) + if bounded_limit == 0: + return [] + + def normalize(value: object) -> str: + canonical = unicodedata.normalize("NFKC", str(value or "")).casefold() + return " ".join(re.findall(r"\w+", canonical, flags=re.UNICODE)) + + haystack = normalize(" ".join(str(text or "") for text in texts)) + padded_haystack = f" {haystack} " + compact_haystack = haystack.replace(" ", "") + matches: list[tuple[int, Mapping[str, Any]]] = [] + seen: set[str] = set() + for index, word in enumerate(words): + term = normalize(word.get("target") or word.get("en")) + if not term: + continue + compact_term = term.replace(" ", "") + uses_compact_matching = bool( + re.search(r"[\u3040-\u30ff\u3400-\u9fff]", term) + ) + mentioned = ( + compact_term in compact_haystack + if uses_compact_matching + else f" {term} " in padded_haystack + ) + identity = str(word.get("entry_id") or term) + if not mentioned or identity in seen: + continue + seen.add(identity) + matches.append((index, word)) + if len(matches) >= bounded_limit: + break + return matches + + def render_mirror_capabilities(capabilities: str, *, locale: str | None = None) -> str: """Return only the reviewed learner-facing capability copy.""" selected = normalize_locale(locale, fallback="ru" if locale is None else "en") @@ -985,7 +1076,32 @@ def display_number(*keys: str) -> int | str: focus = translate("mirror_progress_focus_due", selected, due=due) else: focus = translate("mirror_progress_focus_starter", selected) - return f"📊 {facts}\n👉 {focus}" + recent_terms = snapshot.get("recent_terms") + recent: list[str] = [] + seen_recent: set[str] = set() + if isinstance(recent_terms, Sequence) and not isinstance( + recent_terms, (str, bytes) + ): + for value in recent_terms: + candidate = " ".join(str(value or "").strip().split())[:40] + normalized_candidate = candidate.casefold() + if candidate and normalized_candidate not in seen_recent: + recent.append(candidate) + seen_recent.add(normalized_candidate) + if len(recent) >= 5: + break + sections = [f"📊 {facts}"] + if recent: + sections.append( + "🧠 " + + translate( + "mirror_progress_recent_terms", + selected, + terms=", ".join(recent), + ) + ) + sections.append(f"{'🎯' if recent else '👉'} {focus}") + return "\n".join(sections) def _normalize_mirror_turn(value: Mapping[str, Any]) -> dict[str, str]: @@ -1265,6 +1381,20 @@ def grounded_progress_snapshot( except ValueError: pass weak_terms.sort(key=lambda item: (-item["error_gap"], item["term"])) + recent_terms: list[str] = [] + seen_recent_terms: set[str] = set() + for word in sorted( + words, + key=lambda item: str(item.last_seen or ""), + reverse=True, + ): + term = " ".join(str(word.term or "").strip().split())[:80] + normalized_term = term.casefold() + if word.last_seen and term and normalized_term not in seen_recent_terms: + recent_terms.append(term) + seen_recent_terms.add(normalized_term) + if len(recent_terms) >= 5: + break accuracy = round(correct * 100 / attempts) if attempts else None streak = int(progress.streak or 0) snapshot: dict[str, Any] = { @@ -1282,6 +1412,7 @@ def grounded_progress_snapshot( "due_count": due_count, "due_reviews": due_count, "weak_terms": weak_terms[:5], + "recent_terms": recent_terms, "streak": streak if streak > 0 else None, "streak_days": streak, "recent_activity": {"sessions_7d": int(recent_sessions)}, diff --git a/mydictionary/static/miniapp.css b/mydictionary/static/miniapp.css index eb9d657..e29196a 100644 --- a/mydictionary/static/miniapp.css +++ b/mydictionary/static/miniapp.css @@ -410,6 +410,16 @@ button:not(:disabled):active { transform: translateY(1px) scale(.985); } .metric b { display: block; color: var(--dashboard-text); font-size: 1.15rem; line-height: 1.1; } .metric span { display: block; margin-top: 4px; color: #52605d; font-size: .72rem; overflow-wrap: anywhere; } .dashboard-metric span { color: var(--dashboard-muted); } +.tutor-history { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--dashboard-line); } +.tutor-history-heading { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 8px; } +.tutor-history-heading h3 { margin: 0; color: var(--dashboard-text); font-size: .86rem; } +.tutor-history-count { min-width: 24px; color: var(--dashboard-muted); text-align: end; font-size: .72rem; font-variant-numeric: tabular-nums; } +.tutor-history-hint { margin: 5px 0 0; color: var(--dashboard-muted); font-size: .7rem; line-height: 1.35; } +.tutor-history-list { margin-top: 8px; } +.tutor-history-item { padding: 9px 0; border-top: 1px solid var(--dashboard-line); } +.tutor-history-item:first-child { border-top: 0; } +.tutor-history-item p { margin: 0; overflow-wrap: anywhere; font-size: .75rem; line-height: 1.4; } +.tutor-history-answer { margin-top: 4px !important; color: var(--dashboard-muted); } /* One full-width title-and-art card across all five tabs. */ .section-heading { min-height: 0; } diff --git a/mydictionary/static/miniapp.js b/mydictionary/static/miniapp.js index 6f6a2c1..d649154 100644 --- a/mydictionary/static/miniapp.js +++ b/mydictionary/static/miniapp.js @@ -1002,6 +1002,24 @@ metric(copy.metric_best_streak, progress.best_streak), metric(copy.metric_tracked_words, progress.tracked_words) ); + const tutorHistory = Array.isArray(data.tutor_history) ? data.tutor_history.slice(-3) : []; + const tutorHistorySection = node("tutor-history"); + const tutorHistoryList = node("tutor-history-list"); + const historyItems = tutorHistory.map((exchange) => { + const item = document.createElement("article"); + item.className = "tutor-history-item"; + const historyQuestion = document.createElement("p"); + historyQuestion.className = "tutor-history-question"; + text(historyQuestion, `💬 ${exchange.question || ""}`); + const historyAnswer = document.createElement("p"); + historyAnswer.className = "tutor-history-answer"; + text(historyAnswer, `🦊 ${exchange.answer || ""}`); + item.append(historyQuestion, historyAnswer); + return item; + }); + tutorHistoryList.replaceChildren(...historyItems); + text(node("tutor-history-count"), tutorHistory.length); + tutorHistorySection.hidden = tutorHistory.length === 0; node("word-summary").replaceChildren( summaryStat(copy.metric_tracked_words, progress.tracked_words, "tracked"), diff --git a/mydictionary/storage.py b/mydictionary/storage.py index 519b4d8..e30aa02 100644 --- a/mydictionary/storage.py +++ b/mydictionary/storage.py @@ -7,7 +7,7 @@ from pathlib import Path import re import secrets -from typing import Any, Mapping +from typing import Any, Mapping, Sequence from uuid import uuid4 from alembic import command @@ -1856,6 +1856,78 @@ def get_mirror_dialogue( ) return dialogue + def peek_mirror_dialogue( + self, + user_id: int, + *, + limit: int = 6, + now: datetime | None = None, + ) -> list[dict[str, str]]: + """Read complete unexpired exchanges without pruning or other writes.""" + bounded_limit = int(limit) + if not 1 <= bounded_limit <= 20: + raise ValueError("Mirror dialogue limit must be 1-20 turns") + exchange_limit = bounded_limit // 2 + if exchange_limit == 0: + return [] + observed_at = now or utcnow() + if observed_at.tzinfo is None: + observed_at = observed_at.replace(tzinfo=timezone.utc) + with self.Session() as session: + user = session.get(User, int(user_id)) + if user is None or user.privacy_status != "active": + return [] + latest_exchanges = session.execute( + select( + MirrorDialogueTurn.exchange_id, + func.max(MirrorDialogueTurn.created_at).label( + "exchange_created_at" + ), + ) + .where( + MirrorDialogueTurn.telegram_user_id == int(user_id), + MirrorDialogueTurn.expires_at > observed_at, + ) + .group_by(MirrorDialogueTurn.exchange_id) + .order_by( + func.max(MirrorDialogueTurn.created_at).desc(), + MirrorDialogueTurn.exchange_id.desc(), + ) + .limit(exchange_limit) + ).all() + exchange_ids = [ + str(row.exchange_id) for row in reversed(latest_exchanges) + ] + if not exchange_ids: + return [] + rows = session.execute( + select(MirrorDialogueTurn).where( + MirrorDialogueTurn.telegram_user_id == int(user_id), + MirrorDialogueTurn.exchange_id.in_(exchange_ids), + MirrorDialogueTurn.expires_at > observed_at, + ) + ).scalars().all() + + turns_by_exchange: dict[str, list[MirrorDialogueTurn]] = {} + for row in rows: + turns_by_exchange.setdefault(str(row.exchange_id), []).append(row) + dialogue: list[dict[str, str]] = [] + for exchange_id in exchange_ids: + linked_turns = sorted( + turns_by_exchange.get(exchange_id, []), + key=lambda row: row.turn_index, + ) + if ( + len(linked_turns) != 2 + or linked_turns[0].role != "user" + or linked_turns[1].role != "assistant" + ): + continue + dialogue.extend( + {"role": row.role, "text": row.text} for row in linked_turns + ) + return dialogue + def clear_mirror_dialogue(self, user_id: int) -> int: with self.Session.begin() as session: deleted = session.execute( @@ -2708,6 +2780,62 @@ def _upsert_word( setattr(row, field, value) row.updated_at = utcnow() + def record_word_exposures( + self, + user_id: int, + *, + language: str, + entries: Sequence[tuple[int, Mapping[str, Any]]], + now: datetime | None = None, + ) -> int: + """Remember mentioned catalog words without awarding scores or XP.""" + namespace = str(language or "").strip()[:16] + if not namespace: + raise ValueError("Word exposure language is required") + observed_at = now or utcnow() + if observed_at.tzinfo is None: + observed_at = observed_at.replace(tzinfo=timezone.utc) + bounded: list[tuple[int, Mapping[str, Any], str]] = [] + seen: set[str] = set() + for raw_index, word in list(entries)[:24]: + if not isinstance(word, Mapping): + continue + vocabulary_id = vocabulary_id_for(word) + if not vocabulary_id or vocabulary_id in seen: + continue + seen.add(vocabulary_id) + bounded.append((int(raw_index), word, vocabulary_id)) + if len(bounded) >= 12: + break + if not bounded: + return 0 + with self.Session.begin() as session: + user = session.get(User, int(user_id)) + if ( + user is None + or user.privacy_status != "active" + or user.access_status == "blocked" + ): + raise ValueError("Inactive users cannot store word exposure") + for word_index, word, vocabulary_id in bounded: + key = (int(user_id), namespace, vocabulary_id) + row = session.get(WordProgress, key) + if row is None: + row = WordProgress( + telegram_user_id=int(user_id), + language=namespace, + vocabulary_id=vocabulary_id, + term=target_text(word), + word_index=word_index, + ) + session.add(row) + else: + row.word_index = word_index + row.term = target_text(word) + row.last_seen = observed_at.isoformat() + row.updated_at = observed_at + return len(bounded) + def save_learning_state( self, user_id: int, diff --git a/mydictionary/templates/miniapp.html b/mydictionary/templates/miniapp.html index a14cba4..44fd028 100644 --- a/mydictionary/templates/miniapp.html +++ b/mydictionary/templates/miniapp.html @@ -6,13 +6,13 @@ Lexi — языковой тренер - + {% for section in ('profile', 'words', 'credits', 'languages', 'settings') %} {% endfor %} - - + +
@@ -120,6 +120,15 @@

+ diff --git a/prompts/mirror-v9.txt b/prompts/mirror-v9.txt index 5228f8a..91f86c6 100644 --- a/prompts/mirror-v9.txt +++ b/prompts/mirror-v9.txt @@ -29,6 +29,12 @@ follow-ups such as "why?", "how so?", or "what do you mean?" against the immediately preceding assistant answer. If that answer already contains the referent, explain it directly and never ask what the learner means. +When the previous assistant turn contains a learning challenge and the current +question is the learner's short answer, evaluate that answer instead of +restarting the lesson. Give the result and one concise reason; advance to one fresh task +when the learner is ready. Never reuse an example, translation +prompt, or next task that already appears in recent_dialogue. + When task_kind is general_conversation, converse naturally about the learner's meaning and current context. Do not turn an ordinary conversational message into a dictionary entry. Only explain or translate wording when the learner @@ -53,6 +59,8 @@ correction, grammar, pronunciation, practice, and progress_review use their own learning-oriented presentation. Keep the direct answer, supporting facts, and optional next step in their matching schema fields. Do not add decorative emoji or section markers to any JSON string; the application owns all decoration. +Do not output Markdown decoration such as headings, bullets, backticks, or bold +markers; the application owns the presentation. For general_conversation, sound like one attentive person continuing the chat, not an analytics report. Put the complete direct reply in answer_ru. Leave diff --git a/tests/test_miniapp_brand_controls_v1.py b/tests/test_miniapp_brand_controls_v1.py index 4001599..28dfad3 100644 --- a/tests/test_miniapp_brand_controls_v1.py +++ b/tests/test_miniapp_brand_controls_v1.py @@ -48,7 +48,7 @@ def test_dictionary_shortcut_and_primary_actions_have_tactile_states(self) -> No self.assertIn("@media (prefers-reduced-motion: reduce)", CSS) def test_stylesheet_url_is_versioned_for_telegram_webview_cache(self) -> None: - self.assertIn("miniapp.css') }}?v=20260914-learning-flow-v2", HTML) + self.assertIn("miniapp.css') }}?v=20260917-teacher-continuity-v1", HTML) if __name__ == "__main__": diff --git a/tests/test_miniapp_unified_section_headers_v1.py b/tests/test_miniapp_unified_section_headers_v1.py index 8f85355..80ae8ee 100644 --- a/tests/test_miniapp_unified_section_headers_v1.py +++ b/tests/test_miniapp_unified_section_headers_v1.py @@ -65,7 +65,7 @@ def test_shared_hero_is_compact_and_uses_a_fresh_stylesheet_version(self) -> Non self.assertIn("min-height: 148px", header) self.assertIn("height: clamp(148px, 38vw, 208px)", header) - self.assertIn("miniapp.css') }}?v=20260914-learning-flow-v2", HTML) + self.assertIn("miniapp.css') }}?v=20260917-teacher-continuity-v1", HTML) if __name__ == "__main__": diff --git a/tests/test_mirror_teacher_continuity_v1.py b/tests/test_mirror_teacher_continuity_v1.py new file mode 100644 index 0000000..eb4ac7f --- /dev/null +++ b/tests/test_mirror_teacher_continuity_v1.py @@ -0,0 +1,257 @@ +import tempfile +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path +from unittest.mock import MagicMock + +import bot +from mydictionary import ai_tutor, miniapp, mirror_assistant +from mydictionary.storage import DatabaseStore, User, UserProgress + + +ROOT = Path(__file__).resolve().parents[1] + + +class MirrorTeacherTurnContractTest(unittest.TestCase): + def test_ac1_progress_phrase_and_recent_terms_are_structured(self): + self.assertEqual( + mirror_assistant.direct_mirror_progress_locale("что я проходил"), + "ru", + ) + rendered = mirror_assistant.render_mirror_progress_focus( + { + "has_progress": True, + "accuracy_percent": 80, + "tracked_words": 5, + "due_count": 2, + "streak": 3, + "recent_terms": ["old", "book"], + "weak_terms": [], + }, + locale="ru", + ) + self.assertEqual(rendered.count("\n"), 2) + self.assertTrue(rendered.startswith("📊 ")) + self.assertIn("🧠 ", rendered) + self.assertIn("old, book", rendered) + self.assertIn("🎯 ", rendered) + + def test_ac2_short_answer_continues_the_previous_practice(self): + recent = [ + {"role": "user", "text": "дай упражнение"}, + { + "role": "assistant", + "text": "▶️ Переведи на английский: «холодная книга».", + }, + ] + self.assertEqual( + mirror_assistant.classify_mirror_turn_task( + "a cold book", recent_dialogue=recent + ), + "practice", + ) + self.assertEqual( + mirror_assistant.classify_mirror_turn_task( + "привет", recent_dialogue=recent + ), + "general_conversation", + ) + + def test_ac3_renderer_removes_raw_markdown_from_teacher_turn(self): + answer = ai_tutor.parse_mirror_answer( + { + "answer_ru": "**Верно:** an old book.", + "evidence_ru": ["Артикль **an** нужен перед гласным звуком."], + "interpretation_ru": "", + "language_items": [], + "examples": [ + { + "target": "an old house", + "transcription": "", + "russian": "старый дом", + } + ], + "next_step_ru": "Переведи: «новая книга».", + } + ) + rendered = ai_tutor.render_mirror_answer( + answer, + available_credits=3, + task_kind="practice", + ) + self.assertNotIn("**", rendered) + self.assertLessEqual(len(rendered.split("\n\n")), 3) + self.assertIn("🎯 ", rendered) + self.assertIn("▶️ ", rendered) + + def test_ac6_prompt_advances_and_never_reuses_recent_examples(self): + prompt = (ROOT / "prompts" / "mirror-v9.txt").read_text(encoding="utf-8") + self.assertIn("Never reuse an example", prompt) + self.assertIn("advance to one fresh task", prompt) + self.assertIn("Do not output Markdown decoration", prompt) + + +class MirrorVocabularyExposureContractTest(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="mirror-continuity-") + self.store = DatabaseStore( + f"sqlite:///{Path(self.temporary.name) / 'continuity.sqlite3'}" + ) + self.user_id = 91701 + self.store.ensure_user_id(self.user_id) + + def tearDown(self): + self.store.close() + self.temporary.cleanup() + + def test_ac4_catalog_mentions_are_bounded_unique_exposures_without_scoring(self): + pack = bot.CATALOG.get("en-basics-100") + words = bot.CATALOG.words(pack) + selected = [word for word in words if word["target"] in {"old", "book", "cold"}] + entries = mirror_assistant.catalog_word_exposures( + selected, + "old book", + "Try a cold book, then repeat old book.", + ) + observed_at = datetime(2026, 9, 17, 12, 0, tzinfo=timezone.utc) + stored = self.store.record_word_exposures( + self.user_id, + language=pack.storage_key, + entries=entries, + now=observed_at, + ) + + self.assertEqual(stored, 3) + progress = self.store.load_word_progress(self.user_id, pack.storage_key) + self.assertEqual(len(progress), 3) + self.assertTrue(all(row["last_seen"] == observed_at.isoformat() for row in progress.values())) + self.assertTrue(all(row["correct_count"] == row["wrong_count"] == 0 for row in progress.values())) + with self.store.Session() as session: + aggregate = session.get(UserProgress, self.user_id) + self.assertEqual((aggregate.total_correct, aggregate.total_wrong, aggregate.xp, aggregate.sessions), (0, 0, 0, 0)) + + def test_ec1_duplicate_exposures_are_written_once_and_erased_user_is_rejected(self): + pack = bot.CATALOG.get("en-basics-100") + word = next(row for row in bot.CATALOG.words(pack) if row["target"] == "book") + entries = [(4, word), (4, word)] * 8 + self.assertEqual( + self.store.record_word_exposures( + self.user_id, language=pack.storage_key, entries=entries + ), + 1, + ) + with self.store.Session.begin() as session: + learner = session.get(User, self.user_id) + learner.privacy_status = "erased" + with self.assertRaises(ValueError): + self.store.record_word_exposures( + self.user_id, language=pack.storage_key, entries=[(4, word)] + ) + + +class MirrorHistoryProfileContractTest(unittest.TestCase): + def test_ac5_bootstrap_exposes_three_consented_exchanges_only(self): + store = MagicMock() + store.access_profile.return_value = { + "role": "learner", + "access_status": "active", + "privacy_status": "active", + } + store.product_profile.return_value = { + "role": "learner", + "native_language": "ru", + "daily_word_goal": 5, + "active_lang": "en", + "active_pack_id": "en-basics-100", + } + store.load_profile.return_value = {"active_lang": "en", "active_pack_id": "en-basics-100"} + store.load_word_progress.return_value = {} + store.ai_usage_summary.return_value = {} + store.has_consent.return_value = True + store.peek_mirror_dialogue.return_value = [ + item + for index in range(4) + for item in ( + {"role": "user", "text": f"question {index}"}, + {"role": "assistant", "text": f"answer {index}"}, + ) + ] + + payload = miniapp.build_bootstrap( + store, + user_id=91702, + display_name="Mila", + locale="ru", + catalog=bot.CATALOG, + products=[], + checkout_enabled=False, + ai_enabled=True, + voice_enabled=False, + ai_consent_version="ai-v1", + mirror_memory_enabled=True, + ) + + self.assertEqual(len(payload["tutor_history"]), 3) + self.assertEqual(payload["tutor_history"][-1]["question"], "question 3") + store.peek_mirror_dialogue.assert_called_once_with(91702, limit=6) + + store.has_consent.return_value = False + payload = miniapp.build_bootstrap( + store, + user_id=91702, + display_name="Mila", + locale="ru", + catalog=bot.CATALOG, + products=[], + checkout_enabled=False, + ai_enabled=True, + voice_enabled=False, + ai_consent_version="ai-v1", + mirror_memory_enabled=True, + ) + self.assertEqual(payload["tutor_history"], []) + + def test_ec2_profile_history_is_collapsed_bounded_and_text_only(self): + html = (ROOT / "mydictionary" / "templates" / "miniapp.html").read_text(encoding="utf-8") + script = (ROOT / "mydictionary" / "static" / "miniapp.js").read_text(encoding="utf-8") + self.assertIn('id="tutor-history"', html) + self.assertIn('id="tutor-history-list"', html) + self.assertIn('const tutorHistory = Array.isArray(data.tutor_history)', script) + self.assertIn('text(historyQuestion', script) + self.assertIn('text(historyAnswer', script) + self.assertNotIn('tutor-history-list").innerHTML', script) + + +class MirrorReadOnlyHistoryContractTest(unittest.TestCase): + def test_ac5_peek_history_does_not_delete_expired_rows(self): + temporary = tempfile.TemporaryDirectory(prefix="mirror-peek-") + self.addCleanup(temporary.cleanup) + store = DatabaseStore(f"sqlite:///{Path(temporary.name) / 'peek.sqlite3'}") + self.addCleanup(store.close) + now = datetime(2026, 9, 17, 12, 0, tzinfo=timezone.utc) + store.append_mirror_exchange( + 91703, + question="expired", + answer="expired answer", + retention_days=1, + now=now - timedelta(days=2), + ) + store.append_mirror_exchange( + 91703, + question="current", + answer="current answer", + retention_days=7, + now=now, + ) + + self.assertEqual( + store.peek_mirror_dialogue(91703, limit=6, now=now), + [ + {"role": "user", "text": "current"}, + {"role": "assistant", "text": "current answer"}, + ], + ) + with store.Session() as session: + from mydictionary.storage import MirrorDialogueTurn + + self.assertEqual(session.query(MirrorDialogueTurn).count(), 4)