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:
@@ -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()}
|
||||
|
||||
Reference in New Issue
Block a user