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:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user