3db5d331de
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>
729 lines
27 KiB
Python
729 lines
27 KiB
Python
"""학습 진행 상태 (progress) API — review-complete + review-queue + stats.
|
||
|
||
review-complete: 사용자가 오답/모르겠음 문제를 검토했음을 표시. due_at 최초 부여.
|
||
review-queue: 5 탭 (due_today / pending_review / chronic / regressed / mastered) 으로 progress 조회.
|
||
stats (Phase 2-D): 통계 대시보드 — 진척도 / 패턴 분포 / 복습 큐 / 세션 추이 / 일별 풀이량 / 과목별.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import date, datetime, timedelta, timezone
|
||
from typing import Annotated
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
from pydantic import BaseModel
|
||
from sqlalchemy import and_, case, cast, func, or_, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy.types import Date as SQLDate
|
||
|
||
from core.auth import get_current_user
|
||
from core.database import get_session
|
||
from models.study_question import StudyQuestion, StudyQuestionAttempt
|
||
from models.study_question_progress import StudyQuestionProgress
|
||
from models.study_quiz_session import StudyQuizSession
|
||
from models.study_topic import StudyTopic
|
||
from models.user import User
|
||
|
||
router = APIRouter(prefix="/study-topics", tags=["study-progress"])
|
||
|
||
# 1차 due_at 부여 시 디폴트 1일 뒤
|
||
DEFAULT_FIRST_DUE_DAYS = 1
|
||
|
||
|
||
def _verify_topic_owner(topic: StudyTopic | None, user: User) -> None:
|
||
if topic is None or topic.deleted_at is not None or topic.user_id != user.id:
|
||
raise HTTPException(status_code=404, detail="주제를 찾을 수 없습니다")
|
||
|
||
|
||
@router.post("/{topic_id}/questions/{question_id}/review-complete", status_code=204)
|
||
async def review_complete(
|
||
topic_id: int,
|
||
question_id: int,
|
||
user: Annotated[User, Depends(get_current_user)],
|
||
session: Annotated[AsyncSession, Depends(get_session)],
|
||
):
|
||
"""확인완료 처리 — last_reviewed_at + (wrong/unsure 인 경우) due_at 최초 부여.
|
||
|
||
이미 due_at 박힌 문제면 due_at 그대로 유지 (큐 위치 보존).
|
||
정답 맞춘 문제면 due_at 박지 않음 (큐 폭발 방지).
|
||
"""
|
||
topic = await session.get(StudyTopic, topic_id)
|
||
_verify_topic_owner(topic, user)
|
||
|
||
q = await session.get(StudyQuestion, question_id)
|
||
if q is None or q.deleted_at is not None or q.user_id != user.id or q.study_topic_id != topic_id:
|
||
raise HTTPException(status_code=404, detail="문제를 찾을 수 없습니다")
|
||
|
||
progress = (
|
||
await session.execute(
|
||
select(StudyQuestionProgress).where(
|
||
StudyQuestionProgress.user_id == user.id,
|
||
StudyQuestionProgress.study_topic_id == topic_id,
|
||
StudyQuestionProgress.study_question_id == question_id,
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if progress is None:
|
||
# attempt 없는데 review-complete 시도. 진척 상태가 없어 의미 없음.
|
||
raise HTTPException(status_code=409, detail="아직 시도한 적이 없는 문제입니다")
|
||
|
||
now = datetime.now(timezone.utc)
|
||
progress.last_reviewed_at = now
|
||
|
||
# due_at 최초 부여는 wrong/unsure 일 때만. 이미 박혀있으면 유지.
|
||
if progress.last_outcome in ("wrong", "unsure") and progress.due_at is None:
|
||
progress.review_stage = 0
|
||
progress.due_at = now + timedelta(days=DEFAULT_FIRST_DUE_DAYS)
|
||
|
||
await session.commit()
|
||
|
||
|
||
# ─── review-queue ───
|
||
|
||
|
||
class ReviewQueueItem(BaseModel):
|
||
question_id: int
|
||
question_text: str
|
||
subject: str | None
|
||
scope: str | None
|
||
exam_round: str | None
|
||
exam_question_number: int | None
|
||
last_outcome: str | None
|
||
last_attempted_at: datetime | None
|
||
last_reviewed_at: datetime | None
|
||
due_at: datetime | None
|
||
review_stage: int | None
|
||
pattern_state: str | None
|
||
|
||
|
||
class ReviewQueueResponse(BaseModel):
|
||
tab: str
|
||
total: int
|
||
items: list[ReviewQueueItem]
|
||
page: int
|
||
page_size: int
|
||
# Phase 2-F: due_today 탭에서만 채움. due_at < today 0시 (UTC) + stage < 4.
|
||
# UI 가 "정체 N건" 경고 + [정리] 버튼 노출 판단에 사용.
|
||
overdue_count: int = 0
|
||
|
||
|
||
def _truncate(text: str, n: int = 80) -> str:
|
||
if not text:
|
||
return ""
|
||
s = text.strip()
|
||
return s if len(s) <= n else s[:n].rstrip() + "…"
|
||
|
||
|
||
@router.get("/{topic_id}/review-queue", response_model=ReviewQueueResponse)
|
||
async def review_queue(
|
||
topic_id: int,
|
||
user: Annotated[User, Depends(get_current_user)],
|
||
session: Annotated[AsyncSession, Depends(get_session)],
|
||
tab: str = Query(..., pattern="^(due_today|pending_review|chronic|regressed|mastered)$"),
|
||
page: int = Query(1, ge=1),
|
||
page_size: int = Query(50, ge=1, le=200),
|
||
):
|
||
"""5 탭 진행 상태 조회.
|
||
|
||
- due_today: progress.due_at <= now() AND review_stage < 4
|
||
- pending_review: last_outcome IN (wrong, unsure)
|
||
AND (last_reviewed_at IS NULL OR last_reviewed_at < last_attempted_at)
|
||
- chronic: pattern_state = 'chronic_wrong'
|
||
- regressed: pattern_state = 'regressed'
|
||
- mastered: review_stage >= 4
|
||
"""
|
||
topic = await session.get(StudyTopic, topic_id)
|
||
_verify_topic_owner(topic, user)
|
||
|
||
base = (
|
||
select(StudyQuestionProgress, StudyQuestion)
|
||
.join(StudyQuestion, StudyQuestion.id == StudyQuestionProgress.study_question_id)
|
||
.where(
|
||
StudyQuestionProgress.user_id == user.id,
|
||
StudyQuestionProgress.study_topic_id == topic_id,
|
||
StudyQuestion.deleted_at.is_(None),
|
||
)
|
||
)
|
||
|
||
now = datetime.now(timezone.utc)
|
||
if tab == "due_today":
|
||
base = base.where(
|
||
StudyQuestionProgress.due_at.is_not(None),
|
||
StudyQuestionProgress.due_at <= now,
|
||
or_(
|
||
StudyQuestionProgress.review_stage.is_(None),
|
||
StudyQuestionProgress.review_stage < 4,
|
||
),
|
||
).order_by(StudyQuestionProgress.due_at.asc())
|
||
elif tab == "pending_review":
|
||
base = base.where(
|
||
StudyQuestionProgress.last_outcome.in_(("wrong", "unsure")),
|
||
or_(
|
||
StudyQuestionProgress.last_reviewed_at.is_(None),
|
||
and_(
|
||
StudyQuestionProgress.last_reviewed_at.is_not(None),
|
||
StudyQuestionProgress.last_attempted_at.is_not(None),
|
||
StudyQuestionProgress.last_reviewed_at
|
||
< StudyQuestionProgress.last_attempted_at,
|
||
),
|
||
),
|
||
).order_by(StudyQuestionProgress.last_attempted_at.desc().nulls_last())
|
||
elif tab == "chronic":
|
||
base = base.where(
|
||
StudyQuestionProgress.pattern_state == "chronic_wrong",
|
||
).order_by(StudyQuestionProgress.last_attempted_at.desc().nulls_last())
|
||
elif tab == "regressed":
|
||
base = base.where(
|
||
StudyQuestionProgress.pattern_state == "regressed",
|
||
).order_by(StudyQuestionProgress.last_attempted_at.desc().nulls_last())
|
||
elif tab == "mastered":
|
||
base = base.where(
|
||
StudyQuestionProgress.review_stage.is_not(None),
|
||
StudyQuestionProgress.review_stage >= 4,
|
||
).order_by(StudyQuestionProgress.last_attempted_at.desc().nulls_last())
|
||
|
||
# total
|
||
total_row = await session.execute(
|
||
select(func.count()).select_from(base.subquery())
|
||
)
|
||
total = int(total_row.scalar() or 0)
|
||
|
||
# paged
|
||
rows = (
|
||
await session.execute(
|
||
base.offset((page - 1) * page_size).limit(page_size)
|
||
)
|
||
).all()
|
||
|
||
items = [
|
||
ReviewQueueItem(
|
||
question_id=q.id,
|
||
question_text=_truncate(q.question_text, 80),
|
||
subject=q.subject,
|
||
scope=q.scope,
|
||
exam_round=q.exam_round,
|
||
exam_question_number=q.exam_question_number,
|
||
last_outcome=p.last_outcome,
|
||
last_attempted_at=p.last_attempted_at,
|
||
last_reviewed_at=p.last_reviewed_at,
|
||
due_at=p.due_at,
|
||
review_stage=p.review_stage,
|
||
pattern_state=p.pattern_state,
|
||
)
|
||
for (p, q) in rows
|
||
]
|
||
|
||
# Phase 2-F: due_today 탭일 때 overdue 카운트 (오늘 0시 UTC 이전 due) — UI 경고 노출용
|
||
overdue_count = 0
|
||
if tab == "due_today":
|
||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||
overdue_row = await session.execute(
|
||
select(func.count())
|
||
.select_from(StudyQuestionProgress)
|
||
.where(
|
||
StudyQuestionProgress.user_id == user.id,
|
||
StudyQuestionProgress.study_topic_id == topic_id,
|
||
StudyQuestionProgress.due_at.is_not(None),
|
||
StudyQuestionProgress.due_at < today_start,
|
||
or_(
|
||
StudyQuestionProgress.review_stage.is_(None),
|
||
StudyQuestionProgress.review_stage < 4,
|
||
),
|
||
)
|
||
)
|
||
overdue_count = int(overdue_row.scalar() or 0)
|
||
|
||
return ReviewQueueResponse(
|
||
tab=tab, total=total, items=items, page=page, page_size=page_size,
|
||
overdue_count=overdue_count,
|
||
)
|
||
|
||
|
||
# ─── redistribute (Phase 2-F due_at 정체 정리) ───
|
||
|
||
|
||
class RedistributeRequest(BaseModel):
|
||
spread_days: int = 7 # 1~14 일 사이. default 7.
|
||
|
||
|
||
class RedistributeResponse(BaseModel):
|
||
redistributed_count: int
|
||
spread_days: int
|
||
|
||
|
||
@router.post(
|
||
"/{topic_id}/review-queue/redistribute", response_model=RedistributeResponse
|
||
)
|
||
async def redistribute_overdue(
|
||
topic_id: int,
|
||
body: RedistributeRequest,
|
||
user: Annotated[User, Depends(get_current_user)],
|
||
session: Annotated[AsyncSession, Depends(get_session)],
|
||
):
|
||
"""overdue (due_at < today 0시 UTC + stage < 4) 를 내일~spread_days 일에 round-robin 분산.
|
||
|
||
동작:
|
||
- 오늘 0시 이전에 due 된 항목 모두 fetch (오래된 순)
|
||
- i % spread_days + 1 일 후 자정 + i*7분 (분산용 분단위) 로 due_at 갱신
|
||
- review_stage 는 건드리지 않음 (정체 처리는 시간 재배치만)
|
||
"""
|
||
if not (1 <= body.spread_days <= 14):
|
||
raise HTTPException(status_code=400, detail="spread_days 는 1~14 사이여야 합니다")
|
||
|
||
topic = await session.get(StudyTopic, topic_id)
|
||
_verify_topic_owner(topic, user)
|
||
|
||
now = datetime.now(timezone.utc)
|
||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||
|
||
overdue = (
|
||
await session.execute(
|
||
select(StudyQuestionProgress)
|
||
.where(
|
||
StudyQuestionProgress.user_id == user.id,
|
||
StudyQuestionProgress.study_topic_id == topic_id,
|
||
StudyQuestionProgress.due_at.is_not(None),
|
||
StudyQuestionProgress.due_at < today_start,
|
||
or_(
|
||
StudyQuestionProgress.review_stage.is_(None),
|
||
StudyQuestionProgress.review_stage < 4,
|
||
),
|
||
)
|
||
.order_by(StudyQuestionProgress.due_at.asc())
|
||
)
|
||
).scalars().all()
|
||
|
||
if not overdue:
|
||
return RedistributeResponse(redistributed_count=0, spread_days=body.spread_days)
|
||
|
||
base_day = today_start # 오늘 0시 기준 — +1일부터 분산
|
||
for i, p in enumerate(overdue):
|
||
days_offset = (i % body.spread_days) + 1
|
||
# 같은 날 안에서도 분산하려고 i*7분 추가 (200건 까지 24시간 안에 겹침 없이 spread)
|
||
minute_offset = (i * 7) % (24 * 60)
|
||
p.due_at = base_day + timedelta(days=days_offset, minutes=minute_offset)
|
||
|
||
await session.commit()
|
||
return RedistributeResponse(
|
||
redistributed_count=len(overdue), spread_days=body.spread_days
|
||
)
|
||
|
||
|
||
# ─── stats (Phase 2-D 통계 대시보드) ───
|
||
|
||
|
||
class StatsQuestions(BaseModel):
|
||
total: int
|
||
attempted: int
|
||
unattempted: int
|
||
|
||
|
||
class StatsDue(BaseModel):
|
||
today: int
|
||
this_week: int
|
||
later: int
|
||
mastered: int
|
||
|
||
|
||
class StatsSessionTrendItem(BaseModel):
|
||
id: int
|
||
finished_at: datetime
|
||
total: int
|
||
correct_count: int
|
||
wrong_count: int
|
||
unsure_count: int
|
||
accuracy: int # 0~100
|
||
newly_correct_count: int
|
||
relapsed_count: int
|
||
recovered_count: int
|
||
|
||
|
||
class StatsDailyAttempt(BaseModel):
|
||
date: date
|
||
count: int
|
||
|
||
|
||
class StatsSubjectBreakdown(BaseModel):
|
||
subject: str
|
||
total: int
|
||
attempted: int
|
||
last_correct: int
|
||
accuracy: int # 0~100
|
||
pending_review: int
|
||
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
|
||
review_stage_distribution: dict # "0"/"1"/"2"/"3"/"mastered" → count
|
||
due: StatsDue
|
||
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)
|
||
async def topic_stats(
|
||
topic_id: int,
|
||
user: Annotated[User, Depends(get_current_user)],
|
||
session: Annotated[AsyncSession, Depends(get_session)],
|
||
session_trend_limit: int = Query(20, ge=1, le=100),
|
||
):
|
||
"""통계 대시보드 — progress + quiz_sessions + attempts 한 번에 집계.
|
||
|
||
가벼운 쿼리 6~7 묶음. 1인 운영 + 토픽당 progress 수천 행 가정 — 추가 인덱스 없이 OK.
|
||
"""
|
||
topic = await session.get(StudyTopic, topic_id)
|
||
_verify_topic_owner(topic, user)
|
||
|
||
now = datetime.now(timezone.utc)
|
||
|
||
# 1. 문제 진척도 — 토픽의 question 총수 + progress 행 수 (attempted)
|
||
total_q_row = await session.execute(
|
||
select(func.count())
|
||
.select_from(StudyQuestion)
|
||
.where(
|
||
StudyQuestion.user_id == user.id,
|
||
StudyQuestion.study_topic_id == topic_id,
|
||
StudyQuestion.deleted_at.is_(None),
|
||
)
|
||
)
|
||
total_q = int(total_q_row.scalar() or 0)
|
||
|
||
attempted_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.is_not(None),
|
||
)
|
||
)
|
||
attempted = int(attempted_row.scalar() or 0)
|
||
unattempted = max(0, total_q - attempted)
|
||
|
||
# 2. pattern_state 분포 (NULL 은 "unattempted" 로)
|
||
pattern_rows = (
|
||
await session.execute(
|
||
select(
|
||
func.coalesce(StudyQuestionProgress.pattern_state, "unattempted").label("state"),
|
||
func.count().label("cnt"),
|
||
)
|
||
.where(
|
||
StudyQuestionProgress.user_id == user.id,
|
||
StudyQuestionProgress.study_topic_id == topic_id,
|
||
)
|
||
.group_by("state")
|
||
)
|
||
).all()
|
||
pattern_distribution = {r.state: int(r.cnt) for r in pattern_rows}
|
||
# 모든 키 default 0 채우기 (UI 가 빈 키 처리 안 해도 되게)
|
||
for k in ("stable", "unstable", "unsure", "regressed", "recovered", "chronic_wrong", "unattempted"):
|
||
pattern_distribution.setdefault(k, 0)
|
||
# 한 번도 시도 안 한 (progress 행 자체 없음) 분량을 unattempted 에 합산
|
||
pattern_distribution["unattempted"] += unattempted
|
||
|
||
# 3. review_stage 분포 — 0/1/2/3/mastered (>=4)
|
||
stage_rows = (
|
||
await session.execute(
|
||
select(
|
||
StudyQuestionProgress.review_stage.label("stage"),
|
||
func.count().label("cnt"),
|
||
)
|
||
.where(
|
||
StudyQuestionProgress.user_id == user.id,
|
||
StudyQuestionProgress.study_topic_id == topic_id,
|
||
StudyQuestionProgress.review_stage.is_not(None),
|
||
)
|
||
.group_by(StudyQuestionProgress.review_stage)
|
||
)
|
||
).all()
|
||
review_stage_distribution = {"0": 0, "1": 0, "2": 0, "3": 0, "mastered": 0}
|
||
for r in stage_rows:
|
||
st = int(r.stage)
|
||
if st >= 4:
|
||
review_stage_distribution["mastered"] += int(r.cnt)
|
||
elif 0 <= st <= 3:
|
||
review_stage_distribution[str(st)] += int(r.cnt)
|
||
|
||
# 4. due 분류 — today / this_week / later / mastered
|
||
end_today = now.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||
end_week = end_today + timedelta(days=7)
|
||
due_rows = (
|
||
await session.execute(
|
||
select(
|
||
func.count().filter(
|
||
and_(
|
||
StudyQuestionProgress.due_at.is_not(None),
|
||
StudyQuestionProgress.due_at <= end_today,
|
||
or_(
|
||
StudyQuestionProgress.review_stage.is_(None),
|
||
StudyQuestionProgress.review_stage < 4,
|
||
),
|
||
)
|
||
).label("today"),
|
||
func.count().filter(
|
||
and_(
|
||
StudyQuestionProgress.due_at.is_not(None),
|
||
StudyQuestionProgress.due_at > end_today,
|
||
StudyQuestionProgress.due_at <= end_week,
|
||
or_(
|
||
StudyQuestionProgress.review_stage.is_(None),
|
||
StudyQuestionProgress.review_stage < 4,
|
||
),
|
||
)
|
||
).label("this_week"),
|
||
func.count().filter(
|
||
and_(
|
||
StudyQuestionProgress.due_at.is_not(None),
|
||
StudyQuestionProgress.due_at > end_week,
|
||
or_(
|
||
StudyQuestionProgress.review_stage.is_(None),
|
||
StudyQuestionProgress.review_stage < 4,
|
||
),
|
||
)
|
||
).label("later"),
|
||
func.count().filter(
|
||
and_(
|
||
StudyQuestionProgress.review_stage.is_not(None),
|
||
StudyQuestionProgress.review_stage >= 4,
|
||
)
|
||
).label("mastered"),
|
||
)
|
||
.where(
|
||
StudyQuestionProgress.user_id == user.id,
|
||
StudyQuestionProgress.study_topic_id == topic_id,
|
||
)
|
||
)
|
||
).first()
|
||
due = StatsDue(
|
||
today=int(due_rows.today or 0),
|
||
this_week=int(due_rows.this_week or 0),
|
||
later=int(due_rows.later or 0),
|
||
mastered=int(due_rows.mastered or 0),
|
||
)
|
||
|
||
# 5. 최근 done 세션 추이 (Phase 2-B 4 컬럼 활용)
|
||
sess_rows = (
|
||
await session.execute(
|
||
select(StudyQuizSession)
|
||
.where(
|
||
StudyQuizSession.user_id == user.id,
|
||
StudyQuizSession.study_topic_id == topic_id,
|
||
StudyQuizSession.status == "done",
|
||
StudyQuizSession.finished_at.is_not(None),
|
||
)
|
||
.order_by(StudyQuizSession.finished_at.desc())
|
||
.limit(session_trend_limit)
|
||
)
|
||
).scalars().all()
|
||
session_trend: list[StatsSessionTrendItem] = []
|
||
for s in sess_rows:
|
||
total_n = len(s.question_ids or [])
|
||
acc = round((s.correct_count / total_n) * 100) if total_n > 0 else 0
|
||
session_trend.append(StatsSessionTrendItem(
|
||
id=s.id,
|
||
finished_at=s.finished_at,
|
||
total=total_n,
|
||
correct_count=s.correct_count,
|
||
wrong_count=s.wrong_count,
|
||
unsure_count=s.unsure_count,
|
||
accuracy=acc,
|
||
newly_correct_count=s.newly_correct_count,
|
||
relapsed_count=s.relapsed_count,
|
||
recovered_count=s.recovered_count,
|
||
))
|
||
|
||
# 6. 일별 풀이량 30일 (date 기준 — UTC, 시간대 차이는 Phase 5 후보)
|
||
start_30d = (now - timedelta(days=29)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||
daily_rows = (
|
||
await session.execute(
|
||
select(
|
||
cast(StudyQuestionAttempt.answered_at, SQLDate).label("d"),
|
||
func.count().label("cnt"),
|
||
)
|
||
.where(
|
||
StudyQuestionAttempt.user_id == user.id,
|
||
StudyQuestionAttempt.study_topic_id == topic_id,
|
||
StudyQuestionAttempt.answered_at >= start_30d,
|
||
)
|
||
.group_by("d")
|
||
.order_by("d")
|
||
)
|
||
).all()
|
||
daily_attempts_30d = [StatsDailyAttempt(date=r.d, count=int(r.cnt)) for r in daily_rows]
|
||
|
||
# 7. 과목별 약점
|
||
subj_rows = (
|
||
await session.execute(
|
||
select(
|
||
func.coalesce(StudyQuestion.subject, "(미분류)").label("subject"),
|
||
func.count(StudyQuestion.id.distinct()).label("total"),
|
||
func.count(StudyQuestionProgress.id.distinct()).filter(
|
||
StudyQuestionProgress.last_outcome.is_not(None)
|
||
).label("attempted"),
|
||
func.count(StudyQuestionProgress.id.distinct()).filter(
|
||
StudyQuestionProgress.last_outcome == "correct"
|
||
).label("last_correct"),
|
||
func.count(StudyQuestionProgress.id.distinct()).filter(
|
||
and_(
|
||
StudyQuestionProgress.last_outcome.in_(("wrong", "unsure")),
|
||
or_(
|
||
StudyQuestionProgress.last_reviewed_at.is_(None),
|
||
and_(
|
||
StudyQuestionProgress.last_reviewed_at.is_not(None),
|
||
StudyQuestionProgress.last_attempted_at.is_not(None),
|
||
StudyQuestionProgress.last_reviewed_at
|
||
< StudyQuestionProgress.last_attempted_at,
|
||
),
|
||
),
|
||
)
|
||
).label("pending_review"),
|
||
func.count(StudyQuestionProgress.id.distinct()).filter(
|
||
StudyQuestionProgress.pattern_state == "chronic_wrong"
|
||
).label("chronic"),
|
||
)
|
||
.select_from(StudyQuestion)
|
||
.outerjoin(
|
||
StudyQuestionProgress,
|
||
and_(
|
||
StudyQuestionProgress.user_id == StudyQuestion.user_id,
|
||
StudyQuestionProgress.study_topic_id == StudyQuestion.study_topic_id,
|
||
StudyQuestionProgress.study_question_id == StudyQuestion.id,
|
||
),
|
||
)
|
||
.where(
|
||
StudyQuestion.user_id == user.id,
|
||
StudyQuestion.study_topic_id == topic_id,
|
||
StudyQuestion.deleted_at.is_(None),
|
||
)
|
||
.group_by("subject")
|
||
.order_by(func.count(StudyQuestion.id.distinct()).desc())
|
||
)
|
||
).all()
|
||
subject_breakdown = [
|
||
StatsSubjectBreakdown(
|
||
subject=r.subject,
|
||
total=int(r.total),
|
||
attempted=int(r.attempted),
|
||
last_correct=int(r.last_correct),
|
||
accuracy=round((int(r.last_correct) / int(r.attempted)) * 100) if int(r.attempted) > 0 else 0,
|
||
pending_review=int(r.pending_review),
|
||
chronic=int(r.chronic),
|
||
)
|
||
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
|
||
),
|
||
pattern_distribution=pattern_distribution,
|
||
review_stage_distribution=review_stage_distribution,
|
||
due=due,
|
||
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,
|
||
),
|
||
)
|