feat(study): 문제풀이 세션 + 결과 카드 + 학습완료 체크 (PR-10)

- study_quiz_sessions 테이블 (한 토픽 in_progress 1개 partial unique)
- study_question_attempts 에 quiz_session_id + reviewed_at 컬럼
- 풀이 진행률 서버 단일 진실 (cursor) — 나갔다 와도 이어풀기 가능
- 통합뷰: 진행 중 카드(이어풀기) + 최근 완료 결과 카드(미확인 N건 배지)
- 신규 /quiz-sessions/[sid] 결과 페이지 (3 카테고리 + AI 해설 + 분야 설명 + 학습완료 토글)
- /review 페이지는 풀이만, 마지막 문제 풀이 후 결과 페이지로 redirect
- 마이그레이션 206~209 (single-statement, asyncpg 호환)
- API: POST/GET/PATCH /study-topics/{tid}/quiz-sessions(/{sid}),
       PATCH /study-question-attempts/{aid}/review-mark
- AttemptCreate.quiz_session_id 추가 — submit_attempt 가 같은 트랜잭션에서
  세션 cursor + count 증가, 마지막이면 status='done' + finished_at

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-28 16:49:21 +09:00
parent d968b2d901
commit 7f4d64c6df
11 changed files with 1332 additions and 437 deletions
+88
View File
@@ -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) ───
+456 -1
View File
@@ -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)
+6
View File
@@ -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")
+50
View File
@@ -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
)
@@ -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 @@
<Button href={`/study/topics/${topicId}/exam-rounds`} size="sm" variant="ghost" icon={ListChecks}>회차 보기</Button>
<Button href={`/study/topics/${topicId}/questions/new`} size="sm" variant="ghost" icon={Plus}> 문제</Button>
{#if (detail.sections.questions?.length ?? 0) > 0}
<Button href={`/study/topics/${topicId}/review`} size="sm" icon={Play}>문제풀이</Button>
<Button onclick={startNewQuiz} size="sm" icon={Play}> 문제풀이</Button>
{/if}
</div>
</div>
@@ -524,6 +567,61 @@
이 주제에 입력된 문제가 없습니다. "새 문제" 로 4지선다 객관식을 추가하면 문제풀이 모드에서 무작위 출제됩니다.
</div>
{:else}
<!-- PR-10: 진행 중 세션 카드 -->
{#if quizSessions.active}
{@const a = quizSessions.active}
{@const pct = a.total > 0 ? Math.round((a.cursor / a.total) * 100) : 0}
<div class="mb-2 p-3 rounded-lg border border-accent/40 bg-accent/5 flex items-center gap-3">
<Play size={16} class="text-accent shrink-0" />
<div class="flex-1 min-w-0">
<div class="text-sm text-text">
진행 중인 문제풀이 — <span class="font-medium">{a.cursor} / {a.total}</span> 풀음
<span class="text-dim text-[11px]">({pct}%)</span>
</div>
<div class="w-full h-1.5 bg-default/30 rounded-full overflow-hidden mt-1.5">
<div class="h-full bg-accent transition-all" style="width: {pct}%"></div>
</div>
<div class="text-[11px] text-dim mt-1">시작 {fmtDateTime(a.created_at)}</div>
</div>
<Button onclick={resumeActiveQuiz} size="sm" icon={ArrowRight} iconPosition="right">이어풀기</Button>
</div>
{/if}
<!-- PR-10: 최근 완료 세션 (결과 카드) -->
{#if quizSessions.recent_done?.length > 0}
<div class="mb-2 flex flex-col gap-1.5">
<div class="text-[11px] text-dim px-1">최근 결과</div>
{#each quizSessions.recent_done as s (s.id)}
{@const accuracy = s.total > 0 ? Math.round((s.correct_count / s.total) * 100) : 0}
<a
href={`/study/topics/${topicId}/quiz-sessions/${s.id}`}
class="flex items-center gap-3 p-2.5 rounded border border-default bg-surface hover:border-accent/40 hover:bg-bg/30 transition-colors"
>
<div class="flex-1 min-w-0">
<div class="text-sm text-text flex items-center gap-2 flex-wrap">
<span class="text-success">정답 {s.correct_count}</span>
<span class="text-dim">·</span>
<span class="text-error">오답 {s.wrong_count}</span>
<span class="text-dim">·</span>
<span class="text-warning">모르겠음 {s.unsure_count}</span>
<span class="text-dim">·</span>
<span class="text-text font-medium">정답률 {accuracy}%</span>
{#if s.unreviewed_wrong_unsure_count > 0}
<span class="ml-auto text-[10px] text-warning bg-warning/10 rounded px-1.5 py-0.5">미확인 {s.unreviewed_wrong_unsure_count}</span>
{:else}
<span class="ml-auto text-[10px] text-success bg-success/10 rounded px-1.5 py-0.5">✓ 모두 확인</span>
{/if}
</div>
<div class="text-[11px] text-dim mt-0.5">
{s.total}문제 · 완료 {fmtDateTime(s.finished_at)}
</div>
</div>
<ArrowRight size={12} class="text-dim shrink-0" />
</a>
{/each}
</div>
{/if}
<div class="flex flex-col gap-2">
{#each detail.sections.questions as q (q.id)}
<div class="flex items-center gap-3 p-3 rounded-lg border border-default bg-surface hover:bg-surface/80">
@@ -0,0 +1,489 @@
<script>
/**
* /study/topics/[id]/quiz-sessions/[sid] — 문제풀이 결과 (PR-10 신규).
*
* 한 세션 = 한 결과 카드. 정답/틀린/모르겠음 3 탭.
* - 정답: 본문/사용자 선택 확인만
* - 틀린: PR-3 AI 해설 (RAG)
* - 모르겠음: 분야(subject+scope) 설명 (PR-9 캐시)
* 카드별 [학습완료] 토글 (PATCH /study-question-attempts/{aid}/review-mark) — wrong/unsure 만.
* 헤더 "미확인 N건" → 0이면 "✓ 모두 확인".
*/
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import {
ArrowLeft, CheckCircle2, XCircle, HelpCircle, Sparkles, BookOpen, AlertCircle, Square, CheckSquare,
} from 'lucide-svelte';
import { renderMathMarkdown, renderMathMarkdownInline } from '$lib/utils/mathMarkdown';
import Button from '$lib/components/ui/Button.svelte';
import Card from '$lib/components/ui/Card.svelte';
import Skeleton from '$lib/components/ui/Skeleton.svelte';
import ImgAuth from '$lib/components/ImgAuth.svelte';
let topicId = $derived(Number($page.params.id));
let sessionId = $derived(Number($page.params.sid));
let topicName = $state('');
let detail = $state(null); // {summary, questions, attempts}
let loading = $state(true);
// 탭 + per-card state
let activeTab = $state('wrong'); // 'correct' | 'wrong' | 'unsure'
let perCard = $state({}); // { [qid]: { open, kind, loading, error, data } }
async function loadTopic() {
try {
const t = await api(`/study-topics/${topicId}`);
topicName = t?.topic?.name ?? '';
} catch {}
}
async function load() {
loading = true;
try {
detail = await api(`/study-topics/${topicId}/quiz-sessions/${sessionId}`);
// 최초 탭: 틀린 → 모르겠음 → 정답 (학습 우선순위 순)
if ((detail.summary.wrong_count ?? 0) > 0) activeTab = 'wrong';
else if ((detail.summary.unsure_count ?? 0) > 0) activeTab = 'unsure';
else activeTab = 'correct';
} catch (err) {
addToast('error', err?.detail || '결과 로드 실패');
detail = null;
} finally {
loading = false;
}
}
onMount(async () => {
await loadTopic();
await load();
});
/** 같은 question 의 attempt 와 question 객체를 묶어서 카테고리별로 분류. */
let categorized = $derived.by(() => {
if (!detail) return { correct: [], wrong: [], unsure: [] };
const qById = Object.fromEntries((detail.questions ?? []).map((q) => [q.id, q]));
const acc = { correct: [], wrong: [], unsure: [] };
for (const a of detail.attempts ?? []) {
const q = qById[a.question_id];
if (!q) continue;
const item = { attempt: a, q };
if (a.outcome === 'correct') acc.correct.push(item);
else if (a.outcome === 'wrong') acc.wrong.push(item);
else if (a.outcome === 'unsure') acc.unsure.push(item);
}
return acc;
});
let summary = $derived(detail?.summary);
let unreviewedCount = $derived(summary?.unreviewed_wrong_unsure_count ?? 0);
function setCard(qid, patch) {
perCard = { ...perCard, [qid]: { ...(perCard[qid] ?? {}), ...patch } };
}
function toggleCardOpen(qid) {
const cur = perCard[qid];
setCard(qid, { open: !(cur?.open) });
}
async function loadAiExplanation(qid, regenerate = false) {
setCard(qid, { kind: 'ai-expl', loading: true, error: null });
try {
const res = await api(`/study-questions/${qid}/ai-explanation`, {
method: 'POST',
body: JSON.stringify({ regenerate }),
});
setCard(qid, { data: res, error: res.ai_explanation_status === 'failed' ? '풀이 생성 실패. 다시 시도하세요.' : null });
} catch (err) {
const msg = err?.status === 409
? '다른 호출이 풀이를 생성 중입니다. 잠시 후 다시 시도해주세요.'
: (err?.detail || 'AI 풀이 생성 실패');
setCard(qid, { error: msg });
} finally {
setCard(qid, { loading: false });
}
}
async function loadSubjectNote(qid, regenerate = false) {
const item = [...categorized.correct, ...categorized.wrong, ...categorized.unsure].find((it) => it.q.id === qid);
if (!item) return;
const subject = item.q.subject || '';
const scope = item.q.scope || '';
if (!subject) {
setCard(qid, { kind: 'subject-note', error: '문제에 과목(subject)이 지정되지 않아 분야 설명을 만들 수 없습니다.' });
return;
}
setCard(qid, { kind: 'subject-note', loading: true, error: null });
try {
const res = await api(`/study-topics/${topicId}/subject-notes/generate`, {
method: 'POST',
body: JSON.stringify({ subject, scope, regenerate }),
});
setCard(qid, { data: res, error: res.status === 'failed' ? '분야 설명 생성 실패. 다시 시도하세요.' : null });
} catch (err) {
const msg = err?.status === 409
? '다른 호출이 분야 설명을 생성 중입니다. 잠시 후 다시 시도해주세요.'
: (err?.detail || '분야 설명 생성 실패');
setCard(qid, { error: msg });
} finally {
setCard(qid, { loading: false });
}
}
/** 학습완료 토글. PATCH 후 detail.attempts + summary.unreviewed_count 갱신. */
async function toggleReviewed(attempt) {
const next = !attempt.reviewed_at;
try {
const res = await api(`/study-question-attempts/${attempt.attempt_id}/review-mark`, {
method: 'PATCH',
body: JSON.stringify({ reviewed: next }),
});
// 로컬 상태 갱신 (서버 round-trip 없이)
attempt.reviewed_at = res.reviewed_at;
// unreviewed_count 재계산
if (detail && detail.summary) {
const remaining = (detail.attempts ?? []).filter(
(a) => (a.outcome === 'wrong' || a.outcome === 'unsure') && !a.reviewed_at
).length;
detail.summary = { ...detail.summary, unreviewed_wrong_unsure_count: remaining };
}
// perCard 객체 reactivity 트리거 (attempt mutate 만으로는 안 잡힘)
perCard = { ...perCard };
} catch (err) {
addToast('error', err?.detail || '체크 실패');
}
}
function shortText(s, n = 80) {
if (!s) return '';
const t = s.replace(/\s+/g, ' ').trim();
return t.length > n ? t.slice(0, n) + '…' : t;
}
function fmtDateTime(s) {
if (!s) return '';
return new Date(s).toLocaleString('ko-KR', { dateStyle: 'short', timeStyle: 'short' });
}
</script>
<svelte:head><title>결과 — {topicName || '주제'}</title></svelte:head>
<div class="p-4 md:p-6 max-w-3xl mx-auto">
<div class="flex items-center gap-2 text-xs md:text-sm mb-3">
<a href="/study" class="text-dim hover:text-text">공부</a>
<span class="text-faint">/</span>
<a href="/study/topics" class="text-dim hover:text-text">주제</a>
<span class="text-faint">/</span>
<a href={`/study/topics/${topicId}`} class="text-dim hover:text-text truncate">{topicName || '...'}</a>
<span class="text-faint">/</span>
<span class="text-text font-medium">결과 #{sessionId}</span>
</div>
{#if loading}
<Skeleton h="h-32" rounded="lg" />
{:else if !detail}
<Card>
{#snippet children()}
<div class="p-6 text-center text-dim">결과를 불러올 수 없습니다.</div>
{/snippet}
</Card>
{:else}
{@const s = summary}
{@const accuracy = s.total > 0 ? Math.round((s.correct_count / s.total) * 100) : 0}
<Card>
{#snippet children()}
<div class="p-5 flex flex-col gap-4">
<div class="flex items-center justify-between flex-wrap gap-2">
<h1 class="text-base font-semibold text-text">결과</h1>
<div class="text-xs text-dim flex items-center gap-2">
{#if unreviewedCount > 0}
<span class="text-warning bg-warning/10 rounded px-2 py-0.5">미확인 {unreviewedCount}</span>
{:else}
<span class="text-success bg-success/10 rounded px-2 py-0.5">✓ 모두 확인</span>
{/if}
<span>완료 {fmtDateTime(s.finished_at)}</span>
</div>
</div>
<div class="text-sm text-dim">
{s.total}문제 ·
<span class="text-success">정답 {s.correct_count}</span> ·
<span class="text-error">오답 {s.wrong_count}</span> ·
<span class="text-warning">모르겠음 {s.unsure_count}</span> ·
<span class="text-text font-medium">정답률 {accuracy}%</span>
</div>
{#if Object.keys(s.subject_distribution || {}).length > 0}
<div class="text-[11px] text-dim">
과목 분포:
{#each Object.entries(s.subject_distribution) as [sub, n], i}
<span>{i > 0 ? ' · ' : ''}{sub || '미분류'} {n}</span>
{/each}
</div>
{/if}
<!---->
<div class="flex border-b border-default">
<button
type="button"
onclick={() => (activeTab = 'wrong')}
class="px-4 py-2 text-sm border-b-2 transition-colors flex items-center gap-2
{activeTab === 'wrong' ? 'border-error text-error font-medium' : 'border-transparent text-dim hover:text-text'}"
>
<XCircle size={14} />
<span>틀린</span>
<span class="text-[10px] bg-error/10 text-error rounded px-1.5 py-0.5">{s.wrong_count}</span>
</button>
<button
type="button"
onclick={() => (activeTab = 'unsure')}
class="px-4 py-2 text-sm border-b-2 transition-colors flex items-center gap-2
{activeTab === 'unsure' ? 'border-warning text-warning font-medium' : 'border-transparent text-dim hover:text-text'}"
>
<HelpCircle size={14} />
<span>모르겠음</span>
<span class="text-[10px] bg-warning/10 text-warning rounded px-1.5 py-0.5">{s.unsure_count}</span>
</button>
<button
type="button"
onclick={() => (activeTab = 'correct')}
class="px-4 py-2 text-sm border-b-2 transition-colors flex items-center gap-2
{activeTab === 'correct' ? 'border-success text-success font-medium' : 'border-transparent text-dim hover:text-text'}"
>
<CheckCircle2 size={14} />
<span>정답</span>
<span class="text-[10px] bg-success/10 text-success rounded px-1.5 py-0.5">{s.correct_count}</span>
</button>
</div>
{#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}
<div class="flex gap-2 justify-end pt-3 border-t border-default">
<Button href={`/study/topics/${topicId}`} variant="ghost" icon={ArrowLeft}>주제로</Button>
</div>
</div>
{/snippet}
</Card>
{/if}
</div>
{#snippet resultList(items, kind)}
{#if items.length === 0}
<div class="text-xs text-dim p-4 text-center border border-dashed border-default rounded">
{#if kind === 'correct'}정답 처리된 문제가 없습니다.
{:else if kind === 'wrong'}오답 처리된 문제가 없습니다.
{:else}모르겠음 처리된 문제가 없습니다.
{/if}
</div>
{:else}
<ul class="flex flex-col gap-2">
{#each items as it (it.q.id)}
{@const cardState = perCard[it.q.id] ?? {}}
{@const isOpen = cardState.open === true}
{@const reviewed = !!it.attempt.reviewed_at}
<li class="rounded border bg-surface overflow-hidden
{kind !== 'correct' && reviewed ? 'border-success/30 bg-success/5' : 'border-default'}">
<button
type="button"
onclick={() => toggleCardOpen(it.q.id)}
class="w-full text-left p-3 flex items-start gap-3 hover:bg-bg/30 transition-colors"
>
<div class="flex-1 min-w-0">
<div class="text-sm text-text">{shortText(it.q.question_text, 100)}</div>
<div class="text-[11px] text-dim mt-1 flex items-center gap-2 flex-wrap">
{#if it.q.subject}<span>{it.q.subject}</span>{/if}
{#if it.q.scope}<span>· {it.q.scope}</span>{/if}
{#if it.q.exam_round}<span>· {it.q.exam_round}</span>{/if}
<span class="ml-auto">
{#if kind === 'correct'}
<span class="text-success">선택 {it.attempt.selected_choice}번 = 정답</span>
{:else if kind === 'wrong'}
<span class="text-error">선택 {it.attempt.selected_choice ?? '?'}</span>
<span class="text-dim"> · 정답 {it.attempt.correct_choice}</span>
{:else}
<span class="text-warning">모르겠음</span>
<span class="text-dim"> · 정답 {it.attempt.correct_choice}</span>
{/if}
{#if kind !== 'correct' && reviewed}
<CheckCircle2 size={12} class="inline ml-1 text-success" />
{/if}
</span>
</div>
</div>
</button>
{#if isOpen}
<div class="border-t border-default p-3 flex flex-col gap-3 bg-bg/20">
<div class="text-sm text-text leading-relaxed math-area">{@html renderMathMarkdown(it.q.question_text)}</div>
{#if it.q.images?.length > 0}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
{#each it.q.images as img (img.id)}
<ImgAuth src={img.served_url} class="w-full max-h-60 object-contain rounded border border-default bg-bg/30" />
{/each}
</div>
{/if}
<div class="flex flex-col gap-1.5">
{#each it.q.choices as ch (ch.number)}
{@const isCorrect = it.attempt.correct_choice === ch.number}
{@const isSelected = it.attempt.selected_choice === ch.number}
<div class="p-2.5 rounded border flex items-start gap-3 text-sm
{isCorrect ? 'border-success bg-success/10 text-text'
: (isSelected && !isCorrect) ? 'border-error bg-error/10 text-text'
: 'border-default bg-surface text-dim'}"
>
<span class="font-semibold w-5 shrink-0">{ch.number}</span>
<span class="flex-1 math-area overflow-x-auto">{@html renderMathMarkdownInline(ch.text)}</span>
{#if isCorrect}<CheckCircle2 size={14} class="text-success shrink-0" />
{:else if isSelected && !isCorrect}<XCircle size={14} class="text-error shrink-0" />
{/if}
</div>
{/each}
</div>
{#if it.q.explanation}
<div class="rounded border border-default bg-surface p-3 text-xs text-text math-area">
<div class="text-[10px] text-dim mb-1">사용자 입력 해설</div>
<div class="text-text">{@html renderMathMarkdown(it.q.explanation)}</div>
</div>
{/if}
{#if kind === 'wrong'}
{@render aiExplanationBlock(it.q.id, cardState)}
{:else if kind === 'unsure'}
{@render subjectNoteBlock(it, cardState)}
{/if}
{#if kind !== 'correct'}
<div class="flex justify-end pt-2 border-t border-default">
<button
type="button"
onclick={() => toggleReviewed(it.attempt)}
class="flex items-center gap-2 text-xs px-3 py-1.5 rounded border transition-colors
{reviewed
? 'border-success bg-success/10 text-success hover:bg-success/15'
: 'border-default text-dim hover:text-text hover:border-warning/40'}"
>
{#if reviewed}<CheckSquare size={14} />{:else}<Square size={14} />{/if}
<span>{reviewed ? '학습완료' : '학습완료로 표시'}</span>
</button>
</div>
{/if}
</div>
{/if}
</li>
{/each}
</ul>
{/if}
{/snippet}
{#snippet aiExplanationBlock(qid, st)}
{@const data = st.data}
{@const isLoaded = !!data?.ai_explanation}
<div class="rounded border border-default bg-bg/30 p-3 flex flex-col gap-2">
<div class="flex items-center gap-2 flex-wrap">
<Sparkles size={14} class="text-accent" />
<span class="text-xs font-semibold text-text">AI 풀이</span>
{#if data?.is_stale}
<span class="text-[10px] text-warning border border-warning/40 rounded px-1.5 py-0.5">stale</span>
{/if}
{#if data?.from_cache && !data?.is_stale}
<span class="text-[10px] text-dim">캐시</span>
{/if}
<span class="ml-auto flex items-center gap-1.5">
{#if !isLoaded}
<Button size="sm" variant="ghost" onclick={() => loadAiExplanation(qid, false)} loading={st.loading}>AI 해설 보기</Button>
{:else if data?.is_stale}
<Button size="sm" onclick={() => loadAiExplanation(qid, true)} loading={st.loading}>다시 생성</Button>
{:else if data?.ai_explanation_status === 'failed'}
<Button size="sm" onclick={() => loadAiExplanation(qid, true)} loading={st.loading}>다시 시도</Button>
{:else}
<Button size="sm" variant="ghost" onclick={() => loadAiExplanation(qid, true)} loading={st.loading}>다시 생성</Button>
{/if}
</span>
</div>
{#if data?.is_stale}
<div class="text-[11px] text-warning bg-warning/5 border border-warning/30 rounded px-2 py-1.5 flex items-start gap-1.5">
<AlertCircle size={12} class="mt-0.5 shrink-0" />
<span>이 풀이는 정답·문제가 수정된 후의 이전 풀이입니다. "다시 생성" 으로 새로 만들 수 있습니다.</span>
</div>
{/if}
{#if st.error}
<div class="text-[11px] text-error">{st.error}</div>
{/if}
{#if isLoaded}
<div class="text-xs text-text leading-relaxed prose prose-sm prose-invert max-w-none math-area">
{@html renderMathMarkdown(data.ai_explanation)}
</div>
{#if data.evidence?.length}
<details class="text-[10px] text-dim">
<summary class="cursor-pointer hover:text-text">참고 근거 {data.evidence.length}</summary>
<ul class="mt-1 flex flex-col gap-1">
{#each data.evidence as ev}
<li>
<span class="text-text">{ev.source_type === 'document' ? '📄' : '❓'} {ev.title}</span>
<div class="pl-4 truncate">{ev.snippet}</div>
</li>
{/each}
</ul>
</details>
{/if}
{/if}
</div>
{/snippet}
{#snippet subjectNoteBlock(it, st)}
{@const data = st.data}
{@const isLoaded = !!data?.content}
<div class="rounded border border-default bg-bg/30 p-3 flex flex-col gap-2">
<div class="flex items-center gap-2 flex-wrap">
<BookOpen size={14} class="text-warning" />
<span class="text-xs font-semibold text-text">분야 설명</span>
<span class="text-[10px] text-dim">
{it.q.subject || '(과목 미지정)'}{it.q.scope ? ` · ${it.q.scope}` : ''}
</span>
{#if data?.from_cache && data?.status === 'ready'}
<span class="text-[10px] text-dim">캐시</span>
{/if}
<span class="ml-auto flex items-center gap-1.5">
{#if !isLoaded}
<Button size="sm" variant="ghost" onclick={() => loadSubjectNote(it.q.id, false)} loading={st.loading}>분야 설명 보기</Button>
{:else if data?.status === 'failed'}
<Button size="sm" onclick={() => loadSubjectNote(it.q.id, true)} loading={st.loading}>다시 시도</Button>
{:else}
<Button size="sm" variant="ghost" onclick={() => loadSubjectNote(it.q.id, true)} loading={st.loading}>다시 생성</Button>
{/if}
</span>
</div>
{#if st.error}
<div class="text-[11px] text-error">{st.error}</div>
{/if}
{#if isLoaded}
<div class="text-xs text-text leading-relaxed prose prose-sm prose-invert max-w-none math-area">
{@html renderMathMarkdown(data.content)}
</div>
{#if data.evidence?.length}
<details class="text-[10px] text-dim">
<summary class="cursor-pointer hover:text-text">참고 근거 {data.evidence.length}</summary>
<ul class="mt-1 flex flex-col gap-1">
{#each data.evidence as ev}
<li>
<span class="text-text">{ev.source_type === 'document' ? '📄' : '❓'} {ev.title}</span>
<div class="pl-4 truncate">{ev.snippet}</div>
</li>
{/each}
</ul>
</details>
{/if}
{/if}
</div>
{/snippet}
@@ -1,19 +1,21 @@
<script>
/**
* /study/topics/[id]/review — 문제풀이 (PR-9 개편).
* /study/topics/[id]/review — 문제풀이 (PR-9 + PR-10).
*
* 풀이 중 정답·해설 노출 안 함 — 시험처럼 다 풀고 결과 화면에서 한 번에.
* "모르겠음" 5번째 옵션 추가. 답 클릭 시 즉시 다음 문제로 자동 진행.
* 결과 화면 = 정답·틀린·모르겠음 3 카테고리. 카드 클릭 시:
* - 정답: 본문/정답/사용자 선택 확인만
* - 틀린: PR-3 AI 해설
* - 모르겠음: 분야(subject+scope) 설명 (PR-9 신규)
* 라우트 진입 모드:
* - ?session=N 있으면 해당 세션 로드 (이어풀기) — 'playing'.
* - 없으면 'start' UI (옵션 입력 + 시작).
*
* 풀이 중 정답·해설 노출 안 함. "모르겠음" 5번째 옵션. 답 클릭 시 즉시 다음.
* 마지막 문제 풀이 후 (서버 status='done') /quiz-sessions/[sid] 결과 페이지로 redirect.
* 그 결과 페이지에 정답/오답/모르겠음 3 카테고리 + AI 해설 + 분야 설명 + 학습완료 체크.
*/
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import { ArrowLeft, Play, CheckCircle2, XCircle, HelpCircle, RotateCcw, Sparkles, BookOpen, AlertCircle } from 'lucide-svelte';
import { ArrowLeft, Play, HelpCircle } from 'lucide-svelte';
import { renderMathMarkdown, renderMathMarkdownInline } from '$lib/utils/mathMarkdown';
import Button from '$lib/components/ui/Button.svelte';
import Card from '$lib/components/ui/Card.svelte';
@@ -24,34 +26,22 @@
let topicId = $derived(Number($page.params.id));
let topicName = $state('');
// 진입 화면 옵션
let mode = $state('start'); // 'start' | 'playing' | 'done'
// 진입 화면 옵션 (start 모드용)
let mode = $state('start'); // 'start' | 'playing'
let optSubject = $state('');
let optWrongOnly = $state(false);
let optTarget = $state(20);
// 출제 결과
let questions = $state([]);
let total = $state(0);
let distribution = $state({});
// 진행 상태
let cursor = $state(0); // 0-based
// 세션 + 문제 로드
let sessionId = $state(null);
let questions = $state([]); // 출제 순서대로 (서버에서 받음)
let cursor = $state(0); // 0-based — 다음 풀 문제 index
let submitting = $state(false);
// 세션 누적 — 각 문제마다 {qid, q, attempt} 저장
// attempt = AttemptResponse: {is_correct, selected_choice, correct_choice, outcome, explanation, stats}
let results = $state([]);
let loading = $state(true);
// 결과 화면 — 활성 탭
let activeTab = $state('wrong'); // 'correct' | 'wrong' | 'unsure'
// 결과 카드별 expand 상태 + AI 호출 캐시
// perCard[qid] = { open, kind: 'ai-expl'|'subject-note'|'view',
// loading, error, data: {...response...} }
let perCard = $state({});
let progressTotal = $derived(questions.length);
let currentQ = $derived(questions[cursor] ?? null);
let progressLabel = $derived(progressTotal > 0 ? `${cursor + 1} / ${progressTotal}` : '0 / 0');
async function loadTopic() {
try {
@@ -60,158 +50,103 @@
} catch {}
}
onMount(async () => {
loading = true;
await loadTopic();
loading = false;
});
async function start() {
/** ?session=N 로드. 풀이된 attempt 갯수 = cursor 위치 (서버 신뢰). */
async function loadSession(sid) {
loading = true;
try {
const params = new URLSearchParams();
params.set('target_per_subject', String(optTarget));
if (optSubject.trim()) params.set('subject', optSubject.trim());
if (optWrongOnly) params.set('wrong_only', 'true');
const res = await api(`/study-topics/${topicId}/review/questions?${params}`);
questions = res.items;
total = res.total;
distribution = res.subject_distribution || {};
cursor = 0;
results = [];
perCard = {};
if (questions.length === 0) {
addToast('info', '출제할 문제가 없습니다.');
} else {
mode = 'playing';
const detail = await api(`/study-topics/${topicId}/quiz-sessions/${sid}`);
const summary = detail.summary;
if (summary.status === 'done') {
// 이미 끝난 세션 — 결과 페이지로.
goto(`/study/topics/${topicId}/quiz-sessions/${sid}`, { replaceState: true });
return;
}
if (summary.status === 'abandoned') {
addToast('info', '이 세션은 이미 포기 처리되어 새 세션을 시작합니다');
goto(`/study/topics/${topicId}`, { replaceState: true });
return;
}
sessionId = summary.id;
questions = detail.questions ?? [];
cursor = summary.cursor;
mode = 'playing';
} catch (err) {
addToast('error', err.detail || '문제풀이 시작 실패');
addToast('error', err?.detail || '세션 로드 실패');
goto(`/study/topics/${topicId}`, { replaceState: true });
} finally {
loading = false;
}
}
/** answer = 1|2|3|4 (객관식) 또는 'unsure' (모르겠음). 즉시 제출 + 자동 진행. */
onMount(async () => {
await loadTopic();
const url = new URL(window.location.href);
const sid = url.searchParams.get('session');
if (sid) {
await loadSession(Number(sid));
} else {
loading = false;
}
});
/** 'start' 모드에서 [시작] 클릭 → 신규 세션 생성 후 ?session=N 으로 이동. */
async function start() {
loading = true;
try {
const res = await api(`/study-topics/${topicId}/quiz-sessions`, {
method: 'POST',
body: JSON.stringify({
target_per_subject: optTarget,
subject: optSubject.trim() || null,
wrong_only: optWrongOnly,
abandon_existing: false, // start 화면에서는 in_progress 있으면 그쪽으로 이어감.
}),
});
goto(`/study/topics/${topicId}/review?session=${res.id}`, { replaceState: true });
// loadSession 은 navigate 후 onMount 가 다시 안 트리거되므로 직접 호출.
await loadSession(res.id);
} catch (err) {
addToast('error', err?.detail || '문제풀이 시작 실패');
loading = false;
}
}
/** answer = 1|2|3|4 (객관식) 또는 'unsure'. 즉시 제출 + cursor 갱신.
* 서버가 status='done' 으로 응답하면 결과 페이지로 이동. */
async function submitAndAdvance(answer) {
if (submitting) return;
if (submitting || !currentQ) return;
submitting = true;
try {
const q = questions[cursor];
const body = answer === 'unsure'
? { is_unsure: true }
: { selected_choice: Number(answer) };
const res = await api(`/study-questions/${q.id}/attempt`, {
? { is_unsure: true, quiz_session_id: sessionId }
: { selected_choice: Number(answer), quiz_session_id: sessionId };
const res = await api(`/study-questions/${currentQ.id}/attempt`, {
method: 'POST',
body: JSON.stringify(body),
});
results = [...results, { qid: q.id, q, attempt: res }];
if (cursor + 1 >= questions.length) {
// 최초 탭은 틀린 (= 학습 우선순위 높음). 없으면 모르겠음 → 정답.
const wrongCount = results.filter((r) => r.attempt.outcome === 'wrong').length;
const unsureCount = results.filter((r) => r.attempt.outcome === 'unsure').length;
if (wrongCount > 0) activeTab = 'wrong';
else if (unsureCount > 0) activeTab = 'unsure';
else activeTab = 'correct';
mode = 'done';
} else {
cursor += 1;
// 서버 cursor 신뢰.
if (res.quiz_session_status === 'done') {
goto(`/study/topics/${topicId}/quiz-sessions/${sessionId}`, { replaceState: true });
return;
}
cursor = typeof res.quiz_session_cursor === 'number' ? res.quiz_session_cursor : cursor + 1;
} catch (err) {
addToast('error', err.detail || '제출 실패');
addToast('error', err?.detail || '제출 실패');
} finally {
submitting = false;
}
}
function restart() {
mode = 'start';
questions = [];
results = [];
perCard = {};
cursor = 0;
}
let currentQ = $derived(questions[cursor] ?? null);
let progress = $derived(questions.length > 0 ? `${cursor + 1} / ${questions.length}` : '0 / 0');
// 결과 화면용 — 카테고리별 분류
let resultsByOutcome = $derived.by(() => {
const correct = [];
const wrong = [];
const unsure = [];
for (const r of results) {
if (r.attempt.outcome === 'correct') correct.push(r);
else if (r.attempt.outcome === 'wrong') wrong.push(r);
else if (r.attempt.outcome === 'unsure') unsure.push(r);
}
return { correct, wrong, unsure };
});
let answeredCount = $derived(results.length);
let correctCount = $derived(resultsByOutcome.correct.length);
let wrongCount = $derived(resultsByOutcome.wrong.length);
let unsureCount = $derived(resultsByOutcome.unsure.length);
function setCard(qid, patch) {
perCard = { ...perCard, [qid]: { ...(perCard[qid] ?? {}), ...patch } };
}
function toggleCardOpen(qid) {
const cur = perCard[qid];
setCard(qid, { open: !(cur?.open) });
}
/** 틀린 카드: PR-3 AI 해설 호출. */
async function loadAiExplanation(qid, regenerate = false) {
setCard(qid, { kind: 'ai-expl', loading: true, error: null });
async function abandonSession() {
if (!sessionId) return;
if (!confirm('현재 진행 중인 풀이를 포기하고 주제 화면으로 돌아갑니다. 진행률은 보존됩니다.')) return;
try {
const res = await api(`/study-questions/${qid}/ai-explanation`, {
method: 'POST',
body: JSON.stringify({ regenerate }),
await api(`/study-topics/${topicId}/quiz-sessions/${sessionId}`, {
method: 'PATCH',
body: JSON.stringify({ action: 'abandon' }),
});
setCard(qid, { data: res, error: res.ai_explanation_status === 'failed' ? '풀이 생성 실패. 다시 시도하세요.' : null });
} catch (err) {
const msg = err?.status === 409
? '다른 호출이 풀이를 생성 중입니다. 잠시 후 다시 시도해주세요.'
: (err?.detail || 'AI 풀이 생성 실패');
setCard(qid, { error: msg });
} finally {
setCard(qid, { loading: false });
}
}
/** 모르겠음 카드: 분야 설명 호출. (subject + scope 단위 캐시) */
async function loadSubjectNote(qid, regenerate = false) {
const r = results.find((x) => x.qid === qid);
if (!r) return;
const subject = r.q.subject || '';
const scope = r.q.scope || '';
if (!subject) {
setCard(qid, { kind: 'subject-note', error: '문제에 과목(subject)이 지정되지 않아 분야 설명을 만들 수 없습니다.' });
return;
}
setCard(qid, { kind: 'subject-note', loading: true, error: null });
try {
const res = await api(`/study-topics/${topicId}/subject-notes/generate`, {
method: 'POST',
body: JSON.stringify({ subject, scope, regenerate }),
});
setCard(qid, { data: res, error: res.status === 'failed' ? '분야 설명 생성 실패. 다시 시도하세요.' : null });
} catch (err) {
const msg = err?.status === 409
? '다른 호출이 분야 설명을 생성 중입니다. 잠시 후 다시 시도해주세요.'
: (err?.detail || '분야 설명 생성 실패');
setCard(qid, { error: msg });
} finally {
setCard(qid, { loading: false });
}
}
function shortText(s, n = 80) {
if (!s) return '';
const t = s.replace(/\s+/g, ' ').trim();
return t.length > n ? t.slice(0, n) + '…' : t;
} catch {}
goto(`/study/topics/${topicId}`);
}
</script>
@@ -238,6 +173,7 @@
<p class="text-xs text-dim">
기본은 과목별 {optTarget}문제씩 무작위 균등 추출. 한 과목이 부족하면 가용한 만큼만 출제됩니다.
풀이 중 정답·해설은 표시하지 않으며, 다 풀면 결과 화면에서 카테고리별로 한 번에 확인합니다.
나갔다 와도 같은 위치에서 이어풀 수 있습니다.
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
@@ -272,12 +208,17 @@
</Card>
{:else if mode === 'playing' && currentQ}
<div class="mb-3 flex items-center justify-between text-xs text-dim flex-wrap gap-2">
<span>{progress}</span>
<span>{progressLabel}</span>
<span>
{#if currentQ.subject}<span>{currentQ.subject}</span>{/if}
{#if currentQ.scope}<span> · {currentQ.scope}</span>{/if}
</span>
<span>풀이 {answeredCount} / {questions.length}</span>
<button type="button" onclick={abandonSession} class="text-dim hover:text-warning text-[11px]">중단</button>
</div>
<!-- 진행 바 -->
<div class="w-full h-1.5 bg-default/30 rounded-full overflow-hidden mb-3">
<div class="h-full bg-accent transition-all" style="width: {progressTotal > 0 ? (cursor / progressTotal) * 100 : 0}%"></div>
</div>
<Card>
@@ -323,282 +264,5 @@
</div>
{/snippet}
</Card>
{:else if mode === 'done'}
<!-- 결과 화면 -->
<Card>
{#snippet children()}
<div class="p-5 flex flex-col gap-4">
<div class="flex items-center justify-between flex-wrap gap-2">
<h1 class="text-base font-semibold text-text">결과</h1>
<div class="text-xs text-dim">
{answeredCount}문제 ·
<span class="text-success">정답 {correctCount}</span> ·
<span class="text-error">오답 {wrongCount}</span> ·
<span class="text-warning">모르겠음 {unsureCount}</span>
{#if answeredCount > 0}
· 정답률 {Math.round((correctCount / answeredCount) * 100)}%
{/if}
</div>
</div>
{#if Object.keys(distribution).length > 0}
<div class="text-[11px] text-dim">
과목 분포:
{#each Object.entries(distribution) as [s, n], i}
<span>{i > 0 ? ' · ' : ''}{s || '미분류'} {n}</span>
{/each}
</div>
{/if}
<!---->
<div class="flex border-b border-default">
<button
type="button"
onclick={() => (activeTab = 'wrong')}
class="px-4 py-2 text-sm border-b-2 transition-colors flex items-center gap-2
{activeTab === 'wrong' ? 'border-error text-error font-medium' : 'border-transparent text-dim hover:text-text'}"
>
<XCircle size={14} />
<span>틀린</span>
<span class="text-[10px] bg-error/10 text-error rounded px-1.5 py-0.5">{wrongCount}</span>
</button>
<button
type="button"
onclick={() => (activeTab = 'unsure')}
class="px-4 py-2 text-sm border-b-2 transition-colors flex items-center gap-2
{activeTab === 'unsure' ? 'border-warning text-warning font-medium' : 'border-transparent text-dim hover:text-text'}"
>
<HelpCircle size={14} />
<span>모르겠음</span>
<span class="text-[10px] bg-warning/10 text-warning rounded px-1.5 py-0.5">{unsureCount}</span>
</button>
<button
type="button"
onclick={() => (activeTab = 'correct')}
class="px-4 py-2 text-sm border-b-2 transition-colors flex items-center gap-2
{activeTab === 'correct' ? 'border-success text-success font-medium' : 'border-transparent text-dim hover:text-text'}"
>
<CheckCircle2 size={14} />
<span>정답</span>
<span class="text-[10px] bg-success/10 text-success rounded px-1.5 py-0.5">{correctCount}</span>
</button>
</div>
<!-- 카드 리스트 -->
{#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}
<div class="flex gap-2 justify-end pt-3 border-t border-default">
<Button href={`/study/topics/${topicId}`} variant="ghost" icon={ArrowLeft}>주제로</Button>
<Button onclick={restart} icon={RotateCcw}>다시 풀기</Button>
</div>
</div>
{/snippet}
</Card>
{/if}
</div>
{#snippet resultList(items, kind)}
{#if items.length === 0}
<div class="text-xs text-dim p-4 text-center border border-dashed border-default rounded">
{#if kind === 'correct'}정답 처리된 문제가 없습니다.
{:else if kind === 'wrong'}오답 처리된 문제가 없습니다.
{:else}모르겠음 처리된 문제가 없습니다.
{/if}
</div>
{:else}
<ul class="flex flex-col gap-2">
{#each items as r (r.qid)}
{@const cardState = perCard[r.qid] ?? {}}
{@const isOpen = cardState.open === true}
<li class="rounded border border-default bg-surface overflow-hidden">
<button
type="button"
onclick={() => toggleCardOpen(r.qid)}
class="w-full text-left p-3 flex items-start gap-3 hover:bg-bg/30 transition-colors"
>
<div class="flex-1 min-w-0">
<div class="text-sm text-text">{shortText(r.q.question_text, 100)}</div>
<div class="text-[11px] text-dim mt-1 flex items-center gap-2 flex-wrap">
{#if r.q.subject}<span>{r.q.subject}</span>{/if}
{#if r.q.scope}<span>· {r.q.scope}</span>{/if}
{#if r.q.exam_round}<span>· {r.q.exam_round}</span>{/if}
<span class="ml-auto">
{#if kind === 'correct'}
<span class="text-success">선택 {r.attempt.selected_choice}번 = 정답</span>
{:else if kind === 'wrong'}
<span class="text-error">선택 {r.attempt.selected_choice ?? '?'}</span>
<span class="text-dim"> · 정답 {r.attempt.correct_choice}</span>
{:else}
<span class="text-warning">모르겠음</span>
<span class="text-dim"> · 정답 {r.attempt.correct_choice}</span>
{/if}
</span>
</div>
</div>
</button>
{#if isOpen}
<div class="border-t border-default p-3 flex flex-col gap-3 bg-bg/20">
<!-- 본문 (수식 포함) -->
<div class="text-sm text-text leading-relaxed math-area">{@html renderMathMarkdown(r.q.question_text)}</div>
{#if r.q.images?.length > 0}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
{#each r.q.images as img (img.id)}
<ImgAuth src={img.served_url} class="w-full max-h-60 object-contain rounded border border-default bg-bg/30" />
{/each}
</div>
{/if}
<!-- 보기 + 정답 표시 -->
<div class="flex flex-col gap-1.5">
{#each r.q.choices as ch (ch.number)}
{@const isCorrect = r.attempt.correct_choice === ch.number}
{@const isSelected = r.attempt.selected_choice === ch.number}
<div class="p-2.5 rounded border flex items-start gap-3 text-sm
{isCorrect ? 'border-success bg-success/10 text-text'
: (isSelected && !isCorrect) ? 'border-error bg-error/10 text-text'
: 'border-default bg-surface text-dim'}"
>
<span class="font-semibold w-5 shrink-0">{ch.number}</span>
<span class="flex-1 math-area overflow-x-auto">{@html renderMathMarkdownInline(ch.text)}</span>
{#if isCorrect}<CheckCircle2 size={14} class="text-success shrink-0" />
{:else if isSelected && !isCorrect}<XCircle size={14} class="text-error shrink-0" />
{/if}
</div>
{/each}
</div>
<!-- 사용자 입력 해설 (있으면) -->
{#if r.q.explanation}
<div class="rounded border border-default bg-surface p-3 text-xs text-text math-area">
<div class="text-[10px] text-dim mb-1">사용자 입력 해설</div>
<div class="text-text">{@html renderMathMarkdown(r.q.explanation)}</div>
</div>
{/if}
<!-- 카테고리별 추가 액션 -->
{#if kind === 'wrong'}
{@render aiExplanationBlock(r.qid, cardState)}
{:else if kind === 'unsure'}
{@render subjectNoteBlock(r, cardState)}
{/if}
</div>
{/if}
</li>
{/each}
</ul>
{/if}
{/snippet}
<!-- 틀린 카드용: PR-3 AI 해설 -->
{#snippet aiExplanationBlock(qid, st)}
{@const data = st.data}
{@const isLoaded = !!data?.ai_explanation}
<div class="rounded border border-default bg-bg/30 p-3 flex flex-col gap-2">
<div class="flex items-center gap-2 flex-wrap">
<Sparkles size={14} class="text-accent" />
<span class="text-xs font-semibold text-text">AI 풀이</span>
{#if data?.is_stale}
<span class="text-[10px] text-warning border border-warning/40 rounded px-1.5 py-0.5">stale</span>
{/if}
{#if data?.from_cache && !data?.is_stale}
<span class="text-[10px] text-dim">캐시</span>
{/if}
<span class="ml-auto flex items-center gap-1.5">
{#if !isLoaded}
<Button size="sm" variant="ghost" onclick={() => loadAiExplanation(qid, false)} loading={st.loading}>AI 해설 보기</Button>
{:else if data?.is_stale}
<Button size="sm" onclick={() => loadAiExplanation(qid, true)} loading={st.loading}>다시 생성</Button>
{:else if data?.ai_explanation_status === 'failed'}
<Button size="sm" onclick={() => loadAiExplanation(qid, true)} loading={st.loading}>다시 시도</Button>
{:else}
<Button size="sm" variant="ghost" onclick={() => loadAiExplanation(qid, true)} loading={st.loading}>다시 생성</Button>
{/if}
</span>
</div>
{#if data?.is_stale}
<div class="text-[11px] text-warning bg-warning/5 border border-warning/30 rounded px-2 py-1.5 flex items-start gap-1.5">
<AlertCircle size={12} class="mt-0.5 shrink-0" />
<span>이 풀이는 정답·문제가 수정된 후의 이전 풀이입니다. "다시 생성" 으로 새로 만들 수 있습니다.</span>
</div>
{/if}
{#if st.error}
<div class="text-[11px] text-error">{st.error}</div>
{/if}
{#if isLoaded}
<div class="text-xs text-text leading-relaxed prose prose-sm prose-invert max-w-none math-area">
{@html renderMathMarkdown(data.ai_explanation)}
</div>
{#if data.evidence?.length}
<details class="text-[10px] text-dim">
<summary class="cursor-pointer hover:text-text">참고 근거 {data.evidence.length}</summary>
<ul class="mt-1 flex flex-col gap-1">
{#each data.evidence as ev}
<li>
<span class="text-text">{ev.source_type === 'document' ? '📄' : '❓'} {ev.title}</span>
<div class="pl-4 truncate">{ev.snippet}</div>
</li>
{/each}
</ul>
</details>
{/if}
{/if}
</div>
{/snippet}
<!-- 모르겠음 카드용: 분야 설명 (PR-9) -->
{#snippet subjectNoteBlock(r, st)}
{@const data = st.data}
{@const isLoaded = !!data?.content}
<div class="rounded border border-default bg-bg/30 p-3 flex flex-col gap-2">
<div class="flex items-center gap-2 flex-wrap">
<BookOpen size={14} class="text-warning" />
<span class="text-xs font-semibold text-text">분야 설명</span>
<span class="text-[10px] text-dim">
{r.q.subject || '(과목 미지정)'}{r.q.scope ? ` · ${r.q.scope}` : ''}
</span>
{#if data?.from_cache && data?.status === 'ready'}
<span class="text-[10px] text-dim">캐시</span>
{/if}
<span class="ml-auto flex items-center gap-1.5">
{#if !isLoaded}
<Button size="sm" variant="ghost" onclick={() => loadSubjectNote(r.qid, false)} loading={st.loading}>분야 설명 보기</Button>
{:else if data?.status === 'failed'}
<Button size="sm" onclick={() => loadSubjectNote(r.qid, true)} loading={st.loading}>다시 시도</Button>
{:else}
<Button size="sm" variant="ghost" onclick={() => loadSubjectNote(r.qid, true)} loading={st.loading}>다시 생성</Button>
{/if}
</span>
</div>
{#if st.error}
<div class="text-[11px] text-error">{st.error}</div>
{/if}
{#if isLoaded}
<div class="text-xs text-text leading-relaxed prose prose-sm prose-invert max-w-none math-area">
{@html renderMathMarkdown(data.content)}
</div>
{#if data.evidence?.length}
<details class="text-[10px] text-dim">
<summary class="cursor-pointer hover:text-text">참고 근거 {data.evidence.length}</summary>
<ul class="mt-1 flex flex-col gap-1">
{#each data.evidence as ev}
<li>
<span class="text-text">{ev.source_type === 'document' ? '📄' : '❓'} {ev.title}</span>
<div class="pl-4 truncate">{ev.snippet}</div>
</li>
{/each}
</ul>
</details>
{/if}
{/if}
</div>
{/snippet}
+26
View File
@@ -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()
);
@@ -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';
+6
View File
@@ -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;
+6
View File
@@ -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;