feat(study): 문제은행 + 복습모드 (study_questions)

study_topic 워크스페이스에 4지선다 문제은행 자산 트랙 추가. 기사시험 필기
대비 시나리오 — 빠른 반복 입력 + 과목별 균등 추출 복습 + 정오답 누적.

데이터 모델 (migrations 186~190):
- study_questions: study_topic 1:N, soft delete, is_active 토글, correct_choice
  SMALLINT CHECK 1~4
- study_question_attempts: 답 제출 1행 누적. study_question_id FK는 ON DELETE
  RESTRICT (이력 보존 원칙 — hard delete 실수로 풀이 기록 소실 차단)

설계 원칙:
- 문제 삭제는 API 에서 soft delete only. attempts FK RESTRICT 로 DB 레벨도 보호
- correct_choice 변경 시 기존 attempts.is_correct 재계산 안 함 (시점 사실 보존)
- 복습 default = 과목별 target_per_subject(20) 무작위 균등 추출. 한 과목이
  부족하면 가용한 만큼만
- wrong_only=true 정의 = 가장 최근 attempt 가 오답인 문제 (latest-wrong, ever-wrong 아님)
- 출제 응답에서 정답·해설 비공개. 답 제출 시점에만 노출
- subject/scope 강한 enum 미사용 (자유 텍스트, 자동완성은 후속)

API: /api/study-topics/{id}/questions, /review/questions, /api/study-questions/{id},
/attempt. 통합뷰(/study-topics/{id}) 응답에 sections.questions / stats.question_count
추가. 기존 question_set_count 는 후속 PR(회차/모의고사 묶음)용으로 보존.

프론트: /study/topics/[id]에 문제 섹션 + "새 문제"/"복습 시작" 진입.
/questions/new (저장 후 계속 입력 + sessionStorage persistent),
/questions/[qid]/edit (정답 변경 시 attempts 재계산 안 됨 안내 배너),
/review (시작 옵션 → 풀이 → 마지막 요약).

후속 PR 예정: 오답노트/취약 과목 리포트, AI 해설/클러스터링, spaced
repetition, 이미지 OCR 입력, CSV import, study_question_sets 묶음.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-28 08:00:37 +09:00
parent efa1781211
commit 4b7156061e
15 changed files with 1678 additions and 6 deletions
+650
View File
@@ -0,0 +1,650 @@
"""학습 워크스페이스 문제은행 + 복습모드 API.
PR-2 가드레일:
- study_topic 컨테이너에 자산 타입별 1:N (문제) 추가. polymorphic 단일 테이블 영구 금지.
- subject/scope 강한 enum 미사용 (자유 텍스트, 자동완성은 후속 PR).
- 문제 삭제는 soft delete only. attempts 는 RESTRICT FK 로 DB 레벨 보호.
- correct_choice 변경 시 기존 attempts 재계산 안 함 (기록 = 시점 사실).
- 복습 출제 시 정답·해설 응답 비공개 → 답 제출 시점에만 노출.
- wrong_only=true 정의 = 가장 최근 attempt 가 오답인 문제. "한 번이라도 틀린 적 있는" 아님.
"""
import logging
import random
from datetime import datetime, timezone
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import and_, case, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from core.database import get_session
from models.study_question import StudyQuestion, StudyQuestionAttempt
from models.study_topic import StudyTopic
from models.user import User
logger = logging.getLogger(__name__)
router = APIRouter()
# ─── Helpers ───
def _verify_topic_ownership(topic: StudyTopic | None, user: User) -> StudyTopic:
"""study_topics.py 와 동일한 패턴 — 정보 누설 방지로 mismatch 도 404."""
if topic is None or topic.user_id != user.id or topic.deleted_at is not None:
raise HTTPException(status_code=404, detail="학습 주제를 찾을 수 없습니다")
return topic
def _verify_question_ownership(q: StudyQuestion | None, user: User) -> StudyQuestion:
if q is None or q.user_id != user.id or q.deleted_at is not None:
raise HTTPException(status_code=404, detail="문제를 찾을 수 없습니다")
return q
# ─── Pydantic 스키마 ───
class StudyQuestionCreate(BaseModel):
question_text: str = Field(min_length=1)
choice_1: str = Field(min_length=1)
choice_2: str = Field(min_length=1)
choice_3: str = Field(min_length=1)
choice_4: str = Field(min_length=1)
correct_choice: int = Field(ge=1, le=4)
subject: str | None = Field(default=None, max_length=120)
scope: str | None = Field(default=None, max_length=200)
exam_name: str | None = Field(default=None, max_length=120)
exam_round: str | None = Field(default=None, max_length=120)
explanation: str | None = None
source_note: str | None = None
is_active: bool = True
class StudyQuestionUpdate(BaseModel):
question_text: str | None = Field(default=None, min_length=1)
choice_1: str | None = Field(default=None, min_length=1)
choice_2: str | None = Field(default=None, min_length=1)
choice_3: str | None = Field(default=None, min_length=1)
choice_4: str | None = Field(default=None, min_length=1)
correct_choice: int | None = Field(default=None, ge=1, le=4)
subject: str | None = Field(default=None, max_length=120)
scope: str | None = Field(default=None, max_length=200)
exam_name: str | None = Field(default=None, max_length=120)
exam_round: str | None = Field(default=None, max_length=120)
explanation: str | None = None
source_note: str | None = None
is_active: bool | None = None
class QuestionAttemptStats(BaseModel):
attempt_count: int
correct_count: int
wrong_count: int
class StudyQuestionResponse(BaseModel):
"""편집·관리용 — 정답·해설 모두 노출."""
id: int
study_topic_id: int
question_text: str
choice_1: str
choice_2: str
choice_3: str
choice_4: str
correct_choice: int
subject: str | None
scope: str | None
exam_name: str | None
exam_round: str | None
explanation: str | None
source_note: str | None
is_active: bool
created_at: datetime
updated_at: datetime
stats: QuestionAttemptStats
class StudyQuestionSummary(BaseModel):
"""통합 뷰·목록용 — 본문 truncate, 정답 비공개."""
id: int
question_text: str # 앞 80자 truncate (서버 측에서 처리)
subject: str | None
scope: str | None
exam_name: str | None
exam_round: str | None
is_active: bool
attempt_count: int
last_correct: bool | None
created_at: datetime
class StudyQuestionListResponse(BaseModel):
items: list[StudyQuestionSummary]
total: int
page: int
page_size: int
class ReviewChoice(BaseModel):
number: int
text: str
class ReviewQuestionItem(BaseModel):
"""복습 출제 응답 — 정답·해설 비공개."""
id: int
question_text: str
choices: list[ReviewChoice]
subject: str | None
scope: str | None
stats: QuestionAttemptStats
class ReviewQuestionListResponse(BaseModel):
items: list[ReviewQuestionItem]
total: int
target_per_subject: int
subject_distribution: dict[str, int] # {"연소공학": 20, ...}
class AttemptCreate(BaseModel):
selected_choice: int = Field(ge=1, le=4)
class AttemptResponse(BaseModel):
is_correct: bool
selected_choice: int
correct_choice: int
explanation: str | None
stats: QuestionAttemptStats
# ─── 통계 헬퍼 ───
async def _attempt_stats(
session: AsyncSession, user_id: int, question_id: int
) -> QuestionAttemptStats:
"""단일 문제의 누적 attempt 통계 (사용자 한정)."""
row = (
await session.execute(
select(
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 == question_id,
)
)
).one()
total = int(row.total or 0)
correct = int(row.correct or 0)
return QuestionAttemptStats(
attempt_count=total, correct_count=correct, wrong_count=total - correct
)
def _truncate(text: str, n: int = 80) -> str:
return text if len(text) <= n else text[:n].rstrip() + ""
# ─── 토픽 단위 엔드포인트 ───
@router.get(
"/study-topics/{topic_id}/questions",
response_model=StudyQuestionListResponse,
)
async def list_questions_in_topic(
topic_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
subject: str | None = Query(None),
scope: str | None = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
):
"""토픽 내 문제 목록. soft-deleted 제외. summary 응답 (정답 비공개)."""
topic = await session.get(StudyTopic, topic_id)
_verify_topic_ownership(topic, user)
base = select(StudyQuestion).where(
StudyQuestion.user_id == user.id,
StudyQuestion.study_topic_id == topic_id,
StudyQuestion.deleted_at.is_(None),
)
if subject is not None:
base = base.where(StudyQuestion.subject == subject)
if scope is not None:
base = base.where(StudyQuestion.scope == scope)
total = (
await session.execute(select(func.count()).select_from(base.subquery()))
).scalar() or 0
rows = (
await session.execute(
base.order_by(StudyQuestion.created_at.desc(), StudyQuestion.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
).scalars().all()
# 한 번의 쿼리로 attempt 통계 + 마지막 정/오답 끌어오기 (N+1 회피)
qids = [q.id for q in rows]
stats_map: dict[int, tuple[int, int]] = {}
last_correct_map: dict[int, bool] = {}
if qids:
stats_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()
for r in stats_rows:
stats_map[r.study_question_id] = (int(r.total), int(r.correct))
latest_rows = (
await session.execute(
select(
StudyQuestionAttempt.study_question_id,
StudyQuestionAttempt.is_correct,
StudyQuestionAttempt.answered_at,
)
.where(
StudyQuestionAttempt.user_id == user.id,
StudyQuestionAttempt.study_question_id.in_(qids),
)
.order_by(
StudyQuestionAttempt.study_question_id,
StudyQuestionAttempt.answered_at.desc(),
)
.distinct(StudyQuestionAttempt.study_question_id)
)
).all()
for r in latest_rows:
last_correct_map[r.study_question_id] = bool(r.is_correct)
items = [
StudyQuestionSummary(
id=q.id,
question_text=_truncate(q.question_text, 80),
subject=q.subject,
scope=q.scope,
exam_name=q.exam_name,
exam_round=q.exam_round,
is_active=q.is_active,
attempt_count=stats_map.get(q.id, (0, 0))[0],
last_correct=last_correct_map.get(q.id),
created_at=q.created_at,
)
for q in rows
]
return StudyQuestionListResponse(items=items, total=total, page=page, page_size=page_size)
@router.post(
"/study-topics/{topic_id}/questions",
response_model=StudyQuestionResponse,
status_code=201,
)
async def create_question_in_topic(
topic_id: int,
body: StudyQuestionCreate,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
topic = await session.get(StudyTopic, topic_id)
_verify_topic_ownership(topic, user)
q = StudyQuestion(
user_id=user.id,
study_topic_id=topic_id,
question_text=body.question_text,
choice_1=body.choice_1,
choice_2=body.choice_2,
choice_3=body.choice_3,
choice_4=body.choice_4,
correct_choice=body.correct_choice,
subject=body.subject,
scope=body.scope,
exam_name=body.exam_name,
exam_round=body.exam_round,
explanation=body.explanation,
source_note=body.source_note,
is_active=body.is_active,
)
session.add(q)
await session.flush()
await session.commit()
stats = QuestionAttemptStats(attempt_count=0, correct_count=0, wrong_count=0)
return StudyQuestionResponse(
id=q.id,
study_topic_id=q.study_topic_id,
question_text=q.question_text,
choice_1=q.choice_1,
choice_2=q.choice_2,
choice_3=q.choice_3,
choice_4=q.choice_4,
correct_choice=q.correct_choice,
subject=q.subject,
scope=q.scope,
exam_name=q.exam_name,
exam_round=q.exam_round,
explanation=q.explanation,
source_note=q.source_note,
is_active=q.is_active,
created_at=q.created_at,
updated_at=q.updated_at,
stats=stats,
)
# ─── 복습 출제 ───
@router.get(
"/study-topics/{topic_id}/review/questions",
response_model=ReviewQuestionListResponse,
)
async def review_questions_for_topic(
topic_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
subject: str | None = Query(None, description="단일 과목 집중. 미지정 시 과목별 균등 추출"),
scope: str | None = Query(None),
target_per_subject: int = Query(20, ge=1, le=100),
wrong_only: bool = Query(
False,
description="가장 최근 attempt 가 오답인 문제만 (latest-wrong, ever_wrong 아님)",
),
):
"""복습 출제. default = 과목별 target_per_subject 무작위 균등 추출.
응답에서 정답·해설은 비공개 — 답 제출(POST /api/study-questions/{id}/attempt) 시점에만 노출.
"""
topic = await session.get(StudyTopic, topic_id)
_verify_topic_ownership(topic, user)
# 후보 질문 base — soft delete 제외 + is_active
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_only=true: latest attempt 가 오답인 question_id 만 후보로 좁힘.
# DISTINCT ON (study_question_id) ORDER BY answered_at DESC 패턴.
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 ReviewQuestionListResponse(
items=[], total=0, target_per_subject=target_per_subject, subject_distribution={}
)
# 과목 그룹 결정
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 ReviewQuestionListResponse(
items=[], total=0, target_per_subject=target_per_subject, subject_distribution={}
)
# 각 subject 별로 ORDER BY random() LIMIT target_per_subject
selected: list[StudyQuestion] = []
distribution: dict[str, int] = {}
for subj in subjects:
sub_q = select(StudyQuestion).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 = (await session.execute(sub_q)).scalars().all()
if rows:
selected.extend(rows)
distribution[subj or ""] = len(rows)
# 그룹별 추출 후 전체 셔플 (입력 순서 단조 회피)
random.shuffle(selected)
# attempt 통계 batch (N+1 회피)
qids = [q.id for q in selected]
stats_map: dict[int, QuestionAttemptStats] = {}
if qids:
stats_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()
for r in stats_rows:
t = int(r.total)
c = int(r.correct)
stats_map[r.study_question_id] = QuestionAttemptStats(
attempt_count=t, correct_count=c, wrong_count=t - c
)
items = [
ReviewQuestionItem(
id=q.id,
question_text=q.question_text,
choices=[
ReviewChoice(number=1, text=q.choice_1),
ReviewChoice(number=2, text=q.choice_2),
ReviewChoice(number=3, text=q.choice_3),
ReviewChoice(number=4, text=q.choice_4),
],
subject=q.subject,
scope=q.scope,
stats=stats_map.get(
q.id, QuestionAttemptStats(attempt_count=0, correct_count=0, wrong_count=0)
),
)
for q in selected
]
return ReviewQuestionListResponse(
items=items,
total=len(items),
target_per_subject=target_per_subject,
subject_distribution=distribution,
)
# ─── 단건 엔드포인트 ───
@router.get("/study-questions/{question_id}", response_model=StudyQuestionResponse)
async def get_question(
question_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
q = await session.get(StudyQuestion, question_id)
q = _verify_question_ownership(q, user)
stats = await _attempt_stats(session, user.id, question_id)
return StudyQuestionResponse(
id=q.id,
study_topic_id=q.study_topic_id,
question_text=q.question_text,
choice_1=q.choice_1,
choice_2=q.choice_2,
choice_3=q.choice_3,
choice_4=q.choice_4,
correct_choice=q.correct_choice,
subject=q.subject,
scope=q.scope,
exam_name=q.exam_name,
exam_round=q.exam_round,
explanation=q.explanation,
source_note=q.source_note,
is_active=q.is_active,
created_at=q.created_at,
updated_at=q.updated_at,
stats=stats,
)
@router.patch("/study-questions/{question_id}", response_model=StudyQuestionResponse)
async def update_question(
question_id: int,
body: StudyQuestionUpdate,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""부분 업데이트. correct_choice 변경 시 기존 attempts.is_correct 재계산 안 함 (시점 사실 보존).
프론트 편집 페이지에 안내 배너 노출."""
q = await session.get(StudyQuestion, question_id)
q = _verify_question_ownership(q, user)
fields_set = body.model_fields_set
SIMPLE_FIELDS = {
"question_text", "choice_1", "choice_2", "choice_3", "choice_4",
"correct_choice", "subject", "scope", "exam_name", "exam_round",
"explanation", "source_note", "is_active",
}
for fname in SIMPLE_FIELDS & fields_set:
setattr(q, fname, getattr(body, fname))
q.updated_at = datetime.now(timezone.utc)
await session.commit()
stats = await _attempt_stats(session, user.id, question_id)
return StudyQuestionResponse(
id=q.id,
study_topic_id=q.study_topic_id,
question_text=q.question_text,
choice_1=q.choice_1,
choice_2=q.choice_2,
choice_3=q.choice_3,
choice_4=q.choice_4,
correct_choice=q.correct_choice,
subject=q.subject,
scope=q.scope,
exam_name=q.exam_name,
exam_round=q.exam_round,
explanation=q.explanation,
source_note=q.source_note,
is_active=q.is_active,
created_at=q.created_at,
updated_at=q.updated_at,
stats=stats,
)
@router.delete("/study-questions/{question_id}", status_code=204)
async def soft_delete_question(
question_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""soft delete only. attempts 는 RESTRICT FK 로 보호되어 영구 보존.
hard delete 호출 경로 자체가 없음 — DB 레벨에서도 attempts 가 있으면 거부."""
q = await session.get(StudyQuestion, question_id)
q = _verify_question_ownership(q, user)
q.deleted_at = datetime.now(timezone.utc)
q.updated_at = q.deleted_at
await session.commit()
# ─── attempt ───
@router.post("/study-questions/{question_id}/attempt", response_model=AttemptResponse)
async def submit_attempt(
question_id: int,
body: AttemptCreate,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""답 제출. is_correct 판정 + attempt 1행 insert + 누적 통계 + 정답·해설 노출."""
q = await session.get(StudyQuestion, question_id)
q = _verify_question_ownership(q, user)
is_correct = body.selected_choice == q.correct_choice
attempt = StudyQuestionAttempt(
user_id=user.id,
study_question_id=q.id,
study_topic_id=q.study_topic_id,
selected_choice=body.selected_choice,
correct_choice=q.correct_choice,
is_correct=is_correct,
)
session.add(attempt)
await session.commit()
stats = await _attempt_stats(session, user.id, question_id)
return AttemptResponse(
is_correct=is_correct,
selected_choice=body.selected_choice,
correct_choice=q.correct_choice,
explanation=q.explanation,
stats=stats,
)
+126 -3
View File
@@ -74,6 +74,7 @@ class StudyTopicResponse(BaseModel):
sort_order: int
session_count: int = 0
document_count: int = 0
question_count: int = 0
created_at: datetime
updated_at: datetime
@@ -114,18 +115,37 @@ class StudyTopicDocumentSummary(BaseModel):
linked_at: datetime
class StudyTopicQuestionSummary(BaseModel):
"""상세 뷰의 문제 카드 페이로드 — 정답·해설 비공개, 본문 80자 truncate."""
id: int
question_text: str
subject: str | None
scope: str | None
exam_name: str | None
exam_round: str | None
is_active: bool
attempt_count: int
last_correct: bool | None
created_at: datetime
class StudyTopicSections(BaseModel):
"""확장 친화 dict — 향후 audio_assets / vocab_decks / question_sets 키 추가 가능."""
"""확장 친화 dict — 향후 audio_assets / vocab_decks 키 추가 가능."""
sessions: list[StudyTopicSessionSummary]
documents: list[StudyTopicDocumentSummary]
questions: list[StudyTopicQuestionSummary] = []
class StudyTopicStats(BaseModel):
"""자산 카운트 — 0 으로 미리 노출해서 후속 PR 에서 필드만 채움."""
"""자산 카운트 — 0 으로 미리 노출해서 후속 PR 에서 필드만 채움.
question_count = PR-2 단일 문제 수. question_set_count = 후속 PR 의 회차/모의고사 묶음 수 (둘은 의미 다름).
"""
session_count: int
document_count: int
question_count: int = 0
audio_count: int = 0
vocab_count: int = 0
question_set_count: int = 0
@@ -230,14 +250,28 @@ async def list_study_topics(
.subquery()
)
# PR-2 — 활성 문제 카운트 (soft-deleted 제외)
from models.study_question import StudyQuestion as _SQ
q_count_sub = (
select(
_SQ.study_topic_id.label("topic_id"),
func.count().label("c"),
)
.where(_SQ.user_id == user.id, _SQ.deleted_at.is_(None))
.group_by(_SQ.study_topic_id)
.subquery()
)
base = (
select(
StudyTopic,
func.coalesce(sess_count_sub.c.c, 0).label("session_count"),
func.coalesce(doc_count_sub.c.c, 0).label("document_count"),
func.coalesce(q_count_sub.c.c, 0).label("question_count"),
)
.outerjoin(sess_count_sub, sess_count_sub.c.topic_id == StudyTopic.id)
.outerjoin(doc_count_sub, doc_count_sub.c.topic_id == StudyTopic.id)
.outerjoin(q_count_sub, q_count_sub.c.topic_id == StudyTopic.id)
.where(StudyTopic.user_id == user.id, StudyTopic.deleted_at.is_(None))
.order_by(StudyTopic.sort_order.asc(), StudyTopic.id.desc())
)
@@ -261,10 +295,11 @@ async def list_study_topics(
sort_order=t.sort_order,
session_count=int(sc),
document_count=int(dc),
question_count=int(qc),
created_at=t.created_at,
updated_at=t.updated_at,
)
for t, sc, dc in rows
for t, sc, dc, qc in rows
]
return StudyTopicListResponse(items=items, total=total)
@@ -409,15 +444,92 @@ async def get_study_topic(
for d, link in doc_rows
]
# 문제 목록 (PR-2) — 본문 truncate, 정답·해설 비공개. attempt 통계 batch 로 끌어옴.
from models.study_question import StudyQuestion, StudyQuestionAttempt
q_rows = (
await session.execute(
select(StudyQuestion)
.where(
StudyQuestion.user_id == user.id,
StudyQuestion.study_topic_id == topic_id,
StudyQuestion.deleted_at.is_(None),
)
.order_by(StudyQuestion.created_at.desc(), StudyQuestion.id.desc())
)
).scalars().all()
qids = [q.id for q in q_rows]
q_attempt_count: dict[int, int] = {}
q_last_correct: dict[int, bool] = {}
if qids:
from sqlalchemy import case as _case
cnt_rows = (
await session.execute(
select(
StudyQuestionAttempt.study_question_id,
func.count().label("total"),
)
.where(
StudyQuestionAttempt.user_id == user.id,
StudyQuestionAttempt.study_question_id.in_(qids),
)
.group_by(StudyQuestionAttempt.study_question_id)
)
).all()
for r in cnt_rows:
q_attempt_count[r.study_question_id] = int(r.total)
latest_rows = (
await session.execute(
select(
StudyQuestionAttempt.study_question_id,
StudyQuestionAttempt.is_correct,
)
.where(
StudyQuestionAttempt.user_id == user.id,
StudyQuestionAttempt.study_question_id.in_(qids),
)
.order_by(
StudyQuestionAttempt.study_question_id,
StudyQuestionAttempt.answered_at.desc(),
)
.distinct(StudyQuestionAttempt.study_question_id)
)
).all()
for r in latest_rows:
q_last_correct[r.study_question_id] = bool(r.is_correct)
def _truncate_q(text: str, n: int = 80) -> str:
return text if len(text) <= n else text[:n].rstrip() + ""
questions_payload = [
StudyTopicQuestionSummary(
id=q.id,
question_text=_truncate_q(q.question_text, 80),
subject=q.subject,
scope=q.scope,
exam_name=q.exam_name,
exam_round=q.exam_round,
is_active=q.is_active,
attempt_count=q_attempt_count.get(q.id, 0),
last_correct=q_last_correct.get(q.id),
created_at=q.created_at,
)
for q in q_rows
]
return StudyTopicDetailResponse(
topic=_meta_from_topic(topic),
sections=StudyTopicSections(
sessions=sessions_payload,
documents=documents_payload,
questions=questions_payload,
),
stats=StudyTopicStats(
session_count=len(sessions_payload),
document_count=len(documents_payload),
question_count=len(questions_payload),
# 후속 PR 에서 채움
audio_count=0,
vocab_count=0,
@@ -460,6 +572,16 @@ async def update_study_topic(
.select_from(StudyTopicDocument)
.where(StudyTopicDocument.study_topic_id == topic.id)
)).scalar() or 0
from models.study_question import StudyQuestion as _SQ2
qc = (await session.execute(
select(func.count())
.select_from(_SQ2)
.where(
_SQ2.user_id == user.id,
_SQ2.study_topic_id == topic.id,
_SQ2.deleted_at.is_(None),
)
)).scalar() or 0
return StudyTopicResponse(
id=topic.id,
@@ -470,6 +592,7 @@ async def update_study_topic(
sort_order=topic.sort_order,
session_count=int(sc),
document_count=int(dc),
question_count=int(qc),
created_at=topic.created_at,
updated_at=topic.updated_at,
)
+3
View File
@@ -19,6 +19,7 @@ from api.memos import router as memos_router
from api.news import router as news_router
from api.search import router as search_router
from api.setup import router as setup_router
from api.study_questions import router as study_questions_router
from api.study_sessions import router as study_sessions_router
from api.study_topics import router as study_topics_router
from api.video import router as video_router
@@ -117,6 +118,8 @@ app.include_router(audio_router, prefix="/api/audio", tags=["audio"])
app.include_router(video_router, prefix="/api/video", tags=["video"])
app.include_router(study_sessions_router, prefix="/api/study-sessions", tags=["study-sessions"])
app.include_router(study_topics_router, prefix="/api/study-topics", tags=["study-topics"])
# study_questions: 라우터 안에서 /study-topics/{id}/questions 와 /study-questions/{id} 두 줄기를 모두 정의하므로 prefix=/api 로 등록
app.include_router(study_questions_router, prefix="/api", tags=["study-questions"])
# TODO: Phase 5에서 추가
# app.include_router(tasks.router, prefix="/api/tasks", tags=["tasks"])
+87
View File
@@ -0,0 +1,87 @@
"""study_questions / study_question_attempts ORM — 학습 워크스페이스의 문제은행 트랙
PR-2 가드레일:
- study_topic 1차 컨테이너에 자산 타입별 조인 테이블 추가 방식. polymorphic 단일 테이블 영구 금지.
- subject/scope 는 강한 enum 미사용 (jlpt 등 어학 분류 확장 여지).
- 문제 삭제는 API 에서 soft delete only. attempts FK 는 ON DELETE RESTRICT 로 DB 레벨 보호 (hard delete 실수 차단, 이력 보존).
- correct_choice 변경 시 기존 attempt.is_correct 재계산 안 함 (기록은 그 시점의 사실).
"""
from datetime import datetime
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Integer, SmallInteger, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from core.database import Base
class StudyQuestion(Base):
__tablename__ = "study_questions"
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
)
question_text: Mapped[str] = mapped_column(Text, nullable=False)
choice_1: Mapped[str] = mapped_column(Text, nullable=False)
choice_2: Mapped[str] = mapped_column(Text, nullable=False)
choice_3: Mapped[str] = mapped_column(Text, nullable=False)
choice_4: Mapped[str] = mapped_column(Text, nullable=False)
correct_choice: Mapped[int] = mapped_column(SmallInteger, nullable=False)
subject: Mapped[str | None] = mapped_column(String(120))
scope: Mapped[str | None] = mapped_column(String(200))
exam_name: Mapped[str | None] = mapped_column(String(120))
exam_round: Mapped[str | None] = mapped_column(String(120))
explanation: Mapped[str | None] = mapped_column(Text)
source_note: Mapped[str | None] = mapped_column(Text)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
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
)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# 연관 — 통합 뷰/통계 조회 시 selectinload 으로 끌어옴
topic: Mapped["StudyTopic | None"] = relationship( # type: ignore[name-defined] # noqa: F821
"StudyTopic", back_populates="questions", lazy="noload"
)
attempts: Mapped[list["StudyQuestionAttempt"]] = relationship(
back_populates="question",
cascade="all, delete-orphan", # ORM 레벨 cascade — 실 hard delete 는 RESTRICT FK 가 막음
order_by="StudyQuestionAttempt.answered_at.desc()",
lazy="noload",
)
class StudyQuestionAttempt(Base):
__tablename__ = "study_question_attempts"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
study_question_id: Mapped[int] = mapped_column(
BigInteger,
ForeignKey("study_questions.id", ondelete="RESTRICT"),
nullable=False,
)
study_topic_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("study_topics.id", ondelete="CASCADE"), nullable=False
)
selected_choice: Mapped[int] = mapped_column(SmallInteger, nullable=False)
correct_choice: Mapped[int] = mapped_column(SmallInteger, nullable=False)
is_correct: Mapped[bool] = mapped_column(Boolean, nullable=False)
answered_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, nullable=False
)
question: Mapped["StudyQuestion"] = relationship(back_populates="attempts")
+4 -1
View File
@@ -48,7 +48,7 @@ class StudyTopic(Base):
)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# 연관 — 세션 (1:N), 자료 매핑 (N:M)
# 연관 — 세션 (1:N), 자료 매핑 (N:M), 문제 (1:N PR-2)
sessions: Mapped[list["StudySession"]] = relationship( # type: ignore[name-defined] # noqa: F821
"StudySession", back_populates="study_topic", lazy="noload"
)
@@ -58,6 +58,9 @@ class StudyTopic(Base):
order_by="StudyTopicDocument.sort_order",
lazy="noload",
)
questions: Mapped[list["StudyQuestion"]] = relationship( # type: ignore[name-defined] # noqa: F821
"StudyQuestion", back_populates="topic", lazy="noload"
)
class StudyTopicDocument(Base):
@@ -220,9 +220,10 @@
{#if t.description}
<p class="text-xs text-dim line-clamp-2 mb-2">{t.description}</p>
{/if}
<div class="text-[10px] text-dim flex items-center gap-3">
<div class="text-[10px] text-dim flex items-center gap-3 flex-wrap">
<span>필기 <span class="text-text">{t.session_count}</span></span>
<span>자료 <span class="text-text">{t.document_count}</span></span>
<span>문제 <span class="text-text">{t.question_count ?? 0}</span></span>
<span class="ml-auto">{fmtDate(t.created_at)}</span>
</div>
</a>
@@ -12,7 +12,7 @@
import { addToast } from '$lib/stores/toast';
import {
ArrowLeft, FolderKanban, PenLine, BookOpen, Plus, Trash2, ArrowRight, Languages,
ChevronRight, ChevronDown, FolderOpen, FolderPlus,
ChevronRight, ChevronDown, FolderOpen, FolderPlus, HelpCircle, Edit, Play, CheckCircle2, XCircle,
} from 'lucide-svelte';
import Button from '$lib/components/ui/Button.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
@@ -365,6 +365,54 @@
</div>
{/if}
</section>
<!-- 문제 (PR-2) -->
<section class="mb-5">
<div class="flex items-center justify-between mb-2 flex-wrap gap-2">
<h2 class="text-sm font-semibold text-text flex items-center gap-2">
<HelpCircle size={14} class="text-accent" /> 문제
<span class="text-[10px] text-dim">{detail.sections.questions?.length ?? 0}</span>
</h2>
<div class="flex items-center gap-1">
<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>
{/if}
</div>
</div>
{#if (detail.sections.questions?.length ?? 0) === 0}
<div class="text-xs text-dim p-3 border border-dashed border-default/60 rounded-lg">
이 주제에 입력된 문제가 없습니다. "새 문제" 로 4지선다 객관식을 추가하면 복습모드에서 무작위 출제됩니다.
</div>
{:else}
<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">
<div class="flex-1 min-w-0">
<div class="text-sm text-text line-clamp-2">{q.question_text}</div>
<div class="text-[11px] text-dim mt-1 flex items-center gap-2 flex-wrap">
{#if q.subject}<span>{q.subject}</span>{/if}
{#if q.scope}<span>· {q.scope}</span>{/if}
{#if q.exam_round}<span>· {q.exam_round}</span>{/if}
{#if q.attempt_count > 0}
<span class="ml-auto flex items-center gap-1">
{#if q.last_correct === true}
<CheckCircle2 size={11} class="text-success" />
{:else if q.last_correct === false}
<XCircle size={11} class="text-error" />
{/if}
<span>{q.attempt_count}</span>
</span>
{/if}
{#if !q.is_active}<span class="text-warning">· 비활성</span>{/if}
</div>
</div>
<Button href={`/study/topics/${topicId}/questions/${q.id}/edit`} size="sm" variant="ghost" icon={Edit}>편집</Button>
</div>
{/each}
</div>
{/if}
</section>
{/if}
</div>
@@ -0,0 +1,195 @@
<script>
/**
* /study/topics/[id]/questions/[qid]/edit — 문제 편집 페이지.
*
* 정답(correct_choice) 변경 시 기존 attempts.is_correct 는 재계산되지 않음.
* 풀이 시점의 정오답이 그대로 보존되므로 안내 배너 고정 노출.
* 삭제는 soft delete 만 (DB FK RESTRICT 로 hard delete 차단, 이력 보존).
*/
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, Save, Trash2, AlertCircle } from 'lucide-svelte';
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 TextInput from '$lib/components/ui/TextInput.svelte';
import Textarea from '$lib/components/ui/Textarea.svelte';
let topicId = $derived(Number($page.params.id));
let questionId = $derived(Number($page.params.qid));
let loading = $state(true);
let saving = $state(false);
let deleting = $state(false);
let topicName = $state('');
let q_text = $state('');
let c1 = $state(''); let c2 = $state(''); let c3 = $state(''); let c4 = $state('');
let correct = $state(1);
let subject = $state(''); let scope = $state('');
let exam_name = $state(''); let exam_round = $state('');
let explanation = $state(''); let source_note = $state('');
let is_active = $state(true);
let stats = $state({ attempt_count: 0, correct_count: 0, wrong_count: 0 });
async function load() {
loading = true;
try {
const [t, q] = await Promise.all([
api(`/study-topics/${topicId}`),
api(`/study-questions/${questionId}`),
]);
topicName = t?.topic?.name ?? '';
q_text = q.question_text;
c1 = q.choice_1; c2 = q.choice_2; c3 = q.choice_3; c4 = q.choice_4;
correct = q.correct_choice;
subject = q.subject ?? '';
scope = q.scope ?? '';
exam_name = q.exam_name ?? '';
exam_round = q.exam_round ?? '';
explanation = q.explanation ?? '';
source_note = q.source_note ?? '';
is_active = q.is_active;
stats = q.stats;
} catch (err) {
addToast('error', err.detail || '문제 로딩 실패');
} finally {
loading = false;
}
}
onMount(load);
function validate() {
if (!q_text.trim()) { addToast('error', '문제 본문을 입력하세요'); return false; }
if (!c1.trim() || !c2.trim() || !c3.trim() || !c4.trim()) {
addToast('error', '1~4번 보기를 모두 입력하세요'); return false;
}
return true;
}
async function save() {
if (!validate()) return;
saving = true;
try {
await api(`/study-questions/${questionId}`, {
method: 'PATCH',
body: JSON.stringify({
question_text: q_text.trim(),
choice_1: c1.trim(), choice_2: c2.trim(),
choice_3: c3.trim(), choice_4: c4.trim(),
correct_choice: Number(correct),
subject: subject || null,
scope: scope || null,
exam_name: exam_name || null,
exam_round: exam_round || null,
explanation: explanation || null,
source_note: source_note || null,
is_active,
}),
});
addToast('success', '저장됨');
goto(`/study/topics/${topicId}`);
} catch (err) {
addToast('error', err.detail || '저장 실패');
} finally {
saving = false;
}
}
async function remove() {
if (!confirm('이 문제를 삭제합니다.\n풀이 기록(attempts)은 보존되지만 복습 후보에서는 제외됩니다.')) return;
deleting = true;
try {
await api(`/study-questions/${questionId}`, { method: 'DELETE' });
addToast('success', '삭제됨');
goto(`/study/topics/${topicId}`);
} catch (err) {
addToast('error', err.detail || '삭제 실패');
deleting = false;
}
}
</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">문제 편집</span>
</div>
<!-- 정답 변경 → attempts 재계산 안 됨 안내 -->
<div class="mb-3 p-3 rounded-lg border border-warning/40 bg-warning/5 text-xs text-text flex items-start gap-2">
<AlertCircle size={14} class="text-warning shrink-0 mt-0.5" />
<div>
정답을 수정해도 기존 풀이 기록은 변경되지 않습니다.<br />
풀이 시점의 정답·정오답이 그대로 보존됩니다 ({stats.attempt_count}회 누적, 정답 {stats.correct_count} / 오답 {stats.wrong_count}).
</div>
</div>
{#if loading}
<Skeleton h="h-64" rounded="lg" />
{:else}
<Card class="mb-3">
{#snippet children()}
<div class="p-4 flex flex-col gap-3">
<Textarea label="문제 본문" bind:value={q_text} rows={3} />
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<TextInput label="① 1번 보기" bind:value={c1} />
<TextInput label="② 2번 보기" bind:value={c2} />
<TextInput label="③ 3번 보기" bind:value={c3} />
<TextInput label="④ 4번 보기" bind:value={c4} />
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs text-dim">정답 번호</label>
<div class="flex items-center gap-2">
{#each [1, 2, 3, 4] as n}
<button
type="button"
onclick={() => (correct = n)}
class="px-4 py-2 rounded border text-sm font-medium transition-colors
{correct === n ? 'bg-accent text-white border-accent' : 'bg-surface text-dim border-default hover:text-text'}"
aria-pressed={correct === n}
>{n}</button>
{/each}
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 pt-2 border-t border-default">
<TextInput label="과목" bind:value={subject} />
<TextInput label="범위" bind:value={scope} />
<TextInput label="시험명" bind:value={exam_name} />
<TextInput label="회차" bind:value={exam_round} />
</div>
<Textarea label="해설" bind:value={explanation} rows={2} />
<TextInput label="출처/메모" bind:value={source_note} />
<label class="flex items-center gap-2 text-xs text-dim cursor-pointer">
<input type="checkbox" bind:checked={is_active} />
<span>출제 활성 (체크 해제 시 복습 후보에서 일시 제외, 데이터는 유지)</span>
</label>
</div>
{/snippet}
</Card>
<div class="flex items-center justify-between gap-2 flex-wrap">
<div class="flex gap-2">
<Button href={`/study/topics/${topicId}`} variant="ghost" icon={ArrowLeft}>주제로</Button>
<Button onclick={remove} loading={deleting} variant="ghost" icon={Trash2}>삭제</Button>
</div>
<Button onclick={save} loading={saving} icon={Save}>저장</Button>
</div>
{/if}
</div>
@@ -0,0 +1,199 @@
<script>
/**
* /study/topics/[id]/questions/new — 문제 입력 페이지.
*
* 하루 100문제 입력 시나리오에 맞춰 빠른 반복 입력 UX:
* - "저장 후 계속 입력" → subject/scope/exam_name/exam_round 유지, 본문·보기·정답만 초기화
* - sessionStorage 캐시: 페이지 새로고침해도 분류 필드 유지
* - 입력 검증 실패 시 토스트
*/
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, Save, Repeat } from 'lucide-svelte';
import Button from '$lib/components/ui/Button.svelte';
import Card from '$lib/components/ui/Card.svelte';
import TextInput from '$lib/components/ui/TextInput.svelte';
import Textarea from '$lib/components/ui/Textarea.svelte';
let topicId = $derived(Number($page.params.id));
let topicName = $state('');
// 입력 필드
let q_text = $state('');
let c1 = $state('');
let c2 = $state('');
let c3 = $state('');
let c4 = $state('');
let correct = $state(1);
// persistent (sessionStorage 동기화)
let subject = $state('');
let scope = $state('');
let exam_name = $state('');
let exam_round = $state('');
// 한 번 입력 후 유지 안 함
let explanation = $state('');
let source_note = $state('');
let saving = $state(false);
const STORAGE_KEY = $derived(`study_q_persist_${topicId}`);
function loadPersist() {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (raw) {
const p = JSON.parse(raw);
subject = p.subject ?? '';
scope = p.scope ?? '';
exam_name = p.exam_name ?? '';
exam_round = p.exam_round ?? '';
}
} catch {}
}
function savePersist() {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ subject, scope, exam_name, exam_round }));
} catch {}
}
async function loadTopic() {
try {
const t = await api(`/study-topics/${topicId}`);
topicName = t?.topic?.name ?? '';
} catch {}
}
onMount(() => {
loadPersist();
loadTopic();
});
function validate() {
if (!q_text.trim()) { addToast('error', '문제 본문을 입력하세요'); return false; }
if (!c1.trim() || !c2.trim() || !c3.trim() || !c4.trim()) {
addToast('error', '1~4번 보기를 모두 입력하세요'); return false;
}
if (![1, 2, 3, 4].includes(Number(correct))) {
addToast('error', '정답은 1~4 중 하나'); return false;
}
return true;
}
function clearForCont() {
q_text = ''; c1 = ''; c2 = ''; c3 = ''; c4 = ''; correct = 1;
explanation = ''; source_note = '';
// subject/scope/exam_name/exam_round 는 유지
}
async function save(continueAfter) {
if (!validate()) return;
saving = true;
savePersist();
try {
const body = {
question_text: q_text.trim(),
choice_1: c1.trim(),
choice_2: c2.trim(),
choice_3: c3.trim(),
choice_4: c4.trim(),
correct_choice: Number(correct),
subject: subject || null,
scope: scope || null,
exam_name: exam_name || null,
exam_round: exam_round || null,
explanation: explanation || null,
source_note: source_note || null,
};
await api(`/study-topics/${topicId}/questions`, {
method: 'POST',
body: JSON.stringify(body),
});
addToast('success', '문제 저장됨');
if (continueAfter) {
clearForCont();
// 본문 textarea 로 포커스 이동
setTimeout(() => document.getElementById('q-text')?.focus(), 0);
} else {
goto(`/study/topics/${topicId}`);
}
} catch (err) {
addToast('error', err.detail || '저장 실패');
} finally {
saving = false;
}
}
</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">새 문제</span>
</div>
<Card class="mb-3">
{#snippet children()}
<div class="p-4 flex flex-col gap-3">
<Textarea
label="문제 본문"
bind:value={q_text}
rows={3}
placeholder="예: 다음 중 가연성 가스의 폭발범위에 대한 설명으로 옳은 것은?"
id="q-text"
/>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<TextInput label="① 1번 보기" bind:value={c1} />
<TextInput label="② 2번 보기" bind:value={c2} />
<TextInput label="③ 3번 보기" bind:value={c3} />
<TextInput label="④ 4번 보기" bind:value={c4} />
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs text-dim">정답 번호</label>
<div class="flex items-center gap-2">
{#each [1, 2, 3, 4] as n}
<button
type="button"
onclick={() => (correct = n)}
class="px-4 py-2 rounded border text-sm font-medium transition-colors
{correct === n ? 'bg-accent text-white border-accent' : 'bg-surface text-dim border-default hover:text-text'}"
aria-pressed={correct === n}
>{n}</button>
{/each}
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 pt-2 border-t border-default">
<TextInput label="과목 (유지)" bind:value={subject} placeholder="예: 연소공학" />
<TextInput label="범위 (유지)" bind:value={scope} placeholder="예: 폭발범위" />
<TextInput label="시험명 (유지)" bind:value={exam_name} placeholder="예: 가스기사" />
<TextInput label="회차 (유지)" bind:value={exam_round} placeholder="예: 2024년 1회" />
</div>
<div class="text-[11px] text-dim -mt-2">"유지" 표시 필드는 다음 입력에도 그대로 유지됩니다 (sessionStorage).</div>
<Textarea label="해설 (선택)" bind:value={explanation} rows={2} placeholder="정답 근거 요약" />
<TextInput label="출처/메모 (선택)" bind:value={source_note} placeholder="예: 산업안전기사 2023 1회 기출 7번" />
</div>
{/snippet}
</Card>
<div class="flex items-center justify-between gap-2 flex-wrap">
<Button href={`/study/topics/${topicId}`} variant="ghost" icon={ArrowLeft}>주제로</Button>
<div class="flex gap-2">
<Button onclick={() => save(false)} loading={saving} variant="ghost" icon={Save}>저장</Button>
<Button onclick={() => save(true)} loading={saving} icon={Repeat}>저장 계속 입력</Button>
</div>
</div>
</div>
@@ -0,0 +1,285 @@
<script>
/**
* /study/topics/[id]/review — 복습모드.
*
* default = 과목별 target_per_subject(=20) 무작위 균등 추출.
* 정답·해설은 답 제출 시점에만 노출 (선노출 금지).
* wrong_only = 가장 최근 attempt 가 오답인 문제만 (latest-wrong, ever-wrong 아님).
* 자동 진행 안 함 — 사용자가 [다음 문제] 클릭으로 넘김.
*/
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import { ArrowLeft, Play, CheckCircle2, XCircle, RotateCcw } from 'lucide-svelte';
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 TextInput from '$lib/components/ui/TextInput.svelte';
let topicId = $derived(Number($page.params.id));
let topicName = $state('');
// 진입 화면 옵션
let mode = $state('start'); // 'start' | 'playing' | 'done'
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 selected = $state(null); // 1~4
let submitted = $state(null); // {is_correct, correct_choice, explanation, stats}
let submitting = $state(false);
// 세션 누적
let answered = $state(0);
let correctCount = $state(0);
let loading = $state(true);
async function loadTopic() {
try {
const t = await api(`/study-topics/${topicId}`);
topicName = t?.topic?.name ?? '';
} catch {}
}
onMount(async () => {
loading = true;
await loadTopic();
loading = false;
});
async function start() {
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;
answered = 0;
correctCount = 0;
selected = null;
submitted = null;
if (questions.length === 0) {
addToast('info', '출제할 문제가 없습니다.');
} else {
mode = 'playing';
}
} catch (err) {
addToast('error', err.detail || '복습 시작 실패');
} finally {
loading = false;
}
}
async function submit() {
if (selected == null) {
addToast('error', '답을 선택하세요'); return;
}
submitting = true;
try {
const q = questions[cursor];
const res = await api(`/study-questions/${q.id}/attempt`, {
method: 'POST',
body: JSON.stringify({ selected_choice: Number(selected) }),
});
submitted = res;
answered += 1;
if (res.is_correct) correctCount += 1;
} catch (err) {
addToast('error', err.detail || '제출 실패');
} finally {
submitting = false;
}
}
function next() {
if (cursor + 1 >= questions.length) {
mode = 'done';
return;
}
cursor += 1;
selected = null;
submitted = null;
}
function restart() {
mode = 'start';
questions = [];
submitted = null;
selected = null;
cursor = 0;
answered = 0;
correctCount = 0;
}
let currentQ = $derived(questions[cursor] ?? null);
let progress = $derived(questions.length > 0 ? `${cursor + 1} / ${questions.length}` : '0 / 0');
</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">복습</span>
</div>
{#if loading}
<Skeleton h="h-32" rounded="lg" />
{:else if mode === 'start'}
<Card>
{#snippet children()}
<div class="p-5 flex flex-col gap-4">
<h1 class="text-base font-semibold text-text">복습 시작</h1>
<p class="text-xs text-dim">
기본은 과목별 {optTarget}문제씩 무작위 균등 추출. 한 과목이 부족하면 가용한 만큼만 출제됩니다.
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<TextInput label="과목 (선택, 비워두면 전체 균등)" bind:value={optSubject} placeholder="예: 연소공학" />
</div>
<div>
<label class="flex flex-col gap-1.5">
<span class="text-xs text-dim">과목당 문제 수</span>
<input
type="number"
bind:value={optTarget}
min="1"
max="100"
class="px-3 py-2 bg-surface border border-default rounded text-sm text-text outline-none focus:border-accent"
/>
</label>
</div>
</div>
<label class="flex items-center gap-2 text-xs text-text cursor-pointer">
<input type="checkbox" bind:checked={optWrongOnly} />
<span>오답만 (가장 최근 attempt 가 오답인 문제만)</span>
</label>
<div class="flex gap-2 justify-end">
<Button href={`/study/topics/${topicId}`} variant="ghost" icon={ArrowLeft}>주제로</Button>
<Button onclick={start} icon={Play}>시작</Button>
</div>
</div>
{/snippet}
</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>
{#if currentQ.subject}<span>{currentQ.subject}</span>{/if}
{#if currentQ.scope}<span> · {currentQ.scope}</span>{/if}
</span>
<span>정답 {correctCount} / 풀이 {answered}</span>
</div>
<Card>
{#snippet children()}
<div class="p-5 flex flex-col gap-4">
<p class="text-base text-text whitespace-pre-line">{currentQ.question_text}</p>
<div class="flex flex-col gap-2">
{#each currentQ.choices as ch (ch.number)}
{@const isSelected = selected === ch.number}
{@const correctAfter = submitted && submitted.correct_choice === ch.number}
{@const wrongAfter = submitted && submitted.selected_choice === ch.number && !submitted.is_correct}
<button
type="button"
disabled={submitted !== null}
onclick={() => (selected = ch.number)}
class="text-left p-3 rounded-lg border transition-colors flex items-start gap-3
{submitted === null
? (isSelected ? 'border-accent bg-accent/5 text-text' : 'border-default bg-surface text-text hover:border-accent/40')
: (correctAfter ? 'border-success bg-success/10 text-text'
: wrongAfter ? '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 text-sm">{ch.text}</span>
{#if submitted !== null && correctAfter}
<CheckCircle2 size={16} class="text-success shrink-0" />
{:else if submitted !== null && wrongAfter}
<XCircle size={16} class="text-error shrink-0" />
{/if}
</button>
{/each}
</div>
{#if submitted === null}
<div class="flex justify-end">
<Button onclick={submit} loading={submitting} disabled={selected == null}>제출</Button>
</div>
{:else}
<div class="p-3 rounded border border-default bg-bg/40 text-sm text-text">
<div class="flex items-center gap-2 mb-2">
{#if submitted.is_correct}
<CheckCircle2 size={16} class="text-success" />
<span class="font-semibold text-success">정답</span>
{:else}
<XCircle size={16} class="text-error" />
<span class="font-semibold text-error">오답</span>
<span class="text-dim text-xs">정답: {submitted.correct_choice}</span>
{/if}
</div>
{#if submitted.explanation}
<p class="text-xs text-dim whitespace-pre-line">{submitted.explanation}</p>
{/if}
<div class="text-[11px] text-dim mt-2">
누적 {submitted.stats.attempt_count}회 · 정답 {submitted.stats.correct_count} · 오답 {submitted.stats.wrong_count}
</div>
</div>
<div class="flex justify-end">
<Button onclick={next}>{cursor + 1 >= questions.length ? '결과 보기' : '다음 문제'}</Button>
</div>
{/if}
</div>
{/snippet}
</Card>
{:else if mode === 'done'}
<Card>
{#snippet children()}
<div class="p-6 flex flex-col items-center gap-4 text-center">
<h1 class="text-xl font-semibold text-text">복습 완료</h1>
<div class="text-sm text-dim">
{answered}문제 · <span class="text-success">정답 {correctCount}</span> · <span class="text-error">오답 {answered - correctCount}</span>
{#if answered > 0}
<span> · 정답률 {Math.round((correctCount / answered) * 100)}%</span>
{/if}
</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 gap-2 mt-2">
<Button href={`/study/topics/${topicId}`} variant="ghost" icon={ArrowLeft}>주제로</Button>
<Button onclick={restart} icon={RotateCcw}>다시 시작</Button>
</div>
</div>
{/snippet}
</Card>
{/if}
</div>
+35
View File
@@ -0,0 +1,35 @@
-- 186_study_questions.sql (1/5)
-- study_topic 아래 4지선다 문제은행. 기사시험 필기 대비.
--
-- 핵심:
-- - study_topic_id 1:N (한 주제에 N 문제, 워크스페이스 컨테이너 PR-1 연장).
-- - subject/scope 는 느슨한 분류 (강한 enum 미사용). 자유 텍스트 + 자동완성은 후속.
-- - correct_choice 는 SMALLINT CHECK 1~4. 객관식 외 형식(주관식·OX·다답) 은 본 PR 범위 밖.
-- - is_active 는 향후 출제 제외 토글 (예: 잘못 입력한 문제 임시 비활성). soft delete(deleted_at)와 별개.
-- - polymorphic study_topic_items 영구 금지 (PR-1 가드레일). 자산 타입별 조인 테이블 없음 — 본 테이블은 study_topic_id 직접 FK.
CREATE TABLE IF NOT EXISTS study_questions (
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,
question_text TEXT NOT NULL,
choice_1 TEXT NOT NULL,
choice_2 TEXT NOT NULL,
choice_3 TEXT NOT NULL,
choice_4 TEXT NOT NULL,
correct_choice SMALLINT NOT NULL CHECK (correct_choice BETWEEN 1 AND 4),
subject VARCHAR(120),
scope VARCHAR(200),
exam_name VARCHAR(120),
exam_round VARCHAR(120),
explanation TEXT,
source_note TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
@@ -0,0 +1,7 @@
-- 187_study_questions_idx_topic.sql (2/5)
-- 토픽 단위 목록·복습 추출의 기본 인덱스. soft-deleted 제외 partial index.
-- subject/scope 보조 인덱스는 운영 후 EXPLAIN 보고 추가 (PR-2 1차 보류).
CREATE INDEX IF NOT EXISTS idx_study_questions_topic
ON study_questions (study_topic_id, created_at DESC, id)
WHERE deleted_at IS NULL;
@@ -0,0 +1,24 @@
-- 188_study_question_attempts.sql (3/5)
-- 복습모드에서 답 제출할 때마다 1행 누적. 통계·오답노트의 토대.
--
-- FK 정책 (이력 보존 원칙):
-- - study_question_id ON DELETE RESTRICT — 문제 삭제는 API 에서 soft delete 만 수행.
-- hard delete 실수로 풀이 이력이 사라지면 안 되기 때문에 DB 레벨에서도 막는다. CASCADE 금지.
-- - study_topic_id ON DELETE CASCADE — 토픽 삭제는 워크스페이스 전체 폐기 의미.
-- 단 PR-1 의 토픽 삭제도 soft delete 라 실 cascade 발생은 hard delete 시.
-- - user_id ON DELETE CASCADE — 사용자 탈퇴 정리.
--
-- correct_choice 컬럼은 attempt 시점 정답을 그대로 보존 — 문제 편집으로 정답 변경되어도
-- 기존 attempt 의 is_correct 는 재계산 안 함 (기록은 시점 사실).
-- selected_choice/correct_choice 모두 SMALLINT CHECK 1~4.
CREATE TABLE IF NOT EXISTS study_question_attempts (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
study_question_id BIGINT NOT NULL REFERENCES study_questions(id) ON DELETE RESTRICT,
study_topic_id BIGINT NOT NULL REFERENCES study_topics(id) ON DELETE CASCADE,
selected_choice SMALLINT NOT NULL CHECK (selected_choice BETWEEN 1 AND 4),
correct_choice SMALLINT NOT NULL CHECK (correct_choice BETWEEN 1 AND 4),
is_correct BOOLEAN NOT NULL,
answered_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@@ -0,0 +1,6 @@
-- 189_study_question_attempts_idx_question.sql (4/5)
-- 문제별 시도 이력 조회 (가장 최근 attempt, 누적 통계).
-- DISTINCT ON (study_question_id) ORDER BY answered_at DESC 패턴에 사용 — wrong_only=latest_wrong 동작의 핵심.
CREATE INDEX IF NOT EXISTS idx_study_question_attempts_question
ON study_question_attempts (study_question_id, answered_at DESC);
@@ -0,0 +1,6 @@
-- 190_study_question_attempts_idx_user_topic.sql (5/5)
-- 통계·오답노트의 기본 인덱스 — 한 사용자의 한 토픽 안에서 시간순 조회.
-- 후속 PR (오답노트/취약 과목 리포트) 에서 GROUP BY 쿼리도 본 인덱스를 prefix 로 활용.
CREATE INDEX IF NOT EXISTS idx_study_question_attempts_user_topic
ON study_question_attempts (user_id, study_topic_id, answered_at DESC);