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 @@