Files
hyungi_document_server/app/api/study_question_progress.py
T
Hyungi Ahn 3db5d331de 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>
2026-05-01 11:59:20 +09:00

729 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""학습 진행 상태 (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,
),
)