Spaces:
Running
Running
| from __future__ import annotations | |
| import json | |
| from datetime import date, timedelta | |
| from typing import Any, Optional | |
| from cert_study_app.config import DATA_DIR | |
| PROGRESS_PATH = DATA_DIR / "learning_progress.json" | |
| STUDY_UNIT_POINTS = 3.0 | |
| ACTIVITY_POINTS = { | |
| "lesson": 1.0, | |
| "quiz": 0.35, | |
| "review": 1.0, | |
| "practice": 2.0, | |
| "cert_question": 0.5, | |
| "docs": 1.0, | |
| } | |
| STEP_ACTIVITY_DEFAULTS = { | |
| "lesson": ("lesson", 1), | |
| "quiz": ("quiz", 3), | |
| "apply": ("practice", 1), | |
| "review": ("review", 1), | |
| } | |
| DEFAULT_TRACK_ID = "linux" | |
| # κ°κ²© λ°λ³΅ 볡μ΅: νλ¦° ν 1β3β7β14β30μΌ κ°κ²©μΌλ‘ μ¬μΆμ | |
| _SPACED_INTERVALS = [1, 3, 7, 14, 30] | |
| def load_progress() -> dict[str, Any]: | |
| if not PROGRESS_PATH.exists(): | |
| return {"activity": {}} | |
| try: | |
| payload = json.loads(PROGRESS_PATH.read_text(encoding="utf-8")) | |
| except Exception: | |
| return {"activity": {}} | |
| if not isinstance(payload, dict): | |
| return {"activity": {}} | |
| return _normalize_progress(payload) | |
| def _normalize_progress(progress: dict[str, Any]) -> dict[str, Any]: | |
| """Keep old progress files readable while storing ongoing study as activity.""" | |
| activity = progress.get("activity") | |
| legacy_daily = progress.get("daily") | |
| if not isinstance(activity, dict): | |
| activity = legacy_daily if isinstance(legacy_daily, dict) else {} | |
| progress["activity"] = activity | |
| return progress | |
| def _activity_days(progress: dict[str, Any]) -> dict[str, Any]: | |
| activity = progress.setdefault("activity", {}) | |
| if isinstance(activity, dict): | |
| return activity | |
| progress["activity"] = {} | |
| return progress["activity"] | |
| def save_progress(payload: dict[str, Any]) -> None: | |
| PROGRESS_PATH.parent.mkdir(parents=True, exist_ok=True) | |
| PROGRESS_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") | |
| def preferred_track() -> str: | |
| progress = load_progress() | |
| preferences = progress.get("preferences", {}) | |
| track_id = preferences.get("preferred_track") if isinstance(preferences, dict) else None | |
| return str(track_id or DEFAULT_TRACK_ID) | |
| def save_preferred_track(track_id: str) -> str: | |
| normalized = str(track_id or DEFAULT_TRACK_ID) | |
| progress = load_progress() | |
| preferences = progress.setdefault("preferences", {}) | |
| preferences["preferred_track"] = normalized | |
| save_progress(progress) | |
| return normalized | |
| def completed_steps(track_id: str, target_date: Optional[date] = None) -> set[str]: | |
| target = (target_date or date.today()).isoformat() | |
| progress = load_progress() | |
| steps = ( | |
| _activity_days(progress) | |
| .get(target, {}) | |
| .get("tracks", {}) | |
| .get(track_id, {}) | |
| .get("steps", []) | |
| ) | |
| return set(steps if isinstance(steps, list) else []) | |
| def mark_learning_step(track_id: str, step_id: str, target_date: Optional[date] = None) -> set[str]: | |
| target = (target_date or date.today()).isoformat() | |
| progress = load_progress() | |
| activity_days = _activity_days(progress) | |
| day_record = activity_days.setdefault(target, {"tracks": {}}) | |
| tracks = day_record.setdefault("tracks", {}) | |
| track_record = tracks.setdefault(track_id, {"steps": []}) | |
| steps = set(track_record.get("steps") or []) | |
| steps.add(step_id) | |
| track_record["steps"] = sorted(steps) | |
| track_record["updated_at"] = date.today().isoformat() | |
| save_progress(progress) | |
| if step_id in STEP_ACTIVITY_DEFAULTS: | |
| activity, amount = STEP_ACTIVITY_DEFAULTS[step_id] | |
| record_activity(track_id, activity, amount, target_date=target_date) | |
| return steps | |
| def record_activity(track_id: str, activity: str, amount: int = 1, target_date: Optional[date] = None) -> dict[str, Any]: | |
| target = (target_date or date.today()).isoformat() | |
| progress = load_progress() | |
| activity_days = _activity_days(progress) | |
| day_record = activity_days.setdefault(target, {"tracks": {}}) | |
| tracks = day_record.setdefault("tracks", {}) | |
| track_record = tracks.setdefault(track_id, {"steps": [], "counts": {}}) | |
| counts = track_record.setdefault("counts", {}) | |
| counts[activity] = int(counts.get(activity, 0)) + int(amount) | |
| track_record["updated_at"] = date.today().isoformat() | |
| save_progress(progress) | |
| return track_record | |
| def track_counts_points(track: dict[str, Any]) -> float: | |
| counts = track.get("counts", {}) if isinstance(track, dict) else {} | |
| points = 0.0 | |
| for activity, count in counts.items(): | |
| points += ACTIVITY_POINTS.get(activity, 0.0) * int(count or 0) | |
| return points | |
| def tracks_points(tracks: dict[str, Any]) -> float: | |
| return sum(track_counts_points(track) for track in tracks.values()) | |
| def tracks_step_count(tracks: dict[str, Any]) -> int: | |
| return sum(len(track.get("steps") or []) for track in tracks.values() if isinstance(track, dict)) | |
| def study_points(track_id: Optional[str] = None, target_date: Optional[date] = None) -> float: | |
| target = (target_date or date.today()).isoformat() | |
| progress = load_progress() | |
| tracks = _activity_days(progress).get(target, {}).get("tracks", {}) | |
| selected_tracks = {track_id: tracks.get(track_id, {})} if track_id else tracks | |
| return round(tracks_points(selected_tracks), 2) | |
| def study_units(track_id: Optional[str] = None, target_date: Optional[date] = None) -> float: | |
| return round(study_points(track_id, target_date) / STUDY_UNIT_POINTS, 1) | |
| def weekly_summary(days: int = 7) -> dict[str, Any]: | |
| progress = load_progress() | |
| activity_days = _activity_days(progress) | |
| today = date.today() | |
| active_days = 0 | |
| completed_steps_count = 0 | |
| points = 0.0 | |
| for offset in range(days): | |
| key = (today - timedelta(days=offset)).isoformat() | |
| tracks = activity_days.get(key, {}).get("tracks", {}) | |
| day_steps = tracks_step_count(tracks) | |
| day_points = tracks_points(tracks) | |
| if day_steps or day_points: | |
| active_days += 1 | |
| completed_steps_count += day_steps | |
| points += day_points | |
| return { | |
| "active_days": active_days, | |
| "completed_steps": completed_steps_count, | |
| "study_units": round(points / STUDY_UNIT_POINTS, 1), | |
| "activity_units": round(points / STUDY_UNIT_POINTS, 1), | |
| "daily_units": round(points / STUDY_UNIT_POINTS, 1), | |
| } | |
| def streak_days() -> int: | |
| progress = load_progress() | |
| activity_days = _activity_days(progress) | |
| today = date.today() | |
| streak = 0 | |
| for offset in range(365): | |
| key = (today - timedelta(days=offset)).isoformat() | |
| tracks = activity_days.get(key, {}).get("tracks", {}) | |
| day_steps = tracks_step_count(tracks) | |
| day_points = tracks_points(tracks) | |
| if not day_steps and not day_points: | |
| break | |
| streak += 1 | |
| return streak | |
| def next_day_recommendation(track_id: str) -> str: | |
| steps = completed_steps(track_id) | |
| if "lesson" not in steps: | |
| return "μ΄λ‘ μΉ΄λ 1κ°λΆν° μμνλ©΄ λΆλ΄μ΄ κ°μ₯ μ μ΅λλ€." | |
| if "quiz" not in steps: | |
| return "μ€λ λ³Έ μ΄λ‘ μ νμΈ ν΄μ¦λ‘ λ°λ‘ μ κ²ν΄ 보μΈμ." | |
| if "apply" not in steps: | |
| return "κ°λ μ λ΄€λ€λ©΄ μ€μ΅μ΄λ λ¬Έμ νμ΄λ‘ ν λ² μ μ©ν΄ 보μΈμ." | |
| if "review" not in steps: | |
| return "μ€λ΅ 1κ°λ₯Ό 볡μ΅ν λ€ κ³μ μ΄μ΄μ νλ©΄ νμ΅λμ΄ λμ λ©λλ€." | |
| units = study_units(track_id) | |
| if units >= 1: | |
| return f"Focus μ§λ νλ¦μ μ§λμμ΅λλ€. μ§κΈκΉμ§ {units}λ¨μλ§νΌ 곡λΆνκ³ , κ³μ μ΄μ΄κ° μ μμ΅λλ€." | |
| return "Focus μ§λ νλ¦μ κ±°μ μ§λμμ΅λλ€. λΆλ΄ μμ΄ ν λ¨κ³ λ μ΄μ΄κ°λ©΄ λ©λλ€." | |
| def daily_points(track_id: Optional[str] = None, target_date: Optional[date] = None) -> float: | |
| return study_points(track_id, target_date) | |
| def daily_units(track_id: Optional[str] = None, target_date: Optional[date] = None) -> float: | |
| return study_units(track_id, target_date) | |
| # ββ Lab μλ£ νλͺ© μμ ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def save_completed_items( | |
| lessons: set[str], | |
| quizzes: set[str], | |
| practices: set[str], | |
| ) -> None: | |
| progress = load_progress() | |
| progress["completed"] = { | |
| "lessons": sorted(lessons), | |
| "quizzes": sorted(quizzes), | |
| "practices": sorted(practices), | |
| } | |
| save_progress(progress) | |
| def load_completed_items() -> tuple[set[str], set[str], set[str]]: | |
| progress = load_progress() | |
| completed = progress.get("completed", {}) | |
| return ( | |
| set(completed.get("lessons", [])), | |
| set(completed.get("quizzes", [])), | |
| set(completed.get("practices", [])), | |
| ) | |
| def save_wrong_notes(notes: list[dict]) -> None: | |
| progress = load_progress() | |
| progress["wrong_notes"] = notes | |
| save_progress(progress) | |
| def load_wrong_notes() -> list[dict]: | |
| progress = load_progress() | |
| return progress.get("wrong_notes", []) | |
| # ββ κ°λ ν΄μ¦ κ°κ²© λ°λ³΅ (string ID κΈ°λ°) βββββββββββββββββββββββββββββββββββββ | |
| def update_lab_spaced_review(quiz_id: str, correct: bool) -> None: | |
| """κ°λ /command ν΄μ¦ μ€λ΅μ 1β3β7β14β30μΌ κ°κ²©μΌλ‘ μ¬μΆμ μ€μΌμ€μ λ±λ‘.""" | |
| progress = load_progress() | |
| spaced: dict = progress.setdefault("lab_spaced_review", {}) | |
| key = str(quiz_id) | |
| today = date.today() | |
| if not correct: | |
| spaced[key] = { | |
| "interval": 1, | |
| "next_review": (today + timedelta(days=1)).isoformat(), | |
| "correct_streak": 0, | |
| } | |
| elif key in spaced: | |
| record = spaced[key] | |
| streak = int(record.get("correct_streak", 0)) + 1 | |
| if streak >= 3: | |
| del spaced[key] | |
| else: | |
| cur_interval = int(record.get("interval", 1)) | |
| cur_idx = _SPACED_INTERVALS.index(cur_interval) if cur_interval in _SPACED_INTERVALS else 0 | |
| next_interval = _SPACED_INTERVALS[min(cur_idx + 1, len(_SPACED_INTERVALS) - 1)] | |
| spaced[key] = { | |
| "interval": next_interval, | |
| "next_review": (today + timedelta(days=next_interval)).isoformat(), | |
| "correct_streak": streak, | |
| } | |
| save_progress(progress) | |
| def lab_spaced_review_due_today(limit: int = 30) -> list[str]: | |
| """μ€λ λ³΅μ΅ κΈ°νμ΄ λ κ°λ ν΄μ¦ ID λͺ©λ‘.""" | |
| progress = load_progress() | |
| spaced: dict = progress.get("lab_spaced_review", {}) | |
| today = date.today().isoformat() | |
| due: list[str] = [] | |
| for qid, record in spaced.items(): | |
| if isinstance(record, dict) and record.get("next_review", "") <= today: | |
| due.append(qid) | |
| return due[:limit] | |
| def lab_spaced_review_count() -> int: | |
| return len(lab_spaced_review_due_today(limit=200)) | |
| # ββ κ°κ²© λ°λ³΅ λ³΅μ΅ (Spaced Repetition) ββββββββββββββββββββββββββββββββββββββ | |
| def update_spaced_review(question_id: int, correct: bool) -> None: | |
| """νλ¦° λ¬Έμ λ 1β3β7β14β30μΌ κ°κ²©μΌλ‘ μ¬μΆμ μ€μΌμ€μ λ±λ‘νλ€. | |
| 3μ°μ μ λ΅μ΄λ©΄ μ€μΌμ€μμ μ κ±°νλ€.""" | |
| progress = load_progress() | |
| spaced: dict = progress.setdefault("spaced_review", {}) | |
| key = str(question_id) | |
| today = date.today() | |
| if not correct: | |
| spaced[key] = { | |
| "interval": 1, | |
| "next_review": (today + timedelta(days=1)).isoformat(), | |
| "correct_streak": 0, | |
| } | |
| elif key in spaced: | |
| record = spaced[key] | |
| streak = int(record.get("correct_streak", 0)) + 1 | |
| if streak >= 3: | |
| del spaced[key] | |
| else: | |
| cur_interval = int(record.get("interval", 1)) | |
| cur_idx = _SPACED_INTERVALS.index(cur_interval) if cur_interval in _SPACED_INTERVALS else 0 | |
| next_interval = _SPACED_INTERVALS[min(cur_idx + 1, len(_SPACED_INTERVALS) - 1)] | |
| spaced[key] = { | |
| "interval": next_interval, | |
| "next_review": (today + timedelta(days=next_interval)).isoformat(), | |
| "correct_streak": streak, | |
| } | |
| save_progress(progress) | |
| def spaced_review_due_today(limit: int = 10) -> list[int]: | |
| """μ€λ λ³΅μ΅ κΈ°νμ΄ λ question_id λͺ©λ‘μ λ°ννλ€.""" | |
| progress = load_progress() | |
| spaced: dict = progress.get("spaced_review", {}) | |
| today = date.today().isoformat() | |
| due: list[int] = [] | |
| for qid_str, record in spaced.items(): | |
| if isinstance(record, dict) and record.get("next_review", "") <= today: | |
| try: | |
| due.append(int(qid_str)) | |
| except ValueError: | |
| pass | |
| return due[:limit] | |
| def spaced_review_count() -> int: | |
| """μ€λ λ³΅μ΅ κΈ°νμ΄ λ λ¬Έμ μ.""" | |
| return len(spaced_review_due_today(limit=200)) | |