22
33test_recommender.py 와 동일하게, db/vector_store 는 config(env)·qdrant_client 를 끌어오므로
44import 전에 가짜 모듈로 대체한다 → RDS·Qdrant·키 없이 '선정 규칙(계약)'만 순수 검증.
5- _select_originals 자체는 personalize 의 순수함수(_slow_threshold/_history_signals)만 쓰므로
6- 이력(rounds)을 손으로 만들어 정렬·우선순위·상한·제외 규칙을 그대로 못박는다.
5+ _select_originals 는 personalize._is_slow(난이도별 '느림' 판정)·_history_signals 를 쓴다.
6+ 난이도는 vector_store.retrieve_meta 로 조회하므로 easy_meta 픽스처로 전 문제를 난이도 하(1)로
7+ 고정한다(컷오프 120초). 이력(rounds)을 손으로 만들어 정렬·우선순위·상한·제외 규칙을 못박는다.
78"""
89import sys
910import types
@@ -26,44 +27,57 @@ def _round(section_id, answers, times=None):
2627 return {"section_id" : section_id , "answers" : answers , "times" : times or {}}
2728
2829
30+ @pytest .fixture
31+ def easy_meta (monkeypatch ):
32+ """선정 테스트의 모든 문제를 난이도 하(1)로 고정 → '느림' = 120초 초과."""
33+ personalize ._meta_of .cache_clear ()
34+ monkeypatch .setattr (
35+ personalize .vector_store , "retrieve_meta" ,
36+ lambda qid : {"courseId" : 1 , "sectionId" : 0 , "difficulty" : 1 , "instructorId" : None },
37+ raising = False ,
38+ )
39+ yield
40+ personalize ._meta_of .cache_clear ()
41+
42+
2943# ---------- _select_originals: 선정 규칙 ----------
3044
3145def test_coldstart_no_history_selects_nothing ():
3246 """이력 없음 → 복습시킬 근거 없음 → 빈 선정."""
3347 assert review ._select_originals ([]) == []
3448
3549
36- def test_wrong_slow_ranks_before_wrong_fast_and_masters_excluded ():
50+ def test_wrong_slow_ranks_before_wrong_fast_and_masters_excluded (easy_meta ):
3751 """한 섹션 안: 틀림+느림(우선순위0)이 틀림+빠름(1)보다 앞. 맞음+빠름은 후보 제외."""
38- # times median=1 → 느림 임계값 1.5. q1@10 =느림, q2/q3@1 =빠름.
39- rounds = [_round (100 , [(1 , False ), (2 , False ), (3 , True )], {1 : 10 , 2 : 1 , 3 : 1 })]
52+ # 난이도 하 컷오프 120초. q1@130 =느림, q2/q3@10 =빠름.
53+ rounds = [_round (100 , [(1 , False ), (2 , False ), (3 , True )], {1 : 130 , 2 : 10 , 3 : 10 })]
4054 result = review ._select_originals (rounds )
4155 assert result == [(1 , 100 ), (2 , 100 )] # 느린 오답이 먼저, 그다음 빠른 오답
4256 assert 3 not in [qid for qid , _ in result ] # 맞음+빠름(완전습득)은 빠짐
4357
4458
45- def test_correct_slow_is_kept_but_lower_priority_than_wrong_section ():
59+ def test_correct_slow_is_kept_but_lower_priority_than_wrong_section (easy_meta ):
4660 """'맞았지만 느림'은 제외되지 않고 남되(우선순위2), 틀린 섹션(tier↓)보다 뒤로 밀린다."""
47- # times [2,2,10] median=2 → 임계값 3 . q2@10 =느림.
61+ # 컷오프 120초 . q2@130 =느림.
4862 rounds = [
49- _round (200 , [(1 , False ), (3 , False )], {1 : 2 , 3 : 2 }), # 틀림+빠름 → 섹션 tier 1
50- _round (201 , [(2 , True )], {2 : 10 }), # 맞음+느림 → 섹션 tier 2
63+ _round (200 , [(1 , False ), (3 , False )], {1 : 10 , 3 : 10 }), # 틀림+빠름 → 섹션 tier 1
64+ _round (201 , [(2 , True )], {2 : 130 }), # 맞음+느림 → 섹션 tier 2
5165 ]
5266 result = review ._select_originals (rounds )
5367 assert (2 , 201 ) in result # 맞음+느림도 복습 대상으로 살아남음
5468 assert result [- 1 ] == (2 , 201 ) # 단, 틀린 섹션 뒤로
5569 assert all (sec == 200 for _ , sec in result [:2 ])
5670
5771
58- def test_cap_per_section_limits_to_two ():
72+ def test_cap_per_section_limits_to_two (easy_meta ):
5973 """한 섹션에서 후보가 많아도 섹션당 MAX_ORIGINALS_PER_SECTION(2)까지만."""
6074 rounds = [_round (300 , [(1 , False ), (2 , False ), (3 , False )], {1 : 5 , 2 : 5 , 3 : 5 })]
6175 result = review ._select_originals (rounds )
6276 assert len (result ) == 2
6377 assert all (sec == 300 for _ , sec in result )
6478
6579
66- def test_cap_total_limits_to_ten ():
80+ def test_cap_total_limits_to_ten (easy_meta ):
6781 """전체 후보가 넘쳐도 MAX_ORIGINALS_TOTAL(10)까지만(섹션 6개×2=12 → 10)."""
6882 rounds = []
6983 qid = 1
@@ -78,15 +92,15 @@ def test_cap_total_limits_to_ten():
7892 assert len (result ) == review .MAX_ORIGINALS_TOTAL == 10
7993
8094
81- def test_synthetic_slow_correct_gets_selected_when_no_wrong_crowding ():
95+ def test_synthetic_slow_correct_gets_selected_when_no_wrong_crowding (easy_meta ):
8296 """[가상 시나리오] 실데이터(9231)는 틀린 섹션 7개가 top-10을 다 채워 '느린 정답'
8397 원문제가 선정까지는 못 갔다. 틀림이 자리를 안 뺏는 학생이면 '맞았지만 느림'이
84- 실제로 원문제로 뽑히는가? → 뽑힌다. (본인 median 낮게 유지: 대부분 빠른정답). """
85- # times median=1 → 임계값 1.5. @15만 느림. 빠른정답(@1 )은 완전습득 → 제외.
98+ 실제로 원문제로 뽑히는가? → 뽑힌다."""
99+ # 컷오프 120초. @130만 느림. 빠른정답(@10 )은 완전습득 → 제외.
86100 rounds = [
87- _round (700 , [(1 , True ), (3 , True )], {1 : 15 , 3 : 1 }), # q1 맞음+느림(target), q3 맞음+빠름(제외)
88- _round (701 , [(2 , True ), (4 , True )], {2 : 15 , 4 : 1 }), # q2 맞음+느림(target), q4 맞음+빠름(제외)
89- _round (702 , [(5 , True ), (6 , True )], {5 : 1 , 6 : 1 }), # 완전습득 섹션 → 전부 제외
101+ _round (700 , [(1 , True ), (3 , True )], {1 : 130 , 3 : 10 }), # q1 맞음+느림(target), q3 맞음+빠름(제외)
102+ _round (701 , [(2 , True ), (4 , True )], {2 : 130 , 4 : 10 }), # q2 맞음+느림(target), q4 맞음+빠름(제외)
103+ _round (702 , [(5 , True ), (6 , True )], {5 : 10 , 6 : 10 }), # 완전습득 섹션 → 전부 제외
90104 ]
91105 result = review ._select_originals (rounds )
92106 assert result == [(1 , 700 ), (2 , 701 )] # 느린 정답 2개가 실제로 선정됨
@@ -109,7 +123,7 @@ def test_recommend_review_attaches_similars_and_course_without_original(monkeypa
109123 review .recommender , "get_similar_problems" ,
110124 lambda sid , pid , k : [pid , 9001 , 9002 ], # [원문제, 유사1, 유사2]
111125 )
112- monkeypatch .setattr (review .personalize , "_meta_of" , lambda qid : {"courseId" : 88 })
126+ monkeypatch .setattr (review .personalize , "_meta_of" , lambda qid : {"courseId" : 88 , "difficulty" : 1 })
113127 result = review .recommend_review (7 , k = 2 )
114128 assert result == [
115129 {"problem_id" : 11 , "section_id" : 500 , "course_id" : 88 , "similar" : [9001 , 9002 ]}
0 commit comments