e5982ebde4
vision (풀이 → 확인 → 학습 → 복습 → 다음 풀이 가중치) 의 데이터 계층.
데이터 모델 (migrations 222~225):
- study_question_progress 테이블 — user × topic × question 단위 현재 상태 캐시
- 마지막 시도: last_outcome, last_attempted_at, last_attempt_id
- 검토 상태: last_reviewed_at
- 복습 큐: due_at, review_stage
- 패턴 분류 (derived): pattern_state, pattern_updated_at, pattern_window_attempts
- 3 partial idx (due / topic_pattern / pending_review) — 탭별 빠른 조회
패턴 분류 (services/study/learning_pattern.py):
- 7 분류: unattempted/unsure/chronic_wrong/regressed/recovered/stable/unstable
- 윈도우 = 최근 3회 + 과거 correct/wrong 존재 여부
- chronic_wrong > regressed > recovered 우선순위 (보수적 학습)
- 가드: wrong 1회만으로 regressed 안 됨 (이전 correct 이력 필요)
- stable 은 3 연속 correct 부터
세션 종료 집계 (services/study/session_finalize.py):
- attempts append-only 원본 보존, progress upsert 만
- 마지막 attempt 직후 finalize hook 자동 발동
- finalize 는 last_* + pattern_state 만 갱신, due_at 미진입 문제는 NULL 유지
- 이미 due_at 박힌 문제는 finalize 가 stage 갱신 (correct → +1 / wrong → 리셋)
API (api/study_question_progress.py):
- POST /study-topics/{tid}/questions/{qid}/review-complete
→ last_reviewed_at + (wrong/unsure 인 경우만) due_at 최초 부여
- GET /study-topics/{tid}/review-queue?tab=due_today|pending_review|chronic|regressed|mastered
→ 5 탭 paginated 조회
→ pending_review 는 last_reviewed_at < last_attempted_at 까지 포함 (이전 확인완료 후 다시 wrong 잡힘)
Phase 1-E (풀이 선별 알고리즘) 은 후속 commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
93 lines
3.3 KiB
Python
93 lines
3.3 KiB
Python
"""학습 패턴 분류 (Phase 1).
|
|
|
|
attempts 시간 순 리스트 → pattern_state 7 분류:
|
|
unattempted / unsure / chronic_wrong / regressed / recovered / stable / unstable
|
|
|
|
윈도우 = 최근 3회 attempts + 과거 correct/wrong 존재 여부.
|
|
recent3 만으로는 regressed/recovered 판정 불가능 — 과거 이력 함께 필요.
|
|
|
|
우선순위 (위→아래, 첫 매칭 박힘):
|
|
1. unattempted (attempts == 0)
|
|
2. unsure (latest == 'unsure')
|
|
3. chronic_wrong (recent3 wrong ≥ 2)
|
|
4. regressed (latest wrong + 이전 correct 이력)
|
|
5. recovered (latest 2 연속 correct + 이전 wrong 이력)
|
|
6. stable (recent3 모두 correct, 3회 충족)
|
|
7. unstable (그 외)
|
|
|
|
UI 라벨 (가칭):
|
|
unattempted="미학습" / unsure="모르겠음" / chronic_wrong="반복 오답 경향"
|
|
regressed="퇴행" / recovered="회복" / stable="안정" / unstable="불안정"
|
|
|
|
chronic_wrong 은 [wrong,wrong,correct] 같이 latest correct 케이스도 잡힘.
|
|
"지금 틀림" 의미 X, "최근 3회 오답 비중 높음" 의미 — UI 라벨 주의.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
|
|
PATTERN_UNATTEMPTED = "unattempted"
|
|
PATTERN_UNSURE = "unsure"
|
|
PATTERN_CHRONIC_WRONG = "chronic_wrong"
|
|
PATTERN_REGRESSED = "regressed"
|
|
PATTERN_RECOVERED = "recovered"
|
|
PATTERN_STABLE = "stable"
|
|
PATTERN_UNSTABLE = "unstable"
|
|
|
|
WINDOW_SIZE = 3
|
|
|
|
|
|
@dataclass
|
|
class _AttemptOutcome:
|
|
"""compute_pattern_state 입력용 가벼운 dataclass. 시간 순 (oldest → newest)."""
|
|
outcome: str # correct / wrong / unsure / skipped
|
|
|
|
|
|
def compute_pattern_state(attempts: list[_AttemptOutcome]) -> tuple[str, int]:
|
|
"""attempts (oldest → newest) → (pattern_state, window_size).
|
|
|
|
window_size = min(WINDOW_SIZE, len(attempts)).
|
|
"""
|
|
if not attempts:
|
|
return PATTERN_UNATTEMPTED, 0
|
|
|
|
latest = attempts[-1].outcome
|
|
if latest == "unsure":
|
|
return PATTERN_UNSURE, min(WINDOW_SIZE, len(attempts))
|
|
|
|
recent_window = attempts[-WINDOW_SIZE:]
|
|
wrong_in_window = sum(1 for a in recent_window if a.outcome == "wrong")
|
|
|
|
# chronic_wrong: 최근 3회 중 wrong ≥ 2 (또는 attempts 가 2회뿐이고 둘 다 wrong)
|
|
if wrong_in_window >= 2:
|
|
return PATTERN_CHRONIC_WRONG, len(recent_window)
|
|
|
|
# regressed: latest wrong + 이전 correct 이력 1건 이상
|
|
if latest == "wrong" and any(a.outcome == "correct" for a in attempts[:-1]):
|
|
return PATTERN_REGRESSED, len(recent_window)
|
|
|
|
# recovered: latest 2 연속 correct + 그 이전 wrong 이력
|
|
if (
|
|
len(attempts) >= 3
|
|
and attempts[-1].outcome == "correct"
|
|
and attempts[-2].outcome == "correct"
|
|
and any(a.outcome == "wrong" for a in attempts[:-2])
|
|
):
|
|
return PATTERN_RECOVERED, len(recent_window)
|
|
|
|
# stable: 최근 3회 모두 correct (3회 충족)
|
|
if len(recent_window) >= WINDOW_SIZE and all(a.outcome == "correct" for a in recent_window):
|
|
return PATTERN_STABLE, WINDOW_SIZE
|
|
|
|
# 그 외: unstable (1~2회 correct, wrong 1회 + correct 이력 없음, 등)
|
|
return PATTERN_UNSTABLE, len(recent_window)
|
|
|
|
|
|
def attempts_from_rows(rows) -> list[_AttemptOutcome]:
|
|
"""SQL row (outcome 컬럼) → _AttemptOutcome 리스트. 호출자가 정렬은 미리.
|
|
|
|
rows 는 oldest → newest 정렬되어 들어와야 함 (answered_at asc).
|
|
"""
|
|
return [_AttemptOutcome(outcome=r.outcome) for r in rows]
|