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,
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user