From e1da984e083da1c3aab602b983f318a0f4edad07 Mon Sep 17 00:00:00 2001 From: hyungi Date: Sun, 7 Jun 2026 10:11:38 +0900 Subject: [PATCH] =?UTF-8?q?refactor(study):=20SR=20=EC=82=B0=EC=88=A0=20sr?= =?UTF-8?q?=5Fschedule.py=20=EA=B3=B5=EC=9A=A9=EC=B6=94=EC=B6=9C=20(B1=20?= =?UTF-8?q?=E2=80=94=20=EC=B9=B4=EB=93=9C=20SR=20=ED=86=A0=EB=8C=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문제 SR과 카드 SR이 같은 간격 상수·산술을 참조하도록 순수함수 추출. 운영 동작 무변경. - app/services/study/sr_schedule.py: REVIEW_INTERVAL_DAYS{1:3,2:7,3:14}/MASTERED=4/FIRST_DUE=1 + advance(stage,outcome,now)→(new_stage,new_due) | None(skipped) + first_due(now). 진입 게이트(due_at IS NOT NULL/최초 due/skipped 불변)는 호출부 잔류(finalize vs review-complete 정책 차이). - session_finalize.py: 상수·advance 분기 → sr_schedule import + sr_advance() (re-export 유지). - study_question_progress.py: DEFAULT_FIRST_DUE_DAYS → sr_schedule import. - 회귀 테스트 7/7: 전진 1·3·7·14·졸업·리셋·skipped불변·상수 + 전 stage×outcome 구 로직 바이트 동등. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/study_question_progress.py | 4 +- app/services/study/session_finalize.py | 27 +++----- app/services/study/sr_schedule.py | 48 +++++++++++++ tests/test_sr_schedule.py | 93 ++++++++++++++++++++++++++ 4 files changed, 154 insertions(+), 18 deletions(-) create mode 100644 app/services/study/sr_schedule.py create mode 100644 tests/test_sr_schedule.py diff --git a/app/api/study_question_progress.py b/app/api/study_question_progress.py index 7b0a4ac..fb85269 100644 --- a/app/api/study_question_progress.py +++ b/app/api/study_question_progress.py @@ -26,8 +26,8 @@ from models.user import User router = APIRouter(prefix="/study-topics", tags=["study-progress"]) -# 1차 due_at 부여 시 디폴트 1일 뒤 -DEFAULT_FIRST_DUE_DAYS = 1 +# 1차 due_at 부여 시 디폴트 1일 뒤 — SR 상수는 sr_schedule.py 단일 source (재-export). +from services.study.sr_schedule import DEFAULT_FIRST_DUE_DAYS # noqa: E402,F401 def _verify_topic_owner(topic: StudyTopic | None, user: User) -> None: diff --git a/app/services/study/session_finalize.py b/app/services/study/session_finalize.py index 4cb8854..fb10a16 100644 --- a/app/services/study/session_finalize.py +++ b/app/services/study/session_finalize.py @@ -40,10 +40,13 @@ from services.study.learning_pattern import ( compute_pattern_state, ) -# review_stage 별 다음 due_at interval (days) -REVIEW_INTERVAL_DAYS = {1: 3, 2: 7, 3: 14} -REVIEW_STAGE_MASTERED = 4 -DEFAULT_FIRST_DUE_DAYS = 1 +# SR 산술은 sr_schedule.py 단일 source (문제 SR + 카드 SR 공용). 상수는 재-export 유지. +from services.study.sr_schedule import ( # noqa: E402 + DEFAULT_FIRST_DUE_DAYS, # noqa: F401 + REVIEW_INTERVAL_DAYS, # noqa: F401 + REVIEW_STAGE_MASTERED, # noqa: F401 + advance as sr_advance, +) @dataclass @@ -185,19 +188,11 @@ async def finalize_session( progress.pattern_updated_at = now progress.pattern_window_attempts = window_size - # 복습 stage 갱신 — 이미 due_at 박힌 문제만 + # 복습 stage 갱신 — 이미 due_at 박힌 문제만 (산술은 sr_schedule 공용) if progress.due_at is not None: - if outcome == "correct": - progress.review_stage = (progress.review_stage or 0) + 1 - if progress.review_stage >= REVIEW_STAGE_MASTERED: - progress.due_at = None # 학습완료 - else: - days = REVIEW_INTERVAL_DAYS[progress.review_stage] - progress.due_at = now + timedelta(days=days) - elif outcome in ("wrong", "unsure"): - progress.review_stage = 0 - progress.due_at = now + timedelta(days=DEFAULT_FIRST_DUE_DAYS) - # skipped 는 due_at 그대로 (큐 유지, stage 변경 안 함) + result = sr_advance(progress.review_stage, outcome, now) + if result is not None: # skipped 는 None → due_at/stage 불변 + progress.review_stage, progress.due_at = result # progress.due_at IS NULL 일반 풀이 → stage 건드리지 않음 # 4. 바로 할 일 카운트 (요약 응답용) — finalize 직후 progress 상태 기준 SQL 한 번 diff --git a/app/services/study/sr_schedule.py b/app/services/study/sr_schedule.py new file mode 100644 index 0000000..518acd3 --- /dev/null +++ b/app/services/study/sr_schedule.py @@ -0,0 +1,48 @@ +"""SR(간격반복) 산술 단일 source — 문제 SR + 카드 SR 공용. + +session_finalize.py(문제 SR)와 study_memo_card writer(카드 SR)가 같은 상수·산술을 참조하도록 +순수함수로 추출. 진입 게이트(due_at IS NOT NULL 행만 갱신 / 최초 due 부여 / skipped 불변)는 +호출부에 남긴다 — finalize 와 review-complete 의 정책이 미묘히 달라 통합 시 회귀 위험. + +정본 간격(실측): review_stage 0→1→2→3 = 1·3·7·14일, stage4 = 졸업(due_at=NULL), +오답/모호 리셋 = 내일(stage 0). +""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +# review_stage 별 '다음 due_at' interval (days). stage 1→3일, 2→7일, 3→14일. +REVIEW_INTERVAL_DAYS = {1: 3, 2: 7, 3: 14} +# 이 stage 도달 시 졸업 (due_at=NULL, 복습 큐에서 제거) +REVIEW_STAGE_MASTERED = 4 +# 최초 due 부여 / 오답 리셋 = 내일 +DEFAULT_FIRST_DUE_DAYS = 1 + + +def advance( + review_stage: int | None, outcome: str, now: datetime +) -> tuple[int, datetime | None] | None: + """이미 복습 큐(due_at IS NOT NULL)에 있는 항목의 SR 갱신 산술. + + 호출부가 'due_at IS NOT NULL' 가드 후 호출한다. + 반환: + (new_stage, new_due_at) — correct/wrong/unsure. 졸업이면 new_due_at=None. + None — skipped/기타(변경 없음, 호출부가 무시). + """ + if outcome == "correct": + new_stage = (review_stage or 0) + 1 + if new_stage >= REVIEW_STAGE_MASTERED: + return new_stage, None # 학습완료(졸업) + return new_stage, now + timedelta(days=REVIEW_INTERVAL_DAYS[new_stage]) + if outcome in ("wrong", "unsure"): + return 0, now + timedelta(days=DEFAULT_FIRST_DUE_DAYS) + return None # skipped — due_at/stage 불변 + + +def first_due(now: datetime) -> tuple[int, datetime]: + """복습 큐 최초 진입(오답/모호 + due_at IS NULL) 시 부여값. + + 문제 review-complete / 카드 첫 회상 공용. 반환: (review_stage=0, due_at=내일). + """ + return 0, now + timedelta(days=DEFAULT_FIRST_DUE_DAYS) diff --git a/tests/test_sr_schedule.py b/tests/test_sr_schedule.py new file mode 100644 index 0000000..77dd1cf --- /dev/null +++ b/tests/test_sr_schedule.py @@ -0,0 +1,93 @@ +"""sr_schedule 공용추출 회귀 테스트 (B1). + +문제 SR 동작이 추출 전과 동일함을 보장 — advance() 가 구 session_finalize 분기 로직과 +바이트 동등(전진 1·3·7·14 / 졸업 / 오답 리셋 / skipped 불변 / 상수값)인지 검증. +""" + +from __future__ import annotations + +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "app")) + +from services.study import sr_schedule as sr # noqa: E402 + +NOW = datetime(2026, 6, 7, 12, 0, 0, tzinfo=timezone.utc) + + +def _old_logic(review_stage, outcome, now): + """추출 전 session_finalize.py:188-201 의 산술을 그대로 재현 (동등성 기준).""" + if outcome == "correct": + new_stage = (review_stage or 0) + 1 + if new_stage >= 4: + return new_stage, None + return new_stage, now + timedelta(days={1: 3, 2: 7, 3: 14}[new_stage]) + elif outcome in ("wrong", "unsure"): + return 0, now + timedelta(days=1) + return None # skipped + + +def test_constants(): + assert sr.REVIEW_INTERVAL_DAYS == {1: 3, 2: 7, 3: 14} + assert sr.REVIEW_STAGE_MASTERED == 4 + assert sr.DEFAULT_FIRST_DUE_DAYS == 1 + + +def test_advance_correct_progression(): + assert sr.advance(None, "correct", NOW) == (1, NOW + timedelta(days=3)) + assert sr.advance(0, "correct", NOW) == (1, NOW + timedelta(days=3)) + assert sr.advance(1, "correct", NOW) == (2, NOW + timedelta(days=7)) + assert sr.advance(2, "correct", NOW) == (3, NOW + timedelta(days=14)) + + +def test_advance_graduation(): + # stage 3 → correct → stage 4 = 졸업(due_at=None) + assert sr.advance(3, "correct", NOW) == (4, None) + assert sr.advance(4, "correct", NOW) == (5, None) + + +def test_advance_reset(): + assert sr.advance(0, "wrong", NOW) == (0, NOW + timedelta(days=1)) + assert sr.advance(2, "wrong", NOW) == (0, NOW + timedelta(days=1)) + assert sr.advance(2, "unsure", NOW) == (0, NOW + timedelta(days=1)) + + +def test_advance_skipped_no_change(): + assert sr.advance(1, "skipped", NOW) is None + assert sr.advance(3, "skipped", NOW) is None + + +def test_first_due(): + assert sr.first_due(NOW) == (0, NOW + timedelta(days=1)) + + +def test_equivalence_with_old_logic(): + # 전 stage × 전 outcome 조합에서 추출 함수 == 구 로직. + for stage in (None, 0, 1, 2, 3, 4): + for outcome in ("correct", "wrong", "unsure", "skipped"): + assert sr.advance(stage, outcome, NOW) == _old_logic(stage, outcome, NOW), \ + f"mismatch stage={stage} outcome={outcome}" + + +def test_reexport_preserved(): + # 기존 import 경로 (session_finalize / study_question_progress) 가 상수를 재-export. + from services.study import session_finalize as sf + assert sf.REVIEW_INTERVAL_DAYS == sr.REVIEW_INTERVAL_DAYS + assert sf.REVIEW_STAGE_MASTERED == sr.REVIEW_STAGE_MASTERED + assert sf.DEFAULT_FIRST_DUE_DAYS == sr.DEFAULT_FIRST_DUE_DAYS + + +_TESTS = [v for k, v in dict(globals()).items() if k.startswith("test_")] + +if __name__ == "__main__": + # session_finalize import 는 무거운 의존(ai 등) 가능 — reexport 테스트만 조건부. + ran = 0 + for t in _TESTS: + if t.__name__ == "test_reexport_preserved": + continue + t() + ran += 1 + print(f"OK ({ran} pure tests; reexport는 pytest에서)")