# WordFSRS - Spaced-repetition vocabulary plugin for LangBot. # Memory scheduling powered by py-fsrs (the FSRS algorithm, the same memory # model behind Maimemo / 墨墨背单词). from __future__ import annotations import os import sys import json import time import asyncio import datetime from typing import TYPE_CHECKING, Any, Optional from langbot_plugin.api.definition.plugin import BasePlugin from fsrs import Scheduler, Card, Rating if TYPE_CHECKING: from langbot_plugin.entities.io.context import InstallationBinding # Allow importing the plugin-level i18n module. sys.path.insert(0, os.path.dirname(__file__)) from i18n import get_text # noqa: E402 # Manifest defaults, used when an invocation carries no explicit value. DEFAULT_LANGUAGE = "en_US" DEFAULT_DAILY_NEW_LIMIT = 20 DEFAULT_DESIRED_RETENTION = 0.9 RETENTION_MIN = 0.7 RETENTION_MAX = 0.97 # Scope token used when no installation binding is available (dedicated or # binding-less placement). It maps to the legacy deck key so those workers keep # reading their own historical rows. DEDICATED_SCOPE = "dedicated" def installation_scope(binding: "InstallationBinding") -> str: """Return the stable scope token for one installation binding. Host storage rows are keyed by ``[instance, workspace, owner_type, owner, key]`` with no installation dimension, so the installation scope has to live in the plugin's own key. ``runtime_revision`` is deliberately excluded: it changes on every worker upgrade and would orphan the tenant's decks. """ return ( f"{binding.instance_uuid}:{binding.workspace_uuid}:" f"{binding.installation_uuid}" ) def deck_storage_key(scope: str, session_id: str) -> str: """Return the plugin-storage key for one installation scope and session.""" if scope == DEDICATED_SCOPE: return f"deck:{session_id}" return f"deck:{scope}:{session_id}" # Rating aliases users can type after `!word grade `. RATING_ALIASES: dict[str, Rating] = { "1": Rating.Again, "again": Rating.Again, "wrong": Rating.Again, "no": Rating.Again, "忘了": Rating.Again, "不会": Rating.Again, "2": Rating.Hard, "hard": Rating.Hard, "难": Rating.Hard, "模糊": Rating.Hard, "3": Rating.Good, "good": Rating.Good, "ok": Rating.Good, "会": Rating.Good, "记得": Rating.Good, "4": Rating.Easy, "easy": Rating.Easy, "简单": Rating.Easy, "秒了": Rating.Easy, } def _now() -> datetime.datetime: return datetime.datetime.now(datetime.timezone.utc) class WordFSRSPlugin(BasePlugin): """Entry point and shared state for the WordFSRS plugin. One object graph serves every installation of this artifact, so tenant settings (language / daily new-card limit / desired retention) are read from the *current invocation* config on every use; ``initialize()`` never captures them (shared placement initializes it with an empty config). All persistent vocabulary data is scoped per *session* (``{launcher_type}:{launcher_id}``) so private chats and groups each keep their own deck. State is stored as JSON via the plugin KV storage under ``deck:{instance}:{workspace}:{installation}:{session}`` because Host rows carry no installation dimension; only binding-less (dedicated) workers use the legacy ``deck:{session}`` row. """ def __init__(self) -> None: super().__init__() # Read-modify-write guards, one per installation binding, so a deck # load/modify/save sequence cannot interleave with another command of # the same installation and lose cards. Released in # on_installation_revoked(). self._deck_locks: dict[str, asyncio.Lock] = {} # ---------------------------------------------------------- tenant config def _invocation_config(self) -> dict[str, Any]: """Settings of the active invocation (or the dedicated worker config).""" return self.get_config() or {} def get_language(self) -> str: return self._invocation_config().get("language") or DEFAULT_LANGUAGE def t(self, key: str, **kwargs) -> str: return get_text(self.get_language(), key, **kwargs) def _daily_new_limit(self) -> int: raw = self._invocation_config().get("daily_new_limit") if raw is None or raw == "": return DEFAULT_DAILY_NEW_LIMIT try: return max(0, int(raw)) except (TypeError, ValueError): return DEFAULT_DAILY_NEW_LIMIT def _desired_retention(self) -> float: raw = self._invocation_config().get("desired_retention") if raw is None or raw == "": return DEFAULT_DESIRED_RETENTION try: value = float(raw) except (TypeError, ValueError): return DEFAULT_DESIRED_RETENTION return min(RETENTION_MAX, max(RETENTION_MIN, value)) def _scheduler(self) -> Scheduler: """Build a scheduler from the current invocation's retention.""" return Scheduler(desired_retention=self._desired_retention()) # -------------------------------------------------------- revocation hook def _current_scope(self) -> str: """Scope token of the active invocation, or the binding-less scope.""" binding = self.get_installation_binding() return installation_scope(binding) if binding is not None else DEDICATED_SCOPE def _deck_lock(self) -> asyncio.Lock: key = self._current_scope() lock = self._deck_locks.get(key) if lock is None: lock = asyncio.Lock() self._deck_locks[key] = lock return lock async def on_installation_revoked(self, binding: InstallationBinding) -> None: """Release the revoked installation's read-modify-write guard.""" self._deck_locks.pop(installation_scope(binding), None) # ---------------------------------------------------------------- storage async def _load_deck(self, session_id: str) -> dict[str, Any]: """Load the current installation's deck for one session.""" key = deck_storage_key(self._current_scope(), session_id) try: raw = await self.get_plugin_storage(key) except Exception: raw = None if not raw: return {"cards": {}, "new_intro": {}} try: data = json.loads(raw.decode("utf-8") if isinstance(raw, bytes) else raw) except Exception: return {"cards": {}, "new_intro": {}} data.setdefault("cards", {}) data.setdefault("new_intro", {}) # {date_str: count} of new cards introduced return data async def _save_deck(self, session_id: str, deck: dict[str, Any]) -> None: payload = json.dumps(deck, ensure_ascii=False).encode("utf-8") await self.set_plugin_storage( deck_storage_key(self._current_scope(), session_id), payload ) # ----------------------------------------------------------------- helpers @staticmethod def _due_dt(card_entry: dict[str, Any]) -> datetime.datetime: card = Card.from_dict(card_entry["fsrs"]) due = card.due if due.tzinfo is None: due = due.replace(tzinfo=datetime.timezone.utc) return due def _is_due(self, card_entry: dict[str, Any], at: datetime.datetime) -> bool: return self._due_dt(card_entry) <= at def _fmt_when(self, due: datetime.datetime) -> str: """Human-friendly 'due in ...' string (localized).""" delta = due - _now() secs = delta.total_seconds() if secs <= 0: return self.t("when.now") mins = secs / 60 if mins < 60: return self.t("when.minutes", n=int(round(mins))) hours = mins / 60 if hours < 24: return self.t("when.hours", h=f"{hours:.1f}") days = hours / 24 return self.t("when.days", d=f"{days:.1f}") # ----------------------------------------------------------------- actions async def add_word( self, session_id: str, word: str, meaning: str = "" ) -> tuple[bool, str]: """Add a new vocabulary card. Returns (created, message).""" word = word.strip() if not word: return False, self.t("add.usage") async with self._deck_lock(): deck = await self._load_deck(session_id) key = word.lower() if key in deck["cards"]: entry = deck["cards"][key] if meaning: entry["meaning"] = meaning await self._save_deck(session_id, deck) return False, self.t("add.updated", word=word, meaning=meaning) return False, self.t("add.exists", word=word) card = Card() deck["cards"][key] = { "word": word, "meaning": meaning, "fsrs": card.to_dict(), "added_at": time.time(), "reviews": 0, } await self._save_deck(session_id, deck) total = len(deck["cards"]) tip = self.t("add.meaning_line", meaning=meaning) if meaning else "" return True, self.t("add.ok", word=word, tip=tip, total=total) async def remove_word(self, session_id: str, word: str) -> str: async with self._deck_lock(): deck = await self._load_deck(session_id) key = word.strip().lower() if key not in deck["cards"]: return self.t("del.not_found", word=word) del deck["cards"][key] await self._save_deck(session_id, deck) return self.t("del.ok", word=word, total=len(deck["cards"])) async def next_due(self, session_id: str) -> tuple[Optional[str], str]: """Pick the next card to review. Priority: cards already due (earliest due first). If none are due but the daily new-card budget allows, introduce a brand-new card. Returns (card_key, message). """ deck = await self._load_deck(session_id) cards = deck["cards"] if not cards: return None, self.t("review.empty") now = _now() due_items = [] new_items = [] for k, e in cards.items(): is_new = e.get("reviews", 0) == 0 if is_new: new_items.append((k, e)) elif self._is_due(e, now): due_items.append((k, self._due_dt(e), e)) if due_items: due_items.sort(key=lambda x: x[1]) k, _, e = due_items[0] return k, self._format_question(e, deck, session_id, kind="review") if new_items: today = now.date().isoformat() introduced_today = deck["new_intro"].get(today, 0) limit = self._daily_new_limit() if limit == 0 or introduced_today < limit: k, e = new_items[0] return k, self._format_question(e, deck, session_id, kind="new") else: soonest = self._soonest_msg(cards, now) return None, self.t( "review.new_limit", limit=limit, soonest=soonest ) soonest = self._soonest_msg(cards, now) return None, self.t("review.none_due", soonest=soonest) def _soonest_msg(self, cards: dict, now: datetime.datetime) -> str: future = [ self._due_dt(e) for e in cards.values() if e.get("reviews", 0) > 0 ] if not future: return self.t("soonest.all_done") nxt = min(future) return self.t("soonest.next", when=self._fmt_when(nxt)) def _format_question( self, entry: dict, deck: dict, session_id: str, kind: str ) -> str: word = entry["word"] kind_label = self.t("q.kind_new") if kind == "new" else self.t("q.kind_review") body = self.t("q.body", kind=kind_label, word=word) if kind == "new": meaning = entry.get("meaning", "") if meaning: body += self.t("q.new_meaning", meaning=meaning) return body async def show_answer(self, session_id: str, word: str) -> str: deck = await self._load_deck(session_id) key = word.strip().lower() e = deck["cards"].get(key) if not e: return self.t("del.not_found", word=word) meaning = e.get("meaning") or self.t("show.no_meaning") return self.t("show.ok", word=e["word"], meaning=meaning) async def grade( self, session_id: str, word: str, rating_token: str ) -> str: async with self._deck_lock(): deck = await self._load_deck(session_id) key = word.strip().lower() e = deck["cards"].get(key) if not e: return self.t("grade.not_found", word=word) rating = RATING_ALIASES.get(rating_token.strip().lower()) if rating is None: return self.t("grade.invalid") was_new = e.get("reviews", 0) == 0 card = Card.from_dict(e["fsrs"]) card, _log = self._scheduler().review_card(card, rating) e["fsrs"] = card.to_dict() e["reviews"] = e.get("reviews", 0) + 1 e["last_rating"] = int(rating) if was_new: today = _now().date().isoformat() deck["new_intro"][today] = deck["new_intro"].get(today, 0) + 1 await self._save_deck(session_id, deck) due = card.due if due.tzinfo is None: due = due.replace(tzinfo=datetime.timezone.utc) rating_key = { 1: "grade.rating_again", 2: "grade.rating_hard", 3: "grade.rating_good", 4: "grade.rating_easy", }[int(rating)] rating_label = self.t(rating_key) meaning = e.get("meaning") meaning_line = self.t("grade.meaning_paren", meaning=meaning) if meaning else "" return self.t( "grade.ok", word=e["word"], meaning=meaning_line, rating=rating_label, when=self._fmt_when(due), ) async def stats(self, session_id: str) -> str: deck = await self._load_deck(session_id) cards = deck["cards"] if not cards: return self.t("stats.empty") now = _now() total = len(cards) new_cnt = sum(1 for e in cards.values() if e.get("reviews", 0) == 0) due_cnt = sum( 1 for e in cards.values() if e.get("reviews", 0) > 0 and self._is_due(e, now) ) learning = total - new_cnt today = now.date().isoformat() intro_today = deck["new_intro"].get(today, 0) limit = self._daily_new_limit() limit_str = self.t("stats.unlimited") if limit == 0 else str(limit) return self.t( "stats.body", total=total, due=due_cnt, new=new_cnt, learning=learning, intro=intro_today, limit=limit_str, ) async def list_words(self, session_id: str, page: int = 1) -> str: deck = await self._load_deck(session_id) cards = list(deck["cards"].values()) if not cards: return self.t("list.empty") cards.sort(key=lambda e: self._due_dt(e)) per = 15 page = max(1, page) start = (page - 1) * per chunk = cards[start : start + per] if not chunk: return self.t("list.no_more") lines = [self.t("list.header")] for e in chunk: meaning = e.get("meaning", "") mtxt = f" \u2014 {meaning}" if meaning else "" if e.get("reviews", 0) == 0: status = self.t("list.status_new") else: status = self._fmt_when(self._due_dt(e)) lines.append(self.t("list.item", word=e["word"], meaning=mtxt, status=status)) total_pages = (len(cards) + per - 1) // per lines.append( self.t("list.footer", page=page, total_pages=total_pages, count=len(cards)) ) return "\n".join(lines) def __del__(self) -> None: pass