feat(trainer): add GitHub activity heatmap, 4-option quiz mode, active typing, and duplicate checks
This commit is contained in:
+250
@@ -0,0 +1,250 @@
|
||||
"""
|
||||
SuperMemo-2 (SM-2) spaced repetition system (SRS) calculation logic,
|
||||
quiz generation, and activity heatmap / streak analysis.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone, date
|
||||
from typing import Dict, Any, Optional, List
|
||||
import random
|
||||
|
||||
# Quality rating constants
|
||||
RATING_AGAIN = 1
|
||||
RATING_HARD = 2
|
||||
RATING_GOOD = 3
|
||||
RATING_EASY = 4
|
||||
|
||||
MIN_EASE = 1.3
|
||||
MAX_EASE = 3.0
|
||||
DEFAULT_EASE = 2.5
|
||||
MASTERED_INTERVAL_THRESHOLD = 21
|
||||
|
||||
FALLBACK_DISTRACTORS = [
|
||||
("concurrency", "одновременность, параллелизм"),
|
||||
("throughput", "пропускная способность"),
|
||||
("bottleneck", "узкое место"),
|
||||
("redundancy", "избыточность, резервирование"),
|
||||
("resilience", "устойчивость, отказоустойчивость"),
|
||||
("deprecated", "устаревший, не рекомендуемый"),
|
||||
("mitigate", "смягчать, уменьшать последствия"),
|
||||
("seamless", "бесшовный, плавный"),
|
||||
("leverage", "использовать с выгодой"),
|
||||
("tweak", "подправить, тонко настроить"),
|
||||
("scaffold", "каркас, шаблон структуры"),
|
||||
("boilerplate", "шаблонный код"),
|
||||
("payload", "полезная нагрузка"),
|
||||
("latency", "задержка, пинг"),
|
||||
("invoke", "вызывать (функцию/метод)"),
|
||||
("retrieve", "извлекать, получать данные"),
|
||||
("subtle", "тонкий, едва уловимый"),
|
||||
("comprehensive", "всесторонний, исчерпывающий"),
|
||||
("reluctant", "неохотный, нежелающий"),
|
||||
("perseverance", "настойчивость, упорство"),
|
||||
("serendipity", "счастливая случайность"),
|
||||
("ubiquitous", "вездесущий, повсеместный"),
|
||||
("indispensable", "незаменимый"),
|
||||
("arbitrary", "произвольный"),
|
||||
("coherent", "связный, последовательный"),
|
||||
("feasible", "осуществимый, выполнимый"),
|
||||
("tangible", "осязаемый, ощутимый"),
|
||||
("robust", "надежный, устойчивый к сбоям"),
|
||||
("versatile", "универсальный, гибкий"),
|
||||
("streamline", "оптимизировать, упрощать"),
|
||||
]
|
||||
|
||||
|
||||
def calculate_next_review(interval_days: int, base_time: Optional[datetime] = None) -> str:
|
||||
"""Calculates the next review timestamp formatted as 'YYYY-MM-DD HH:MM:SS' (UTC)."""
|
||||
if base_time is None:
|
||||
base_time = datetime.now(timezone.utc)
|
||||
next_time = base_time + timedelta(days=interval_days)
|
||||
return next_time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def calculate_sm2(
|
||||
rating: int,
|
||||
reps: Optional[int] = 0,
|
||||
interval: Optional[int] = 0,
|
||||
ease: Optional[float] = 2.5,
|
||||
state: Optional[str] = "new",
|
||||
base_time: Optional[datetime] = None,
|
||||
error_count: Optional[int] = 0,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Processes a card review rating using SM-2 algorithm:
|
||||
- 1: Again (reps=0, interval=1, ease = max(1.3, ease - 0.2), state='learning')
|
||||
- 2: Hard (reps=reps+1, interval=max(1, round(interval * 1.2)), ease = max(1.3, ease - 0.15), state='learning' if reps < 3 else 'review')
|
||||
- 3: Good (reps=reps+1, interval= 1 if reps==1 else (6 if reps==2 else round(interval * ease)), state='review' if interval < 21 else 'mastered')
|
||||
- 4: Easy (reps=reps+1, interval= 4 if reps==1 else round(interval * ease * 1.3), ease = min(3.0, ease + 0.15), state='review' if interval < 21 else 'mastered')
|
||||
"""
|
||||
if rating not in (RATING_AGAIN, RATING_HARD, RATING_GOOD, RATING_EASY):
|
||||
raise ValueError(f"Invalid rating {rating}: must be 1 (Again), 2 (Hard), 3 (Good), or 4 (Easy)")
|
||||
|
||||
reps = reps if reps is not None else 0
|
||||
interval = interval if interval is not None else 0
|
||||
ease = ease if ease is not None else DEFAULT_EASE
|
||||
state = state or "new"
|
||||
error_count = error_count if error_count is not None else 0
|
||||
|
||||
if base_time is None:
|
||||
base_time = datetime.now(timezone.utc)
|
||||
last_review_str = base_time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
new_error_count = error_count
|
||||
if rating == RATING_AGAIN:
|
||||
new_reps = 0
|
||||
new_interval = 1
|
||||
new_ease = max(MIN_EASE, ease - 0.2)
|
||||
new_state = "learning"
|
||||
new_error_count += 1
|
||||
|
||||
elif rating == RATING_HARD:
|
||||
new_reps = reps + 1
|
||||
new_interval = max(1, round(interval * 1.2))
|
||||
new_ease = max(MIN_EASE, ease - 0.15)
|
||||
new_state = "learning" if new_reps < 3 else "review"
|
||||
|
||||
elif rating == RATING_GOOD:
|
||||
new_reps = reps + 1
|
||||
if new_reps == 1:
|
||||
new_interval = 1
|
||||
elif new_reps == 2:
|
||||
new_interval = 6
|
||||
else:
|
||||
new_interval = round(interval * ease)
|
||||
new_ease = ease
|
||||
new_state = "review" if new_interval < MASTERED_INTERVAL_THRESHOLD else "mastered"
|
||||
|
||||
elif rating == RATING_EASY:
|
||||
new_reps = reps + 1
|
||||
if new_reps == 1:
|
||||
new_interval = 4
|
||||
else:
|
||||
new_interval = round(interval * ease * 1.3)
|
||||
new_ease = min(MAX_EASE, ease + 0.15)
|
||||
new_state = "review" if new_interval < MASTERED_INTERVAL_THRESHOLD else "mastered"
|
||||
|
||||
new_ease = round(new_ease, 2)
|
||||
next_review_str = calculate_next_review(new_interval, base_time)
|
||||
|
||||
return {
|
||||
"srs_reps": new_reps,
|
||||
"srs_interval": new_interval,
|
||||
"srs_ease": new_ease,
|
||||
"srs_state": new_state,
|
||||
"srs_last_review": last_review_str,
|
||||
"srs_next_review": next_review_str,
|
||||
"error_count": new_error_count,
|
||||
}
|
||||
|
||||
|
||||
def generate_quiz_options(target_card: Dict[str, Any], all_cards: List[Dict[str, Any]], count: int = 4) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Generates 4 multiple choice options for a target card (1 correct + 3 distractors).
|
||||
Returns list of options with text and is_correct flag, randomly shuffled.
|
||||
"""
|
||||
target_id = target_card.get("id")
|
||||
correct_text = target_card.get("translation", "")
|
||||
|
||||
# Gather potential distractors from user's other cards
|
||||
candidate_distractors = [
|
||||
c.get("translation", "").strip()
|
||||
for c in all_cards
|
||||
if c.get("id") != target_id and c.get("translation", "").strip() and c.get("translation", "").strip() != correct_text
|
||||
]
|
||||
|
||||
# Deduplicate candidates
|
||||
candidate_distractors = list(dict.fromkeys(candidate_distractors))
|
||||
random.shuffle(candidate_distractors)
|
||||
|
||||
selected_distractors: List[str] = []
|
||||
for d in candidate_distractors:
|
||||
if len(selected_distractors) >= (count - 1):
|
||||
break
|
||||
selected_distractors.append(d)
|
||||
|
||||
# If still not enough, take from fallback dictionary
|
||||
if len(selected_distractors) < (count - 1):
|
||||
for _, ru in FALLBACK_DISTRACTORS:
|
||||
if ru != correct_text and ru not in selected_distractors:
|
||||
selected_distractors.append(ru)
|
||||
if len(selected_distractors) >= (count - 1):
|
||||
break
|
||||
|
||||
options = [{"text": correct_text, "is_correct": True}]
|
||||
for d in selected_distractors:
|
||||
options.append({"text": d, "is_correct": False})
|
||||
|
||||
random.shuffle(options)
|
||||
return options
|
||||
|
||||
|
||||
def build_activity_heatmap(review_dates: Dict[str, int], days: int = 180) -> Dict[str, Any]:
|
||||
"""
|
||||
Constructs a daily contribution heatmap (like GitHub) for the past `days`.
|
||||
Returns daily entries with date, count, and level (0: none, 1: 1 review, 2: 2-3 reviews, 3: 4+ reviews).
|
||||
Also computes current streak and longest streak.
|
||||
"""
|
||||
today = date.today()
|
||||
start_date = today - timedelta(days=days - 1)
|
||||
|
||||
heatmap_days = []
|
||||
current_date = start_date
|
||||
|
||||
while current_date <= today:
|
||||
d_str = current_date.strftime("%Y-%m-%d")
|
||||
count = review_dates.get(d_str, 0)
|
||||
if count == 0:
|
||||
level = 0
|
||||
elif count == 1:
|
||||
level = 1
|
||||
elif count <= 3:
|
||||
level = 2
|
||||
else:
|
||||
level = 3
|
||||
|
||||
heatmap_days.append({
|
||||
"date": d_str,
|
||||
"count": count,
|
||||
"level": level,
|
||||
"day_of_week": current_date.weekday(), # 0=Mon, 6=Sun
|
||||
})
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
# Compute streaks
|
||||
current_streak = 0
|
||||
check_day = today
|
||||
# Check today
|
||||
if review_dates.get(check_day.strftime("%Y-%m-%d"), 0) > 0:
|
||||
current_streak += 1
|
||||
check_day -= timedelta(days=1)
|
||||
else:
|
||||
# If no activity today yet, check yesterday to preserve ongoing streak
|
||||
yesterday = today - timedelta(days=1)
|
||||
if review_dates.get(yesterday.strftime("%Y-%m-%d"), 0) > 0:
|
||||
check_day = yesterday
|
||||
|
||||
while check_day >= start_date:
|
||||
if review_dates.get(check_day.strftime("%Y-%m-%d"), 0) > 0:
|
||||
if check_day != today: # don't double-count today
|
||||
current_streak += 1
|
||||
check_day -= timedelta(days=1)
|
||||
else:
|
||||
break
|
||||
|
||||
# Longest streak calculation
|
||||
longest_streak = 0
|
||||
temp_streak = 0
|
||||
for day in heatmap_days:
|
||||
if day["count"] > 0:
|
||||
temp_streak += 1
|
||||
if temp_streak > longest_streak:
|
||||
longest_streak = temp_streak
|
||||
else:
|
||||
temp_streak = 0
|
||||
|
||||
return {
|
||||
"days": heatmap_days,
|
||||
"total_active_days": sum(1 for d in heatmap_days if d["count"] > 0),
|
||||
"total_reviews": sum(d["count"] for d in heatmap_days),
|
||||
"current_streak": current_streak,
|
||||
"longest_streak": max(longest_streak, current_streak),
|
||||
}
|
||||
Reference in New Issue
Block a user