cert-study-app / cert_study_app /services /learning_progress_service.py
Kentlo's picture
Sync from GitHub b098d17dbed1a6386c212d28b4bfa6138ad59d75
88c7275 verified
Raw
History Blame Contribute Delete
13 kB
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))