mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-30 20:35:22 +08:00
438 lines
16 KiB
Python
438 lines
16 KiB
Python
# 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 <word>`.
|
|
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
|