""" 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), }