diff --git a/app/api/study_questions.py b/app/api/study_questions.py index fda1981..5531f99 100644 --- a/app/api/study_questions.py +++ b/app/api/study_questions.py @@ -27,6 +27,7 @@ from core.config import settings from core.database import get_session from models.study_question import StudyQuestion, StudyQuestionAttempt from models.study_question_image import StudyQuestionImage +from models.study_quiz_session import StudyQuizSession from models.study_topic import StudyTopic from models.user import User from services.search.llm_gate import get_mlx_gate @@ -190,18 +191,25 @@ class ReviewQuestionListResponse(BaseModel): class AttemptCreate(BaseModel): """PR-9: selected_choice (1~4) 또는 is_unsure 둘 중 하나 필수. is_unsure=true 면 selected_choice 무시 + outcome='unsure' 로 박힘. + PR-10: quiz_session_id 전달 시 세션의 cursor + count 도 함께 갱신. """ selected_choice: int | None = Field(default=None, ge=1, le=4) is_unsure: bool = False + quiz_session_id: int | None = None class AttemptResponse(BaseModel): + id: int # PR-10: 학습완료 토글 시 attempt 단위로 PATCH 하므로 id 노출 필요. is_correct: bool selected_choice: int | None correct_choice: int outcome: str # PR-9: correct | wrong | unsure explanation: str | None stats: QuestionAttemptStats + quiz_session_id: int | None = None + quiz_session_status: str | None = None # in_progress | done (마지막 문제면 done 으로 박힘) + quiz_session_cursor: int | None = None + reviewed_at: datetime | None = None # ─── PR-5: 비슷한 문제 검색 (embedding cosine) ─── @@ -837,6 +845,8 @@ async def submit_attempt( ): """답 제출. PR-9: is_unsure=true 면 outcome='unsure' + selected_choice=NULL. 그 외엔 selected_choice 와 correct_choice 비교 → outcome='correct'/'wrong'. + PR-10: quiz_session_id 전달 시 같은 트랜잭션에서 세션 cursor + count 도 갱신. + cursor 가 question_ids 끝까지 도달하면 status='done', finished_at 박힘. """ q = await session.get(StudyQuestion, question_id) q = _verify_question_ownership(q, user) @@ -855,6 +865,32 @@ async def submit_attempt( is_correct = selected == q.correct_choice outcome = "correct" if is_correct else "wrong" + # PR-10: 세션 연동. 기본은 None. + quiz_session: StudyQuizSession | None = None + if body.quiz_session_id is not None: + quiz_session = await session.get(StudyQuizSession, body.quiz_session_id) + if quiz_session is None or quiz_session.user_id != user.id: + raise HTTPException(status_code=404, detail="quiz_session 을 찾을 수 없습니다") + if quiz_session.study_topic_id != q.study_topic_id: + raise HTTPException( + status_code=400, + detail="quiz_session 의 토픽과 문제의 토픽이 다릅니다", + ) + if quiz_session.status != "in_progress": + raise HTTPException( + status_code=409, + detail=f"quiz_session 상태가 {quiz_session.status} — 이미 종료된 세션입니다", + ) + # 현재 cursor 위치의 문제와 일치해야만 진행 (out-of-order 제출 차단). + qids = quiz_session.question_ids or [] + if quiz_session.cursor >= len(qids): + raise HTTPException(status_code=409, detail="이미 모든 문제를 풀었습니다") + if qids[quiz_session.cursor] != q.id: + raise HTTPException( + status_code=409, + detail="현재 cursor 위치의 문제와 question_id 가 일치하지 않습니다 (이중 제출/순서 어긋남)", + ) + attempt = StudyQuestionAttempt( user_id=user.id, study_question_id=q.id, @@ -863,21 +899,73 @@ async def submit_attempt( correct_choice=q.correct_choice, is_correct=is_correct, outcome=outcome, + quiz_session_id=quiz_session.id if quiz_session else None, ) session.add(attempt) + + if quiz_session is not None: + quiz_session.cursor = quiz_session.cursor + 1 + if outcome == "correct": + quiz_session.correct_count += 1 + elif outcome == "wrong": + quiz_session.wrong_count += 1 + elif outcome == "unsure": + quiz_session.unsure_count += 1 + if quiz_session.cursor >= len(quiz_session.question_ids or []): + quiz_session.status = "done" + quiz_session.finished_at = datetime.now(timezone.utc) + quiz_session.updated_at = datetime.now(timezone.utc) + await session.commit() + await session.refresh(attempt) stats = await _attempt_stats(session, user.id, question_id) return AttemptResponse( + id=attempt.id, is_correct=is_correct, selected_choice=selected, correct_choice=q.correct_choice, outcome=outcome, explanation=q.explanation, stats=stats, + quiz_session_id=quiz_session.id if quiz_session else None, + quiz_session_status=quiz_session.status if quiz_session else None, + quiz_session_cursor=quiz_session.cursor if quiz_session else None, + reviewed_at=attempt.reviewed_at, ) +# ─── PR-10: 학습완료 토글 ─── + + +class AttemptReviewRequest(BaseModel): + reviewed: bool + + +class AttemptReviewResponse(BaseModel): + id: int + reviewed_at: datetime | None + + +@router.patch( + "/study-question-attempts/{attempt_id}/review-mark", + response_model=AttemptReviewResponse, +) +async def mark_attempt_reviewed( + attempt_id: int, + body: AttemptReviewRequest, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """결과 카드에서 [학습완료] 토글. reviewed=true 면 timestamp, false 면 NULL.""" + attempt = await session.get(StudyQuestionAttempt, attempt_id) + if attempt is None or attempt.user_id != user.id: + raise HTTPException(status_code=404, detail="attempt 를 찾을 수 없습니다") + attempt.reviewed_at = datetime.now(timezone.utc) if body.reviewed else None + await session.commit() + return AttemptReviewResponse(id=attempt.id, reviewed_at=attempt.reviewed_at) + + # ─── PR-5: 비슷한 문제 검색 (embedding cosine) ─── diff --git a/app/api/study_topics.py b/app/api/study_topics.py index 1bec529..7817692 100644 --- a/app/api/study_topics.py +++ b/app/api/study_topics.py @@ -17,13 +17,14 @@ import asyncio import logging +import random as _random from datetime import datetime, timezone from pathlib import Path as _Path from typing import Annotated, Any from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, Field -from sqlalchemy import and_, delete, func, select, text as sql_text, update +from sqlalchemy import and_, case, delete, func, select, text as sql_text, update from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -34,6 +35,9 @@ from core.library import LIBRARY_PREFIX, normalize_library_path from models.document import Document from models.study_session import StudySession from models.study_topic import StudyTopic, StudyTopicDocument +from models.study_question import StudyQuestion, StudyQuestionAttempt +from models.study_question_image import StudyQuestionImage +from models.study_quiz_session import StudyQuizSession from models.study_topic_subject_note import StudyTopicSubjectNote from models.user import User from services.search.llm_gate import get_mlx_gate @@ -1210,3 +1214,454 @@ async def generate_subject_note( evidence=[e.to_dict() for e in ctx.all], from_cache=False, can_regenerate=True, ) + + +# ─── PR-10: 문제풀이 세션 (quiz_session) lifecycle ─── +# +# 한 토픽당 in_progress 1개. 출제 시 session 행 생성 + question_ids 스냅샷. +# 풀이 중 attempt 마다 cursor + count 증가 (study_questions.submit_attempt 가 같은 트랜잭션에서). +# 마지막 문제 풀이 후 status='done' + finished_at 박힘. + + +class QuizSessionStartRequest(BaseModel): + target_per_subject: int = Field(default=20, ge=1, le=100) + subject: str | None = Field(default=None, max_length=120) + scope: str | None = Field(default=None, max_length=200) + wrong_only: bool = False + abandon_existing: bool = False # true 면 기존 in_progress 세션을 abandoned 로 마감 후 새로 + + +class QuizSessionSummary(BaseModel): + """진행 카드 + 결과 카드 공통 (가벼운 메타). 문제 본문은 상세 endpoint 에서.""" + id: int + status: str + cursor: int + total: int + correct_count: int + wrong_count: int + unsure_count: int + target_per_subject: int + subject_filter: str | None + wrong_only: bool + subject_distribution: dict + created_at: datetime + updated_at: datetime + finished_at: datetime | None + # 결과 카드 헤더 "미확인 N건" 용 — done 세션에서만 의미. + unreviewed_wrong_unsure_count: int = 0 + + +class QuizSessionListResponse(BaseModel): + active: QuizSessionSummary | None + recent_done: list[QuizSessionSummary] + + +async def _build_session_summary( + s: StudyQuizSession, + session: AsyncSession, +) -> QuizSessionSummary: + """status='done' 일 때 미확인 카운트(reviewed_at NULL + outcome IN wrong/unsure) 계산.""" + unreviewed = 0 + if s.status == "done": + row = ( + await session.execute( + select(func.count()) + .select_from(StudyQuestionAttempt) + .where( + StudyQuestionAttempt.quiz_session_id == s.id, + StudyQuestionAttempt.outcome.in_(("wrong", "unsure")), + StudyQuestionAttempt.reviewed_at.is_(None), + ) + ) + ).scalar() or 0 + unreviewed = int(row) + return QuizSessionSummary( + id=s.id, + status=s.status, + cursor=s.cursor, + total=len(s.question_ids or []), + correct_count=s.correct_count, + wrong_count=s.wrong_count, + unsure_count=s.unsure_count, + target_per_subject=s.target_per_subject, + subject_filter=s.subject_filter, + wrong_only=s.wrong_only, + subject_distribution=s.subject_distribution or {}, + created_at=s.created_at, + updated_at=s.updated_at, + finished_at=s.finished_at, + unreviewed_wrong_unsure_count=unreviewed, + ) + + +async def _select_questions_for_topic( + session: AsyncSession, + user: User, + topic_id: int, + *, + subject: str | None, + scope: str | None, + target_per_subject: int, + wrong_only: bool, +) -> tuple[list[int], dict[str, int]]: + """과목별 균등 추출 (study_questions.review_questions_for_topic 의 동일 로직). + 출제 후 셔플된 question_id list + subject_distribution 반환. + """ + base_filter = and_( + StudyQuestion.user_id == user.id, + StudyQuestion.study_topic_id == topic_id, + StudyQuestion.deleted_at.is_(None), + StudyQuestion.is_active.is_(True), + ) + + wrong_qids: set[int] | None = None + if wrong_only: + latest = ( + await session.execute( + select( + StudyQuestionAttempt.study_question_id, + StudyQuestionAttempt.is_correct, + ) + .where( + StudyQuestionAttempt.user_id == user.id, + StudyQuestionAttempt.study_topic_id == topic_id, + ) + .order_by( + StudyQuestionAttempt.study_question_id, + StudyQuestionAttempt.answered_at.desc(), + ) + .distinct(StudyQuestionAttempt.study_question_id) + ) + ).all() + wrong_qids = {r.study_question_id for r in latest if r.is_correct is False} + if not wrong_qids: + return [], {} + + if subject is not None: + subjects: list[str | None] = [subject] + else: + subj_query = ( + select(StudyQuestion.subject) + .where(base_filter) + .distinct() + ) + if scope is not None: + subj_query = subj_query.where(StudyQuestion.scope == scope) + if wrong_qids is not None: + subj_query = subj_query.where(StudyQuestion.id.in_(wrong_qids)) + subjects = [r[0] for r in (await session.execute(subj_query)).all()] + if not subjects: + return [], {} + + selected_ids: list[int] = [] + distribution: dict[str, int] = {} + for subj in subjects: + sub_q = select(StudyQuestion.id).where(base_filter) + if subj is None: + sub_q = sub_q.where(StudyQuestion.subject.is_(None)) + else: + sub_q = sub_q.where(StudyQuestion.subject == subj) + if scope is not None: + sub_q = sub_q.where(StudyQuestion.scope == scope) + if wrong_qids is not None: + sub_q = sub_q.where(StudyQuestion.id.in_(wrong_qids)) + sub_q = sub_q.order_by(func.random()).limit(target_per_subject) + rows = [r for (r,) in (await session.execute(sub_q)).all()] + if rows: + selected_ids.extend(rows) + distribution[subj or ""] = len(rows) + + _random.shuffle(selected_ids) + return selected_ids, distribution + + +@router.get("/{topic_id}/quiz-sessions", response_model=QuizSessionListResponse) +async def list_quiz_sessions( + topic_id: int, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], + limit: int = Query(20, ge=1, le=100), +): + """진행 중 + 최근 완료 N건. 통합뷰 진행/결과 카드용.""" + topic = await session.get(StudyTopic, topic_id) + _verify_topic_ownership(topic, user) + + active_row = ( + await session.execute( + select(StudyQuizSession).where( + StudyQuizSession.user_id == user.id, + StudyQuizSession.study_topic_id == topic_id, + StudyQuizSession.status == "in_progress", + ) + ) + ).scalar_one_or_none() + active_summary = await _build_session_summary(active_row, session) if active_row else None + + done_rows = ( + await session.execute( + select(StudyQuizSession) + .where( + StudyQuizSession.user_id == user.id, + StudyQuizSession.study_topic_id == topic_id, + StudyQuizSession.status == "done", + ) + .order_by(StudyQuizSession.finished_at.desc().nulls_last()) + .limit(limit) + ) + ).scalars().all() + done = [await _build_session_summary(s, session) for s in done_rows] + + return QuizSessionListResponse(active=active_summary, recent_done=done) + + +@router.post("/{topic_id}/quiz-sessions", response_model=QuizSessionSummary, status_code=201) +async def start_quiz_session( + topic_id: int, + body: QuizSessionStartRequest, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """새 세션 시작. 기존 in_progress 가 있으면: + - body.abandon_existing=false → 기존 세션 그대로 반환 (이어풀기). + - body.abandon_existing=true → 기존을 abandoned 로 마감 후 새로 출제. + """ + topic = await session.get(StudyTopic, topic_id) + _verify_topic_ownership(topic, user) + + existing = ( + await session.execute( + select(StudyQuizSession).where( + StudyQuizSession.user_id == user.id, + StudyQuizSession.study_topic_id == topic_id, + StudyQuizSession.status == "in_progress", + ) + ) + ).scalar_one_or_none() + + if existing is not None: + if not body.abandon_existing: + # 이어풀기 — 기존 그대로 반환. + return await _build_session_summary(existing, session) + existing.status = "abandoned" + existing.updated_at = datetime.now(timezone.utc) + await session.flush() + + # 신규 출제. + qids, distribution = await _select_questions_for_topic( + session, + user, + topic_id, + subject=body.subject, + scope=body.scope, + target_per_subject=body.target_per_subject, + wrong_only=body.wrong_only, + ) + if not qids: + raise HTTPException(status_code=400, detail="출제 가능한 문제가 없습니다") + + new_session = StudyQuizSession( + user_id=user.id, + study_topic_id=topic_id, + target_per_subject=body.target_per_subject, + subject_filter=body.subject, + wrong_only=body.wrong_only, + question_ids=qids, + subject_distribution=distribution, + status="in_progress", + cursor=0, + ) + session.add(new_session) + try: + await session.commit() + except IntegrityError: + # partial unique idx 충돌 — 동시에 다른 호출이 in_progress 만든 경우. + await session.rollback() + existing = ( + await session.execute( + select(StudyQuizSession).where( + StudyQuizSession.user_id == user.id, + StudyQuizSession.study_topic_id == topic_id, + StudyQuizSession.status == "in_progress", + ) + ) + ).scalar_one_or_none() + if existing is None: + raise HTTPException(status_code=409, detail="quiz_session 충돌 — 다시 시도하세요") + return await _build_session_summary(existing, session) + + return await _build_session_summary(new_session, session) + + +class QuizSessionAttemptItem(BaseModel): + """결과 카드 expand 시 카드별 메타 (attempt + 학습완료 상태).""" + attempt_id: int + question_id: int + selected_choice: int | None + correct_choice: int + outcome: str + answered_at: datetime + reviewed_at: datetime | None + + +class QuizSessionDetailResponse(BaseModel): + summary: QuizSessionSummary + questions: list[dict] # ReviewQuestionItem 호환 shape + attempts: list[QuizSessionAttemptItem] # 풀이된 것만 (cursor 까지) + + +def _attempt_stats_dict_default() -> dict: + return {"attempt_count": 0, "correct_count": 0, "wrong_count": 0} + + +@router.get("/{topic_id}/quiz-sessions/{session_id}", response_model=QuizSessionDetailResponse) +async def get_quiz_session( + topic_id: int, + session_id: int, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """풀이 화면 / 결과 화면 공용. questions 는 출제 순서. attempts 는 cursor 까지 풀이된 것만.""" + topic = await session.get(StudyTopic, topic_id) + _verify_topic_ownership(topic, user) + + qs = await session.get(StudyQuizSession, session_id) + if qs is None or qs.user_id != user.id or qs.study_topic_id != topic_id: + raise HTTPException(status_code=404, detail="quiz_session 을 찾을 수 없습니다") + + qids = list(qs.question_ids or []) + questions_payload: list[dict] = [] + if qids: + rows = ( + await session.execute( + select(StudyQuestion).where(StudyQuestion.id.in_(qids)) + ) + ).scalars().all() + by_id = {q.id: q for q in rows} + + # 이미지 batch + img_rows = ( + await session.execute( + select(StudyQuestionImage) + .where(StudyQuestionImage.study_question_id.in_(qids)) + .order_by(StudyQuestionImage.study_question_id, StudyQuestionImage.position) + ) + ).scalars().all() + images_map: dict[int, list[dict]] = {} + for im in img_rows: + images_map.setdefault(im.study_question_id, []).append({ + "id": im.id, + "served_url": f"/api/study-questions/{im.study_question_id}/images/{im.id}", + "position": im.position, + }) + + # attempt count batch (per question) + stat_rows = ( + await session.execute( + select( + StudyQuestionAttempt.study_question_id, + func.count().label("total"), + func.coalesce( + func.sum(case((StudyQuestionAttempt.is_correct.is_(True), 1), else_=0)), + 0, + ).label("correct"), + ) + .where( + StudyQuestionAttempt.user_id == user.id, + StudyQuestionAttempt.study_question_id.in_(qids), + ) + .group_by(StudyQuestionAttempt.study_question_id) + ) + ).all() + stats_map: dict[int, dict] = {} + for r in stat_rows: + t = int(r.total) + c = int(r.correct) + stats_map[r.study_question_id] = { + "attempt_count": t, "correct_count": c, "wrong_count": t - c + } + + # 출제 순서 그대로 nest + for qid in qids: + q = by_id.get(qid) + if q is None: + continue + questions_payload.append({ + "id": q.id, + "question_text": q.question_text, + "choices": [ + {"number": 1, "text": q.choice_1}, + {"number": 2, "text": q.choice_2}, + {"number": 3, "text": q.choice_3}, + {"number": 4, "text": q.choice_4}, + ], + "subject": q.subject, + "scope": q.scope, + "exam_round": q.exam_round, + "explanation": q.explanation, + "stats": stats_map.get(q.id, _attempt_stats_dict_default()), + "images": images_map.get(q.id, []), + # done 세션 결과 화면용 — 정답 노출. in_progress 세션은 클라이언트가 cursor 까지만 표시. + "correct_choice": q.correct_choice if qs.status == "done" else None, + }) + + # 이 세션의 attempts (cursor 까지). 같은 question 에 여러 attempts 있을 수 있지만, + # 이 세션 내에서는 정상 흐름상 question 당 1개. quiz_session_id 기준으로 가져옴. + attempt_rows = ( + await session.execute( + select(StudyQuestionAttempt) + .where( + StudyQuestionAttempt.user_id == user.id, + StudyQuestionAttempt.quiz_session_id == qs.id, + ) + .order_by(StudyQuestionAttempt.answered_at.asc()) + ) + ).scalars().all() + attempts_payload = [ + QuizSessionAttemptItem( + attempt_id=a.id, + question_id=a.study_question_id, + selected_choice=a.selected_choice, + correct_choice=a.correct_choice, + outcome=a.outcome, + answered_at=a.answered_at, + reviewed_at=a.reviewed_at, + ) + for a in attempt_rows + ] + + summary = await _build_session_summary(qs, session) + return QuizSessionDetailResponse( + summary=summary, + questions=questions_payload, + attempts=attempts_payload, + ) + + +class QuizSessionPatchRequest(BaseModel): + """abandon → status='abandoned'. 다른 필드 패치는 현재 안 받음.""" + action: str = Field(..., pattern="^(abandon)$") + + +@router.patch("/{topic_id}/quiz-sessions/{session_id}", response_model=QuizSessionSummary) +async def patch_quiz_session( + topic_id: int, + session_id: int, + body: QuizSessionPatchRequest, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """현재 abandon 만 지원 — 진행 중 세션 포기 (다시 시작 시 사용).""" + topic = await session.get(StudyTopic, topic_id) + _verify_topic_ownership(topic, user) + + qs = await session.get(StudyQuizSession, session_id) + if qs is None or qs.user_id != user.id or qs.study_topic_id != topic_id: + raise HTTPException(status_code=404, detail="quiz_session 을 찾을 수 없습니다") + + if body.action == "abandon": + if qs.status != "in_progress": + raise HTTPException(status_code=409, detail=f"이미 {qs.status} 상태입니다") + qs.status = "abandoned" + qs.updated_at = datetime.now(timezone.utc) + await session.commit() + return await _build_session_summary(qs, session) diff --git a/app/models/study_question.py b/app/models/study_question.py index 38ebbac..c81f5ed 100644 --- a/app/models/study_question.py +++ b/app/models/study_question.py @@ -113,5 +113,11 @@ class StudyQuestionAttempt(Base): answered_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=datetime.now, nullable=False ) + # PR-10: 어떤 quiz 세션의 attempt 인지 (NULL = 세션 외 직접 입력 또는 세션 삭제됨). + quiz_session_id: Mapped[int | None] = mapped_column( + BigInteger, ForeignKey("study_quiz_sessions.id", ondelete="SET NULL"), nullable=True + ) + # PR-10: 결과 카드에서 "학습완료" 체크 시 박힘. NULL = 미확인. + reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) question: Mapped["StudyQuestion"] = relationship(back_populates="attempts") diff --git a/app/models/study_quiz_session.py b/app/models/study_quiz_session.py new file mode 100644 index 0000000..00f1b1f --- /dev/null +++ b/app/models/study_quiz_session.py @@ -0,0 +1,50 @@ +"""study_quiz_sessions ORM (PR-10) — 문제풀이 세션 기록 + 이어풀기. + +한 토픽의 한 회차 풀이 = 한 행. question_ids 는 출제 순서 스냅샷. +status: in_progress / done / abandoned (강한 enum 미사용 — VARCHAR 권장값). +한 토픽당 in_progress 1개 강제는 partial unique idx (마이그레이션 207). +""" + +from datetime import datetime + +from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Integer, String +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from core.database import Base + + +class StudyQuizSession(Base): + __tablename__ = "study_quiz_sessions" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + user_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + study_topic_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("study_topics.id", ondelete="CASCADE"), nullable=False + ) + + target_per_subject: Mapped[int] = mapped_column(Integer, nullable=False, default=20) + subject_filter: Mapped[str | None] = mapped_column(String(120)) + wrong_only: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + + # 출제 순서 스냅샷 — list[int] (question id). 출제 후 변경 안 됨. + question_ids: Mapped[list] = mapped_column(JSONB, nullable=False) + # {subject: count} 분포. 결과 카드 통계 표시용. + subject_distribution: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + + status: Mapped[str] = mapped_column(String(20), nullable=False, default="in_progress") + cursor: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + correct_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + wrong_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + unsure_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=datetime.now, nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=datetime.now, onupdate=datetime.now, nullable=False + ) diff --git a/frontend/src/routes/study/topics/[id]/+page.svelte b/frontend/src/routes/study/topics/[id]/+page.svelte index 213ee0e..035c89e 100644 --- a/frontend/src/routes/study/topics/[id]/+page.svelte +++ b/frontend/src/routes/study/topics/[id]/+page.svelte @@ -24,6 +24,9 @@ let detail = $state(null); // { topic, sections, stats } let loading = $state(true); + // PR-10: 문제풀이 세션 (진행 중 + 최근 완료) + let quizSessions = $state({ active: null, recent_done: [] }); + // 자료 추가 모달 let docModalOpen = $state(false); let docSearch = $state(''); @@ -122,6 +125,12 @@ loading = true; try { detail = await api(`/study-topics/${topicId}`); + // PR-10: 세션 목록 함께 로드. 실패해도 detail 은 유지. + try { + quizSessions = await api(`/study-topics/${topicId}/quiz-sessions?limit=10`); + } catch { + quizSessions = { active: null, recent_done: [] }; + } } catch (err) { addToast('error', err.detail || '학습 주제 로딩 실패'); detail = null; @@ -130,6 +139,40 @@ } } + /** 새 문제풀이 시작 — 기존 in_progress 있으면 confirm 후 abandon. */ + async function startNewQuiz() { + const hasActive = !!quizSessions.active; + if (hasActive) { + const ok = confirm( + `진행 중인 문제풀이 세션이 있습니다 (${quizSessions.active.cursor}/${quizSessions.active.total} 풀음). 새로 시작하면 그 세션은 포기됩니다. 진행할까요?`, + ); + if (!ok) return; + } + try { + const res = await api(`/study-topics/${topicId}/quiz-sessions`, { + method: 'POST', + body: JSON.stringify({ + target_per_subject: 20, + abandon_existing: hasActive, + }), + }); + goto(`/study/topics/${topicId}/review?session=${res.id}`); + } catch (err) { + addToast('error', err?.detail || '문제풀이 시작 실패'); + } + } + + /** 진행 중 세션 이어풀기. */ + function resumeActiveQuiz() { + if (!quizSessions.active) return; + goto(`/study/topics/${topicId}/review?session=${quizSessions.active.id}`); + } + + function fmtDateTime(s) { + if (!s) return ''; + return new Date(s).toLocaleString('ko-KR', { dateStyle: 'short', timeStyle: 'short' }); + } + $effect(() => { void topicId; if (Number.isFinite(topicId)) { @@ -515,7 +558,7 @@ {#if (detail.sections.questions?.length ?? 0) > 0} - + {/if} @@ -524,6 +567,61 @@ 이 주제에 입력된 문제가 없습니다. "새 문제" 로 4지선다 객관식을 추가하면 문제풀이 모드에서 무작위 출제됩니다. {:else} + + {#if quizSessions.active} + {@const a = quizSessions.active} + {@const pct = a.total > 0 ? Math.round((a.cursor / a.total) * 100) : 0} +
+ +
+
+ 진행 중인 문제풀이 — {a.cursor} / {a.total} 풀음 + ({pct}%) +
+
+
+
+
시작 {fmtDateTime(a.created_at)}
+
+ +
+ {/if} + + + {#if quizSessions.recent_done?.length > 0} +
+
최근 결과
+ {#each quizSessions.recent_done as s (s.id)} + {@const accuracy = s.total > 0 ? Math.round((s.correct_count / s.total) * 100) : 0} + +
+
+ 정답 {s.correct_count} + · + 오답 {s.wrong_count} + · + 모르겠음 {s.unsure_count} + · + 정답률 {accuracy}% + {#if s.unreviewed_wrong_unsure_count > 0} + 미확인 {s.unreviewed_wrong_unsure_count} + {:else} + ✓ 모두 확인 + {/if} +
+
+ 총 {s.total}문제 · 완료 {fmtDateTime(s.finished_at)} +
+
+ +
+ {/each} +
+ {/if} +
{#each detail.sections.questions as q (q.id)}
diff --git a/frontend/src/routes/study/topics/[id]/quiz-sessions/[sid]/+page.svelte b/frontend/src/routes/study/topics/[id]/quiz-sessions/[sid]/+page.svelte new file mode 100644 index 0000000..7b72d48 --- /dev/null +++ b/frontend/src/routes/study/topics/[id]/quiz-sessions/[sid]/+page.svelte @@ -0,0 +1,489 @@ + + +결과 — {topicName || '주제'} + +
+
+ 공부 + / + 주제 + / + {topicName || '...'} + / + 결과 #{sessionId} +
+ + {#if loading} + + {:else if !detail} + + {#snippet children()} +
결과를 불러올 수 없습니다.
+ {/snippet} +
+ {:else} + {@const s = summary} + {@const accuracy = s.total > 0 ? Math.round((s.correct_count / s.total) * 100) : 0} + + {#snippet children()} +
+
+

결과

+
+ {#if unreviewedCount > 0} + 미확인 {unreviewedCount}건 + {:else} + ✓ 모두 확인 + {/if} + 완료 {fmtDateTime(s.finished_at)} +
+
+
+ 총 {s.total}문제 · + 정답 {s.correct_count} · + 오답 {s.wrong_count} · + 모르겠음 {s.unsure_count} · + 정답률 {accuracy}% +
+ + {#if Object.keys(s.subject_distribution || {}).length > 0} +
+ 과목 분포: + {#each Object.entries(s.subject_distribution) as [sub, n], i} + {i > 0 ? ' · ' : ''}{sub || '미분류'} {n} + {/each} +
+ {/if} + + +
+ + + +
+ + {#if activeTab === 'correct'} + {@render resultList(categorized.correct, 'correct')} + {:else if activeTab === 'wrong'} + {@render resultList(categorized.wrong, 'wrong')} + {:else if activeTab === 'unsure'} + {@render resultList(categorized.unsure, 'unsure')} + {/if} + +
+ +
+
+ {/snippet} +
+ {/if} +
+ +{#snippet resultList(items, kind)} + {#if items.length === 0} +
+ {#if kind === 'correct'}정답 처리된 문제가 없습니다. + {:else if kind === 'wrong'}오답 처리된 문제가 없습니다. + {:else}모르겠음 처리된 문제가 없습니다. + {/if} +
+ {:else} + + {/if} +{/snippet} + +{#snippet aiExplanationBlock(qid, st)} + {@const data = st.data} + {@const isLoaded = !!data?.ai_explanation} +
+
+ + AI 풀이 + {#if data?.is_stale} + stale + {/if} + {#if data?.from_cache && !data?.is_stale} + 캐시 + {/if} + + {#if !isLoaded} + + {:else if data?.is_stale} + + {:else if data?.ai_explanation_status === 'failed'} + + {:else} + + {/if} + +
+ + {#if data?.is_stale} +
+ + 이 풀이는 정답·문제가 수정된 후의 이전 풀이입니다. "다시 생성" 으로 새로 만들 수 있습니다. +
+ {/if} + {#if st.error} +
{st.error}
+ {/if} + {#if isLoaded} +
+ {@html renderMathMarkdown(data.ai_explanation)} +
+ {#if data.evidence?.length} +
+ 참고 근거 {data.evidence.length}건 +
    + {#each data.evidence as ev} +
  • + {ev.source_type === 'document' ? '📄' : '❓'} {ev.title} +
    {ev.snippet}
    +
  • + {/each} +
+
+ {/if} + {/if} +
+{/snippet} + +{#snippet subjectNoteBlock(it, st)} + {@const data = st.data} + {@const isLoaded = !!data?.content} +
+
+ + 분야 설명 + + {it.q.subject || '(과목 미지정)'}{it.q.scope ? ` · ${it.q.scope}` : ''} + + {#if data?.from_cache && data?.status === 'ready'} + 캐시 + {/if} + + {#if !isLoaded} + + {:else if data?.status === 'failed'} + + {:else} + + {/if} + +
+ + {#if st.error} +
{st.error}
+ {/if} + {#if isLoaded} +
+ {@html renderMathMarkdown(data.content)} +
+ {#if data.evidence?.length} +
+ 참고 근거 {data.evidence.length}건 +
    + {#each data.evidence as ev} +
  • + {ev.source_type === 'document' ? '📄' : '❓'} {ev.title} +
    {ev.snippet}
    +
  • + {/each} +
+
+ {/if} + {/if} +
+{/snippet} diff --git a/frontend/src/routes/study/topics/[id]/review/+page.svelte b/frontend/src/routes/study/topics/[id]/review/+page.svelte index c0d96c8..56fd8ac 100644 --- a/frontend/src/routes/study/topics/[id]/review/+page.svelte +++ b/frontend/src/routes/study/topics/[id]/review/+page.svelte @@ -1,19 +1,21 @@ @@ -238,6 +173,7 @@

기본은 과목별 {optTarget}문제씩 무작위 균등 추출. 한 과목이 부족하면 가용한 만큼만 출제됩니다. 풀이 중 정답·해설은 표시하지 않으며, 다 풀면 결과 화면에서 카테고리별로 한 번에 확인합니다. + 나갔다 와도 같은 위치에서 이어풀 수 있습니다.

@@ -272,12 +208,17 @@ {:else if mode === 'playing' && currentQ}
- {progress} + {progressLabel} {#if currentQ.subject}{currentQ.subject}{/if} {#if currentQ.scope} · {currentQ.scope}{/if} - 풀이 {answeredCount} / {questions.length} + +
+ + +
+
@@ -323,282 +264,5 @@
{/snippet} - {:else if mode === 'done'} - - - {#snippet children()} -
-
-

결과

-
- 총 {answeredCount}문제 · - 정답 {correctCount} · - 오답 {wrongCount} · - 모르겠음 {unsureCount} - {#if answeredCount > 0} - · 정답률 {Math.round((correctCount / answeredCount) * 100)}% - {/if} -
-
- - {#if Object.keys(distribution).length > 0} -
- 과목 분포: - {#each Object.entries(distribution) as [s, n], i} - {i > 0 ? ' · ' : ''}{s || '미분류'} {n} - {/each} -
- {/if} - - -
- - - -
- - - {#if activeTab === 'correct'} - {@render resultList(resultsByOutcome.correct, 'correct')} - {:else if activeTab === 'wrong'} - {@render resultList(resultsByOutcome.wrong, 'wrong')} - {:else if activeTab === 'unsure'} - {@render resultList(resultsByOutcome.unsure, 'unsure')} - {/if} - -
- - -
-
- {/snippet} -
{/if}
- -{#snippet resultList(items, kind)} - {#if items.length === 0} -
- {#if kind === 'correct'}정답 처리된 문제가 없습니다. - {:else if kind === 'wrong'}오답 처리된 문제가 없습니다. - {:else}모르겠음 처리된 문제가 없습니다. - {/if} -
- {:else} - - {/if} -{/snippet} - - -{#snippet aiExplanationBlock(qid, st)} - {@const data = st.data} - {@const isLoaded = !!data?.ai_explanation} -
-
- - AI 풀이 - {#if data?.is_stale} - stale - {/if} - {#if data?.from_cache && !data?.is_stale} - 캐시 - {/if} - - {#if !isLoaded} - - {:else if data?.is_stale} - - {:else if data?.ai_explanation_status === 'failed'} - - {:else} - - {/if} - -
- - {#if data?.is_stale} -
- - 이 풀이는 정답·문제가 수정된 후의 이전 풀이입니다. "다시 생성" 으로 새로 만들 수 있습니다. -
- {/if} - {#if st.error} -
{st.error}
- {/if} - {#if isLoaded} -
- {@html renderMathMarkdown(data.ai_explanation)} -
- {#if data.evidence?.length} -
- 참고 근거 {data.evidence.length}건 -
    - {#each data.evidence as ev} -
  • - {ev.source_type === 'document' ? '📄' : '❓'} {ev.title} -
    {ev.snippet}
    -
  • - {/each} -
-
- {/if} - {/if} -
-{/snippet} - - -{#snippet subjectNoteBlock(r, st)} - {@const data = st.data} - {@const isLoaded = !!data?.content} -
-
- - 분야 설명 - - {r.q.subject || '(과목 미지정)'}{r.q.scope ? ` · ${r.q.scope}` : ''} - - {#if data?.from_cache && data?.status === 'ready'} - 캐시 - {/if} - - {#if !isLoaded} - - {:else if data?.status === 'failed'} - - {:else} - - {/if} - -
- - {#if st.error} -
{st.error}
- {/if} - {#if isLoaded} -
- {@html renderMathMarkdown(data.content)} -
- {#if data.evidence?.length} -
- 참고 근거 {data.evidence.length}건 -
    - {#each data.evidence as ev} -
  • - {ev.source_type === 'document' ? '📄' : '❓'} {ev.title} -
    {ev.snippet}
    -
  • - {/each} -
-
- {/if} - {/if} -
-{/snippet} diff --git a/migrations/206_study_quiz_sessions.sql b/migrations/206_study_quiz_sessions.sql new file mode 100644 index 0000000..d07fd51 --- /dev/null +++ b/migrations/206_study_quiz_sessions.sql @@ -0,0 +1,26 @@ +-- 206_study_quiz_sessions.sql (1/4) +-- PR-10: 문제풀이 세션 기록 + 이어풀기. +-- 한 토픽의 한 회차 풀이 = 한 세션. question_ids 는 출제 순서 스냅샷. +-- 이어풀기: status='in_progress' + cursor (다음 풀 문제 index, 0-based). +-- 결과 카드: status='done' 후 correct/wrong/unsure_count + finished_at. +-- +-- status 권장값: in_progress / done / abandoned (강한 enum 미사용). + +CREATE TABLE IF NOT EXISTS study_quiz_sessions ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + study_topic_id BIGINT NOT NULL REFERENCES study_topics(id) ON DELETE CASCADE, + target_per_subject INT NOT NULL DEFAULT 20, + subject_filter VARCHAR(120), + wrong_only BOOLEAN NOT NULL DEFAULT FALSE, + question_ids JSONB NOT NULL, + subject_distribution JSONB NOT NULL DEFAULT '{}'::jsonb, + status VARCHAR(20) NOT NULL DEFAULT 'in_progress', + cursor INT NOT NULL DEFAULT 0, + correct_count INT NOT NULL DEFAULT 0, + wrong_count INT NOT NULL DEFAULT 0, + unsure_count INT NOT NULL DEFAULT 0, + finished_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/migrations/207_study_quiz_sessions_active_uq.sql b/migrations/207_study_quiz_sessions_active_uq.sql new file mode 100644 index 0000000..d01c95e --- /dev/null +++ b/migrations/207_study_quiz_sessions_active_uq.sql @@ -0,0 +1,7 @@ +-- 207_study_quiz_sessions_active_uq.sql (2/4) +-- 한 토픽당 in_progress 세션 1개 강제 (PR-1 partial unique 패턴 동일). +-- done/abandoned 는 여러 행 허용 (결과 기록 카드 누적). + +CREATE UNIQUE INDEX IF NOT EXISTS uq_study_quiz_sessions_active + ON study_quiz_sessions (user_id, study_topic_id) + WHERE status = 'in_progress'; diff --git a/migrations/208_attempts_quiz_session.sql b/migrations/208_attempts_quiz_session.sql new file mode 100644 index 0000000..1188d56 --- /dev/null +++ b/migrations/208_attempts_quiz_session.sql @@ -0,0 +1,6 @@ +-- 208_attempts_quiz_session.sql (3/4) +-- attempts 에 quiz_session_id FK (NULL 허용) — 세션 단위 attempt 묶기. +-- 세션 삭제 시 attempt 자체는 보존(SET NULL) — 이력 테이블 RESTRICT 원칙과 동일 의도. + +ALTER TABLE study_question_attempts + ADD COLUMN IF NOT EXISTS quiz_session_id BIGINT REFERENCES study_quiz_sessions(id) ON DELETE SET NULL; diff --git a/migrations/209_attempts_reviewed_at.sql b/migrations/209_attempts_reviewed_at.sql new file mode 100644 index 0000000..1ea0481 --- /dev/null +++ b/migrations/209_attempts_reviewed_at.sql @@ -0,0 +1,6 @@ +-- 209_attempts_reviewed_at.sql (4/4) +-- 결과 화면에서 사용자가 "학습완료" 체크 시 timestamp 박힘. NULL = 미확인. +-- 결과 카드 헤더 "미확인 N건" 카운트용. + +ALTER TABLE study_question_attempts + ADD COLUMN IF NOT EXISTS reviewed_at TIMESTAMPTZ;