From 3db5d331de56a8748cdea5fa2d4c777f9086e027 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Fri, 1 May 2026 11:59:20 +0900 Subject: [PATCH] =?UTF-8?q?feat(study):=20Phase=204-A=20=EC=9A=B4=EC=98=81?= =?UTF-8?q?=20=EA=B0=80=EC=8B=9C=ED=99=94=20=E2=80=94=20=ED=86=B5=EA=B3=84?= =?UTF-8?q?=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20AI=20=ED=92=80?= =?UTF-8?q?=EC=9D=B4=20=EC=B9=B4=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4-A 가 wrong/unsure 풀이를 background batch 로 캐시하는데, 사용자/운영자 입장에서 (1) 지금까지 얼마나 캐시 채워졌는지, (2) 환각 차단/파싱 실패/자료 없음 같은 worker 결과 분포를 볼 수 없었음. 통계 대시보드에 카드 추가. Backend (study_question_progress.py /stats): - StatsAiExplanation 신규 응답 섹션 · status_distribution — 토픽 전체 study_questions.ai_explanation_status 분포 (none/ready/failed/skipped/stale/pending 6 키 default 0) · target_total / target_ready — wrong/unsure progress 의 ready 비율 (캐시 hit 가능성 추정 핵심 지표) · recent_jobs — 최근 7일 study_question_jobs 의 (status, error_code) 분포 ('completed', 'failed:guard_fail', 'failed:parse_fail', 'skipped:evidence_missing' 같은 합성 키) Frontend (/study/topics/[id]/stats): - 신규 Card "AI 풀이 캐시" — Sparkles 아이콘 · 큰 숫자 + 진행률 바: ready / wrong+unsure · 토픽 전체 status 분포 inline (한국어 라벨) · 최근 7일 worker 결과 grid (환각 차단 / 파싱 실패 / 자료 없음 skip 등 분리) - statusLabel / jobLabel 헬퍼 — 운영자 친화 한국어 Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/study_question_progress.py | 100 ++++++++++++++++++ .../study/topics/[id]/stats/+page.svelte | 77 +++++++++++++- 2 files changed, 176 insertions(+), 1 deletion(-) 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()}