feat(study): Phase 4-A 운영 가시화 — 통계 대시보드 AI 풀이 카드

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) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-05-01 11:59:20 +09:00
parent 43097e6fd9
commit 3db5d331de
2 changed files with 176 additions and 1 deletions
+100
View File
@@ -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,
),
)
@@ -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}
</Card>
<!-- 6.5 Phase 4-A: AI 풀이 캐시 진척 + 최근 7일 worker 결과 -->
{#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}
<Card>
{#snippet children()}
<div class="p-5 flex flex-col gap-3">
<div class="flex items-center gap-2">
<Sparkles size={14} class="text-accent" />
<h2 class="text-sm font-semibold text-text">AI 풀이 캐시</h2>
</div>
<!-- 핵심 진척: wrong/unsure 중 ready 비율 -->
{#if ae.target_total > 0}
<div class="flex items-baseline gap-3">
<span class="text-2xl font-semibold text-text">{ae.target_ready}</span>
<span class="text-sm text-dim">/ {ae.target_total} 오답·모르겠음 풀이 준비됨 ({targetPct}%)</span>
</div>
<div class="h-2 bg-bg/30 rounded-full overflow-hidden">
<div class="h-full bg-success" style="width: {targetPct}%"></div>
</div>
{:else}
<div class="text-xs text-dim">오답·모르겠음 문제가 없습니다.</div>
{/if}
<!-- 토픽 전체 status 분포 -->
<div class="text-[11px] text-dim">
토픽 전체 풀이 상태:
{#each Object.entries(ae.status_distribution).filter(([_,v]) => v > 0) as [k, v], i}
<span>{i > 0 ? ' · ' : ''}{statusLabel(k)} {v}</span>
{/each}
</div>
<!-- 최근 7일 worker 결과 분포 (운영 관찰) -->
{#if Object.keys(ae.recent_jobs).length > 0}
<div class="border-t border-default pt-2 text-[11px] text-dim">
<div class="mb-1">최근 7일 worker:</div>
<ul class="grid grid-cols-2 sm:grid-cols-3 gap-x-3 gap-y-0.5">
{#each Object.entries(ae.recent_jobs) as [k, v]}
<li class="flex justify-between">
<span>{jobLabel(k)}</span>
<span class="text-text">{v}</span>
</li>
{/each}
</ul>
</div>
{/if}
</div>
{/snippet}
</Card>
{/if}
<!-- 6. 과목별 약점 -->
<Card>
{#snippet children()}