"""eid 학습 약점 판정/포맷 — 순수 함수 (DB·LLM 무관, 단위테스트 대상). W3-2. worker(workers/study_weakness.py)가 decide_tier/topic_trend/overall_trend 로 판정, surface(api/study_topics.py study_diagnosis)가 format_*_block 으로 스냅샷 JSONB → 프롬프트 블록. 임계는 worker 가 주입(여기선 받기만) — 튜닝값은 한 곳(worker)에서 관리. """ from __future__ import annotations # 표면 약점 토픽 상한 (포맷) TOP_WEAKNESS = 5 def decide_tier( *, chronic: int, relapsed: int, overdue: int, unsure: int, attempted: int, min_attempts: int, chronic_focus: int, relapse_focus: int, review_overdue: int, ) -> str | None: """bounded 권고 tier(watch/review/focus). None = 약점 아님(스냅샷 미포함). conservative: 표본 미달(attempted < min_attempts)이면 focus/review 단정 안 하고 watch 상한. """ shallow = attempted < min_attempts if not shallow and (chronic >= chronic_focus or relapsed >= relapse_focus): return "focus" if not shallow and (chronic >= 1 or relapsed >= 1 or overdue >= review_overdue): return "review" if chronic >= 1 or relapsed >= 1 or unsure >= 2 or overdue >= 1: return "watch" return None def topic_trend(sessions: list[dict]) -> str: """recent 세션 finalize 카운트 → 개선|정체|악화. conservative: 명확하지 않으면 정체.""" if not sessions: return "정체" gained = sum(s.get("newly_correct", 0) for s in sessions) lost = sum(s.get("relapsed", 0) + s.get("chronic_remaining", 0) for s in sessions) if gained > lost * 1.5: return "개선" if lost > gained * 1.5: return "악화" return "정체" def overall_trend(topic_trends: list[str]) -> str: """토픽별 추세 다수결 → 전체 추세. conservative: 동률/공백이면 정체.""" if not topic_trends: return "정체" worse = topic_trends.count("악화") better = topic_trends.count("개선") if worse > better: return "악화" if better > worse: return "개선" return "정체" def format_weakness_block(weaknesses: list[dict], *, shallow_overall: bool) -> str: """약점 스냅샷 list → study overlay {weakness_snapshot_block} 텍스트. 워커 값만(추측 없음).""" if not weaknesses: return "(약점으로 판정된 토픽 없음. 스냅샷에 없는 토픽을 약점으로 추정하지 마라.)" lines = [] for w in weaknesses[:TOP_WEAKNESS]: lines.append( f"- {w['topic']}: chronic 반복오답 {w['chronic']}건 / relapsed(회복후재오답) {w['relapsed']}건 / " f"모르겠음 {w['unsure']}건 / 미답(커버리지공백) {w['coverage_gap']}건 / 묵힌 due {w['overdue']}건 / " f"추세 {w['trend']} / 권고 tier={w['tier']}" ) if shallow_overall: lines.append("- (전체 표본 적음 — 약점 단정 대신 '지켜볼 토픽'으로만 해석)") return "\n".join(lines) def format_habit_block(habits: dict) -> str: """태도 신호 dict → study overlay {habit_signal_block} 텍스트.""" parts = [] if habits.get("avoidance_topics"): parts.append(f"- 재시도 회피 신호(모르겠음 누적) 토픽: {', '.join(habits['avoidance_topics'])}") parts.append(f"- 세션 중단율: {round(habits.get('session_abandon_rate', 0.0) * 100)}%") parts.append(f"- 오래 묵힌 due(복습 밀림): {habits.get('stale_due_count', 0)}건") if habits.get("skew_topic"): parts.append(f"- 편중: '{habits['skew_topic']}' 에 풀이 집중") return "\n".join(parts)