From 4b7156061ef15f6fa979989b9f13a2eb46ccd37f Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Tue, 28 Apr 2026 08:00:37 +0900 Subject: [PATCH] =?UTF-8?q?feat(study):=20=EB=AC=B8=EC=A0=9C=EC=9D=80?= =?UTF-8?q?=ED=96=89=20+=20=EB=B3=B5=EC=8A=B5=EB=AA=A8=EB=93=9C=20(study?= =?UTF-8?q?=5Fquestions)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/api/study_questions.py | 650 ++++++++++++++++++ app/api/study_topics.py | 129 +++- app/main.py | 3 + app/models/study_question.py | 87 +++ app/models/study_topic.py | 5 +- frontend/src/routes/study/topics/+page.svelte | 3 +- .../src/routes/study/topics/[id]/+page.svelte | 50 +- .../[id]/questions/[qid]/edit/+page.svelte | 195 ++++++ .../topics/[id]/questions/new/+page.svelte | 199 ++++++ .../study/topics/[id]/review/+page.svelte | 285 ++++++++ migrations/186_study_questions.sql | 35 + migrations/187_study_questions_idx_topic.sql | 7 + migrations/188_study_question_attempts.sql | 24 + ...9_study_question_attempts_idx_question.sql | 6 + ...study_question_attempts_idx_user_topic.sql | 6 + 15 files changed, 1678 insertions(+), 6 deletions(-) create mode 100644 app/api/study_questions.py create mode 100644 app/models/study_question.py create mode 100644 frontend/src/routes/study/topics/[id]/questions/[qid]/edit/+page.svelte create mode 100644 frontend/src/routes/study/topics/[id]/questions/new/+page.svelte create mode 100644 frontend/src/routes/study/topics/[id]/review/+page.svelte create mode 100644 migrations/186_study_questions.sql create mode 100644 migrations/187_study_questions_idx_topic.sql create mode 100644 migrations/188_study_question_attempts.sql create mode 100644 migrations/189_study_question_attempts_idx_question.sql create mode 100644 migrations/190_study_question_attempts_idx_user_topic.sql diff --git a/app/api/study_questions.py b/app/api/study_questions.py new file mode 100644 index 0000000..31b7c58 --- /dev/null +++ b/app/api/study_questions.py @@ -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, + ) diff --git a/app/api/study_topics.py b/app/api/study_topics.py index 7b908f5..6477b97 100644 --- a/app/api/study_topics.py +++ b/app/api/study_topics.py @@ -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, ) diff --git a/app/main.py b/app/main.py index 4b124ba..750f1b6 100644 --- a/app/main.py +++ b/app/main.py @@ -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"]) diff --git a/app/models/study_question.py b/app/models/study_question.py new file mode 100644 index 0000000..609ae56 --- /dev/null +++ b/app/models/study_question.py @@ -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") diff --git a/app/models/study_topic.py b/app/models/study_topic.py index 26e4ada..18d25d8 100644 --- a/app/models/study_topic.py +++ b/app/models/study_topic.py @@ -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): diff --git a/frontend/src/routes/study/topics/+page.svelte b/frontend/src/routes/study/topics/+page.svelte index 5e45537..0e09bb4 100644 --- a/frontend/src/routes/study/topics/+page.svelte +++ b/frontend/src/routes/study/topics/+page.svelte @@ -220,9 +220,10 @@ {#if t.description}

{t.description}

{/if} -
+
필기 {t.session_count} 자료 {t.document_count} + 문제 {t.question_count ?? 0} {fmtDate(t.created_at)}
diff --git a/frontend/src/routes/study/topics/[id]/+page.svelte b/frontend/src/routes/study/topics/[id]/+page.svelte index 8dd1fdf..a47a298 100644 --- a/frontend/src/routes/study/topics/[id]/+page.svelte +++ b/frontend/src/routes/study/topics/[id]/+page.svelte @@ -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 @@
{/if} + + +
+
+

+ 문제 + {detail.sections.questions?.length ?? 0} +

+
+ + {#if (detail.sections.questions?.length ?? 0) > 0} + + {/if} +
+
+ {#if (detail.sections.questions?.length ?? 0) === 0} +
+ 이 주제에 입력된 문제가 없습니다. "새 문제" 로 4지선다 객관식을 추가하면 복습모드에서 무작위 출제됩니다. +
+ {:else} +
+ {#each detail.sections.questions as q (q.id)} +
+
+
{q.question_text}
+
+ {#if q.subject}{q.subject}{/if} + {#if q.scope}· {q.scope}{/if} + {#if q.exam_round}· {q.exam_round}{/if} + {#if q.attempt_count > 0} + + {#if q.last_correct === true} + + {:else if q.last_correct === false} + + {/if} + {q.attempt_count}회 + + {/if} + {#if !q.is_active}· 비활성{/if} +
+
+ +
+ {/each} +
+ {/if} +
{/if} diff --git a/frontend/src/routes/study/topics/[id]/questions/[qid]/edit/+page.svelte b/frontend/src/routes/study/topics/[id]/questions/[qid]/edit/+page.svelte new file mode 100644 index 0000000..b045325 --- /dev/null +++ b/frontend/src/routes/study/topics/[id]/questions/[qid]/edit/+page.svelte @@ -0,0 +1,195 @@ + + +문제 편집 — {topicName || '주제'} + +
+
+ 공부 + / + 주제 + / + {topicName || '...'} + / + 문제 편집 +
+ + +
+ +
+ 정답을 수정해도 기존 풀이 기록은 변경되지 않습니다.
+ 풀이 시점의 정답·정오답이 그대로 보존됩니다 ({stats.attempt_count}회 누적, 정답 {stats.correct_count} / 오답 {stats.wrong_count}). +
+
+ + {#if loading} + + {:else} + + {#snippet children()} +
+