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
@@ -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()}