Files
wordloop/backend/services/adaptive_review_service.py
john 05e173b293 Ship native iOS app, Wiki/TTS backend, and skeleton loading UX.
Replace the WebView shell with SwiftUI screens, add account-scoped Wiki and TTS APIs with adaptive review and photo scan support, and keep web/iOS pages usable while data loads asynchronously.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 15:13:59 +08:00

337 lines
12 KiB
Python

"""可解释的自适应复习调度,用真实回忆表现更新单词出现频率。"""
from __future__ import annotations
import math
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from models import Word
TARGET_RETRIEVABILITY = 0.85
MIN_STABILITY_HOURS = 0.25
MAX_STABILITY_HOURS = 24.0 * 365.0
def parse_utc(value: str | None, fallback: datetime | None = None) -> datetime:
if not value:
return fallback or datetime.now(timezone.utc)
normalized = value.replace("Z", "+00:00")
try:
parsed = datetime.fromisoformat(normalized)
except ValueError:
parsed = datetime.strptime(value[:19], "%Y-%m-%dT%H:%M:%S")
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def utc_iso(value: datetime) -> str:
return value.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def retrievability(word: Word, now: datetime | None = None) -> float:
now = now or datetime.now(timezone.utc)
last_review = parse_utc(word.last_reviewed_at or word.created_at, now)
elapsed_hours = max(0.0, (now - last_review).total_seconds() / 3600.0)
stability = max(MIN_STABILITY_HOURS, float(word.stability_hours or 24.0))
return max(0.0, min(1.0, math.exp(-elapsed_hours / stability)))
def response_quality(
*,
is_correct: bool,
attempts: int,
hints_used: int,
duration_seconds: int,
) -> float:
if not is_correct:
return 0.0
quality = 1.0
quality -= min(0.45, max(0, attempts - 1) * 0.18)
quality -= min(0.35, max(0, hints_used) * 0.12)
if duration_seconds > 30:
quality -= min(0.25, (duration_seconds - 30) / 180.0)
elif duration_seconds <= 8 and attempts == 1 and hints_used == 0:
quality += 0.1
return max(0.15, min(1.0, quality))
@dataclass(frozen=True)
class ReviewUpdate:
quality: float
difficulty: float
stability_hours: float
retrievability: float
next_review_at: str
interval_hours: float
message: str
class AdaptiveReviewService:
def update_word(
self,
word: Word,
*,
is_correct: bool,
attempts: int,
hints_used: int,
duration_seconds: int,
observed_retrievability: float | None = None,
now: datetime | None = None,
) -> ReviewUpdate:
now = now or datetime.now(timezone.utc)
before_r = (
retrievability(word, now)
if observed_retrievability is None
else max(0.0, min(1.0, observed_retrievability))
)
old_difficulty = float(word.difficulty or 5.0)
old_stability = max(
MIN_STABILITY_HOURS, float(word.stability_hours or 24.0)
)
quality = response_quality(
is_correct=is_correct,
attempts=attempts,
hints_used=hints_used,
duration_seconds=duration_seconds,
)
if is_correct:
difficulty = old_difficulty - (quality - 0.55) * 0.9
growth = 1.15 + 1.85 * quality + 0.35 * (1.0 - before_r)
stability = old_stability * growth
interval_hours = -stability * math.log(TARGET_RETRIEVABILITY)
interval_hours = max(1.0, interval_hours)
else:
difficulty = old_difficulty + 0.8
stability = old_stability * 0.45
interval_hours = 0.25
difficulty = max(1.0, min(10.0, difficulty))
stability = max(MIN_STABILITY_HOURS, min(MAX_STABILITY_HOURS, stability))
next_review = now + timedelta(hours=interval_hours)
word.difficulty = round(difficulty, 3)
word.stability_hours = round(stability, 3)
word.next_review_at = utc_iso(next_review)
word.review_due_date = next_review.strftime("%Y-%m-%d")
if not is_correct:
message = "本词记忆较弱,将在约 15 分钟后优先出现"
elif interval_hours < 24:
message = f"本词将在约 {max(1, round(interval_hours))} 小时后复习"
else:
message = f"本词将在约 {max(1, round(interval_hours / 24))} 天后复习"
return ReviewUpdate(
quality=round(quality, 3),
difficulty=word.difficulty,
stability_hours=word.stability_hours,
retrievability=round(before_r * 100.0, 1),
next_review_at=word.next_review_at,
interval_hours=round(interval_hours, 2),
message=message,
)
def selection_score(self, word: Word, now: datetime | None = None) -> float:
now = now or datetime.now(timezone.utc)
current_r = retrievability(word, now)
difficulty = float(word.difficulty or 5.0) / 10.0
score = (1.0 - current_r) * 70.0 + difficulty * 20.0
if word.status == "weak":
score += 35.0
elif word.status == "learning":
score += 18.0
elif word.status == "new":
score += 25.0
if word.next_review_at:
due_at = parse_utc(word.next_review_at, now)
overdue_hours = (now - due_at).total_seconds() / 3600.0
if overdue_hours >= 0:
score += 50.0 + min(50.0, overdue_hours / 24.0 * 5.0)
else:
score -= min(35.0, -overdue_hours / 24.0 * 4.0)
elif word.review_due_date:
if word.review_due_date <= now.strftime("%Y-%m-%d"):
score += 45.0
wrong = max(0, int(word.wrong_count or 0))
correct = max(0, int(word.correct_count or 0))
if wrong > 0:
score += min(40.0, wrong * 8.0)
if correct == 0 and wrong > 0:
score += 45.0
elif wrong > correct:
score += 25.0
mastery = max(0, min(100, int(word.mastery_score or 0)))
score += (100 - mastery) * 0.25
return score
@staticmethod
def error_bank_member(word: Word) -> bool:
return bool(int(getattr(word, "error_bank_member", 0) or 0))
@staticmethod
def error_reinforce_streak(word: Word) -> int:
return max(0, int(getattr(word, "error_reinforce_streak", 0) or 0))
@staticmethod
def is_review_due(word: Word, now: datetime | None = None) -> bool:
"""仅当已排期且到期时返回 True;未排期视为冷却中,不可提前复习。"""
now = now or datetime.now(timezone.utc)
if word.next_review_at:
due_at = parse_utc(word.next_review_at, now)
return due_at <= now
if word.review_due_date:
return word.review_due_date <= now.strftime("%Y-%m-%d")
return False
@staticmethod
def is_in_error_reinforcement(word: Word, clear_count: int) -> bool:
"""错题巩固·强化阶段:在错题库且连续答对未达设定次数。"""
if not AdaptiveReviewService.error_bank_member(word):
return False
return AdaptiveReviewService.error_reinforce_streak(word) < clear_count
@staticmethod
def is_in_error_bank_review(word: Word, clear_count: int, now: datetime | None = None) -> bool:
"""错题库·间隔复习:已移出错题巩固且到达复习时间。"""
if not AdaptiveReviewService.error_bank_member(word):
return False
if AdaptiveReviewService.error_reinforce_streak(word) < clear_count:
return False
return AdaptiveReviewService.is_review_due(word, now)
@staticmethod
def is_plan_completed(word: Word) -> bool:
"""已训练、有答对记录,且不在错题库。"""
if AdaptiveReviewService.error_bank_member(word):
return False
return (
int(word.train_count or 0) > 0 and int(word.correct_count or 0) > 0
)
@staticmethod
def is_plan_eligible(word: Word) -> bool:
return not AdaptiveReviewService.is_plan_completed(word)
@staticmethod
def is_untrained_word(word: Word) -> bool:
"""从未练过的词:无练习记录且不在错题库。"""
if AdaptiveReviewService.error_bank_member(word):
return False
return int(word.train_count or 0) == 0
@staticmethod
def is_error_priority(word: Word) -> bool:
"""练习表现偏差的词:应优先进入记忆对话。"""
if AdaptiveReviewService.is_plan_completed(word):
return False
if AdaptiveReviewService.error_bank_member(word):
return True
wrong = max(0, int(word.wrong_count or 0))
if wrong <= 0:
return int(word.train_count or 0) == 0
if word.status == "weak":
return True
correct = max(0, int(word.correct_count or 0))
if correct == 0:
return True
return wrong > correct
@staticmethod
def split_plan_pools(
words: list[Word], clear_count: int = 2
) -> tuple[list[Word], list[Word]]:
"""将计划内词拆为未练词池与错题巩固强化池。"""
eligible = [w for w in words if AdaptiveReviewService.is_plan_eligible(w)]
untrained = [
w for w in eligible if AdaptiveReviewService.is_untrained_word(w)
]
reinforce = [
w
for w in eligible
if AdaptiveReviewService.is_in_error_reinforcement(w, clear_count)
]
return untrained, reinforce
@staticmethod
def split_error_bank(words: list[Word], clear_count: int = 2, now: datetime | None = None) -> tuple[list[Word], list[Word]]:
"""错题库成员拆为待复习与冷却中。"""
now = now or datetime.now(timezone.utc)
members = [w for w in words if AdaptiveReviewService.error_bank_member(w)]
due = [
w
for w in members
if AdaptiveReviewService.is_in_error_bank_review(w, clear_count, now)
]
cooling = [w for w in members if w not in due]
return due, cooling
@staticmethod
def count_plan_pools(
words: list[Word], clear_count: int = 2, now: datetime | None = None
) -> tuple[int, int, int, int]:
untrained, reinforce = AdaptiveReviewService.split_plan_pools(words, clear_count)
due, _ = AdaptiveReviewService.split_error_bank(words, clear_count, now)
bank_total = sum(1 for w in words if AdaptiveReviewService.error_bank_member(w))
return len(untrained), len(reinforce), len(due), bank_total
def select_untrained_words(
self, words: list[Word], limit: int, now: datetime | None = None, clear_count: int = 2
) -> list[Word]:
untrained, _ = self.split_plan_pools(words, clear_count)
if not untrained or limit <= 0:
return []
return self.select_words(untrained, limit, now)
def select_error_words(
self, words: list[Word], limit: int, now: datetime | None = None, clear_count: int = 2
) -> list[Word]:
_, reinforce = self.split_plan_pools(words, clear_count)
if not reinforce or limit <= 0:
return []
return self.select_words(reinforce, limit, now)
def select_error_bank_words(
self, words: list[Word], limit: int, now: datetime | None = None, clear_count: int = 2
) -> list[Word]:
due, _ = self.split_error_bank(words, clear_count, now)
if not due or limit <= 0:
return []
return self.select_words(due, limit, now)
def select_practice_plan_words(
self, words: list[Word], limit: int, now: datetime | None = None, clear_count: int = 2
) -> list[Word]:
"""未训练词优先,其次错题巩固强化词。"""
if limit <= 0:
return []
untrained_ranked = self.select_untrained_words(words, limit, now, clear_count)
if len(untrained_ranked) >= limit:
return untrained_ranked[:limit]
remaining = limit - len(untrained_ranked)
error_ranked = self.select_error_words(words, remaining, now, clear_count)
return untrained_ranked + error_ranked
def select_words(
self, words: list[Word], limit: int, now: datetime | None = None
) -> list[Word]:
now = now or datetime.now(timezone.utc)
ranked = sorted(
words,
key=lambda word: (
self.selection_score(word, now),
-word.id,
),
reverse=True,
)
return ranked[:limit]
adaptive_review_service = AdaptiveReviewService()