05e173b293
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>
337 lines
12 KiB
Python
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()
|