diff --git a/app/api/study_question_progress.py b/app/api/study_question_progress.py index 0849d3c..7b0a4ac 100644 --- a/app/api/study_question_progress.py +++ b/app/api/study_question_progress.py @@ -353,6 +353,17 @@ class StatsSubjectBreakdown(BaseModel): chronic: int +class StatsAiExplanation(BaseModel): + """Phase 4-A 운영 관찰 — AI 풀이 캐시 진척 + 최근 7일 worker 결과.""" + # study_questions.ai_explanation_status 분포 (이 토픽 전체) + status_distribution: dict # 'none' / 'ready' / 'failed' / 'skipped' / 'stale' / 'pending' + # wrong/unsure 중 ready 박힌 비율 (캐시 hit 가능성 추정) + target_total: int # progress.last_outcome IN (wrong, unsure) 의 qid 수 + target_ready: int # 그 중 ai_explanation_status='ready' 인 수 + # 최근 7일 study_question_jobs 의 (status, error_code) 분포 + recent_jobs: dict # {'completed': N, 'failed:guard_fail': N, 'failed:parse_fail': N, 'skipped:evidence_missing': N, 'pending': N, ...} + + class StatsResponse(BaseModel): questions: StatsQuestions pattern_distribution: dict # state(or "unattempted") → count @@ -361,6 +372,7 @@ class StatsResponse(BaseModel): session_trend: list[StatsSessionTrendItem] # 최근 done 세션 newest→oldest daily_attempts_30d: list[StatsDailyAttempt] subject_breakdown: list[StatsSubjectBreakdown] + ai_explanation: StatsAiExplanation @router.get("/{topic_id}/stats", response_model=StatsResponse) @@ -615,6 +627,88 @@ async def topic_stats( for r in subj_rows ] + # 8. Phase 4-A: AI 풀이 캐시 진척 + 최근 7일 worker 결과 + # 8a. study_questions.ai_explanation_status 분포 (토픽 전체) + ai_status_rows = ( + await session.execute( + select( + func.coalesce(StudyQuestion.ai_explanation_status, "none").label("st"), + func.count().label("cnt"), + ) + .where( + StudyQuestion.user_id == user.id, + StudyQuestion.study_topic_id == topic_id, + StudyQuestion.deleted_at.is_(None), + ) + .group_by("st") + ) + ).all() + ai_status_distribution = {r.st: int(r.cnt) for r in ai_status_rows} + for k in ("none", "ready", "failed", "skipped", "stale", "pending"): + ai_status_distribution.setdefault(k, 0) + + # 8b. wrong/unsure 의 ready 비율 (캐시 hit 가능성) + target_total_row = await session.execute( + select(func.count()) + .select_from(StudyQuestionProgress) + .where( + StudyQuestionProgress.user_id == user.id, + StudyQuestionProgress.study_topic_id == topic_id, + StudyQuestionProgress.last_outcome.in_(("wrong", "unsure")), + ) + ) + target_total = int(target_total_row.scalar() or 0) + + target_ready_row = await session.execute( + select(func.count()) + .select_from(StudyQuestionProgress) + .join( + StudyQuestion, + and_( + StudyQuestion.id == StudyQuestionProgress.study_question_id, + StudyQuestion.deleted_at.is_(None), + ), + ) + .where( + StudyQuestionProgress.user_id == user.id, + StudyQuestionProgress.study_topic_id == topic_id, + StudyQuestionProgress.last_outcome.in_(("wrong", "unsure")), + StudyQuestion.ai_explanation_status == "ready", + ) + ) + target_ready = int(target_ready_row.scalar() or 0) + + # 8c. 최근 7일 study_question_jobs 분포 — terminal status × error_code + from models.study_question_job import StudyQuestionJob + + recent_cutoff = now - timedelta(days=7) + job_rows = ( + await session.execute( + select( + StudyQuestionJob.status.label("st"), + func.coalesce(StudyQuestionJob.error_code, "").label("err"), + func.count().label("cnt"), + ) + .join( + StudyQuestion, + and_( + StudyQuestion.id == StudyQuestionJob.study_question_id, + StudyQuestion.study_topic_id == topic_id, + StudyQuestion.user_id == user.id, + ), + ) + .where( + StudyQuestionJob.user_id == user.id, + StudyQuestionJob.created_at >= recent_cutoff, + ) + .group_by("st", "err") + ) + ).all() + recent_jobs: dict[str, int] = {} + for r in job_rows: + key = f"{r.st}:{r.err}" if r.err else r.st + recent_jobs[key] = int(r.cnt) + return StatsResponse( questions=StatsQuestions( total=total_q, attempted=attempted, unattempted=unattempted @@ -625,4 +719,10 @@ async def topic_stats( session_trend=session_trend, daily_attempts_30d=daily_attempts_30d, subject_breakdown=subject_breakdown, + ai_explanation=StatsAiExplanation( + status_distribution=ai_status_distribution, + target_total=target_total, + target_ready=target_ready, + recent_jobs=recent_jobs, + ), ) diff --git a/frontend/src/routes/study/topics/[id]/stats/+page.svelte b/frontend/src/routes/study/topics/[id]/stats/+page.svelte index fa0f13e..c8151ec 100644 --- a/frontend/src/routes/study/topics/[id]/stats/+page.svelte +++ b/frontend/src/routes/study/topics/[id]/stats/+page.svelte @@ -9,7 +9,7 @@ import { page } from '$app/stores'; import { api } from '$lib/api'; import { addToast } from '$lib/stores/toast'; - import { ArrowLeft, BarChart3, BookOpen, Clock, TrendingUp } from 'lucide-svelte'; + import { ArrowLeft, BarChart3, BookOpen, Clock, TrendingUp, Sparkles } from 'lucide-svelte'; import Button from '$lib/components/ui/Button.svelte'; import Card from '$lib/components/ui/Card.svelte'; import Skeleton from '$lib/components/ui/Skeleton.svelte'; @@ -103,6 +103,29 @@ let dailyChart = $derived(buildBars(stats?.daily_attempts_30d ?? [], 300, 60)); let sessionChart = $derived(buildSparkline(sessionPoints, 300, 60)); + function statusLabel(k) { + return ({ + none: '미생성', + ready: '캐시됨', + pending: '생성 중', + failed: '실패', + skipped: '자료 부족', + stale: '구버전', + }[k] ?? k); + } + function jobLabel(k) { + return ({ + 'completed': '완료', + 'pending': '대기', + 'processing': '처리 중', + 'failed:guard_fail': '환각 차단', + 'failed:parse_fail': '파싱 실패', + 'failed:llm_timeout': 'LLM timeout', + 'failed:unknown': '알 수 없음', + 'skipped:evidence_missing': '자료 없음 skip', + }[k] ?? k); + } + let totalQ = $derived(stats?.questions?.total ?? 0); let attempted = $derived(stats?.questions?.attempted ?? 0); let attemptedPct = $derived(totalQ > 0 ? Math.round((attempted / totalQ) * 100) : 0); @@ -298,6 +321,58 @@ {/snippet} + + {#if stats.ai_explanation} + {@const ae = stats.ai_explanation} + {@const targetPct = ae.target_total > 0 ? Math.round((ae.target_ready / ae.target_total) * 100) : 0} + + {#snippet children()} +
+
+ +

AI 풀이 캐시

+
+ + + {#if ae.target_total > 0} +
+ {ae.target_ready} + / {ae.target_total} 오답·모르겠음 풀이 준비됨 ({targetPct}%) +
+
+
+
+ {:else} +
오답·모르겠음 문제가 없습니다.
+ {/if} + + +
+ 토픽 전체 풀이 상태: + {#each Object.entries(ae.status_distribution).filter(([_,v]) => v > 0) as [k, v], i} + {i > 0 ? ' · ' : ''}{statusLabel(k)} {v} + {/each} +
+ + + {#if Object.keys(ae.recent_jobs).length > 0} +
+
최근 7일 worker:
+
    + {#each Object.entries(ae.recent_jobs) as [k, v]} +
  • + {jobLabel(k)} + {v} +
  • + {/each} +
+
+ {/if} +
+ {/snippet} +
+ {/if} + {#snippet children()}