From 8803e6a0fdff3495cd77e46fb32ec50368317dd3 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Tue, 28 Apr 2026 09:31:06 +0900 Subject: [PATCH] =?UTF-8?q?feat(study):=20=EC=8B=9C=ED=97=98=C2=B7?= =?UTF-8?q?=ED=9A=8C=EC=B0=A8=C2=B7=EB=AC=B8=ED=95=AD=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=20(PR-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기사시험 회차별 100문제 채워가기 시나리오. 문제 입력 페이지를 단순 폼에서 "회차 진행률 추적·재개" 도구로 보강. 데이터 모델 (migrations 195~197): - study_topics: exam_round_size INT CHECK 1~300 (회차당 문항 수, NULL=미설정) + exam_subjects JSONB DEFAULT '[]' (과목 리스트, 입력 페이지 드롭다운 옵션) - study_questions: exam_question_number SMALLINT CHECK >0 (회차 안 문항 번호) - partial idx (study_topic_id, exam_round, exam_question_number) WHERE deleted_at IS NULL AND exam_round IS NOT NULL — 회차별 max+count 고속화 백엔드: - POST /questions: exam_round 명시 + exam_question_number 미명시 시 서버가 같은 토픽·회차의 max+1 자동 채움 - 신규 GET /api/study-topics/{id}/exam-rounds: 회차별 진행률 집계 {exam_round_size, items: [{exam_round, question_count, max_question_number, next_question_number, is_complete}]} - StudyTopic Create/Update/Response/Meta 에 exam_round_size·exam_subjects - StudyQuestion Create/Update/Response 에 exam_question_number - exam_question_number 변경은 embedding stale 트리거에서 제외 (의미 영향 없음) 프론트: - 토픽 생성/편집 모달: "시험 정보" 섹션 (회차당 문항 수 + 과목 리스트 +추가/제거 칩) - /study/topics/[id]/exam-rounds 신규 페이지: 회차 카드 + 진행 바 + [N번부터 이어서] 버튼 + [새 회차 시작] 모달 - 통합뷰 문제 섹션 헤더에 [회차 보기] 진입점 - /questions/new 페이지 전면 개편: - 시험명 = topic.name 자동 prefill - 과목 드롭다운 (topic.exam_subjects + 기존 distinct, "직접 입력" 토글) - 회차 드롭다운 (기존 distinct + "새 회차") - 문항 번호 자동 (회차 선택 시 next_question_number, 새 회차 = 1) - 진행률 바 (현재/exam_round_size) - 출처/메모 자동 합성 "회차 N번" (수정 가능) - "저장 후 계속 입력" → 본문/보기/정답 reset, 회차 유지, 문항 +1 - 회차 변경 감지 시 문항 번호 1로 reset - exam_round_size 도달 시 회차 강조 + "저장 후 계속 입력" 비활성 - query string ?exam_round=&start_qnum= 지원 (회차 목록에서 재개 진입) Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/study_questions.py | 26 ++ app/api/study_topics.py | 94 +++++ app/models/study_question.py | 3 + app/models/study_topic.py | 5 + frontend/src/routes/study/topics/+page.svelte | 108 +++++- .../src/routes/study/topics/[id]/+page.svelte | 4 +- .../topics/[id]/exam-rounds/+page.svelte | 168 +++++++++ .../topics/[id]/questions/new/+page.svelte | 335 ++++++++++++++---- migrations/195_study_topic_exam_meta.sql | 16 + migrations/196_study_questions_exam_qnum.sql | 14 + .../197_study_questions_exam_round_idx.sql | 8 + 11 files changed, 719 insertions(+), 62 deletions(-) create mode 100644 frontend/src/routes/study/topics/[id]/exam-rounds/+page.svelte create mode 100644 migrations/195_study_topic_exam_meta.sql create mode 100644 migrations/196_study_questions_exam_qnum.sql create mode 100644 migrations/197_study_questions_exam_round_idx.sql diff --git a/app/api/study_questions.py b/app/api/study_questions.py index 2ae5d93..8590272 100644 --- a/app/api/study_questions.py +++ b/app/api/study_questions.py @@ -67,6 +67,8 @@ class StudyQuestionCreate(BaseModel): 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) + # PR-6: 회차 안 문항 번호. 미명시 + exam_round 명시 시 서버가 max+1 자동 채움. + exam_question_number: int | None = Field(default=None, ge=1) explanation: str | None = None source_note: str | None = None is_active: bool = True @@ -83,6 +85,7 @@ class StudyQuestionUpdate(BaseModel): 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) + exam_question_number: int | None = Field(default=None, ge=1) explanation: str | None = None source_note: str | None = None is_active: bool | None = None @@ -112,6 +115,8 @@ class StudyQuestionResponse(BaseModel): explanation: str | None source_note: str | None is_active: bool + # PR-6: 회차 안 문항 번호 + exam_question_number: int | None = None # PR-3: AI 풀이 상태 (편집 화면에서 사용). 본문은 별도 GET /ai-explanation 으로 ai_explanation_status: str = "none" ai_explanation_generated_at: datetime | None = None @@ -378,6 +383,22 @@ async def create_question_in_topic( topic = await session.get(StudyTopic, topic_id) _verify_topic_ownership(topic, user) + # PR-6: exam_question_number 미명시 + exam_round 명시 시 서버가 max+1 자동 채움. + # 개인 사용 환경 race 부담 적어 단순 SELECT max 사용. 동시 입력 빈번해지면 향후 + # SELECT FOR UPDATE 또는 토픽 단위 advisory lock 으로 강화 검토. + qnum = body.exam_question_number + if qnum is None and body.exam_round: + max_row = await session.execute( + select(func.coalesce(func.max(StudyQuestion.exam_question_number), 0)) + .where( + StudyQuestion.user_id == user.id, + StudyQuestion.study_topic_id == topic_id, + StudyQuestion.exam_round == body.exam_round, + StudyQuestion.deleted_at.is_(None), + ) + ) + qnum = int(max_row.scalar() or 0) + 1 + q = StudyQuestion( user_id=user.id, study_topic_id=topic_id, @@ -391,6 +412,7 @@ async def create_question_in_topic( scope=body.scope, exam_name=body.exam_name, exam_round=body.exam_round, + exam_question_number=qnum, explanation=body.explanation, source_note=body.source_note, is_active=body.is_active, @@ -416,6 +438,7 @@ async def create_question_in_topic( explanation=q.explanation, source_note=q.source_note, is_active=q.is_active, + exam_question_number=q.exam_question_number, ai_explanation_status=q.ai_explanation_status, ai_explanation_generated_at=q.ai_explanation_generated_at, ai_explanation_model=q.ai_explanation_model, @@ -610,6 +633,7 @@ async def get_question( explanation=q.explanation, source_note=q.source_note, is_active=q.is_active, + exam_question_number=q.exam_question_number, ai_explanation_status=q.ai_explanation_status, ai_explanation_generated_at=q.ai_explanation_generated_at, ai_explanation_model=q.ai_explanation_model, @@ -635,6 +659,7 @@ async def update_question( SIMPLE_FIELDS = { "question_text", "choice_1", "choice_2", "choice_3", "choice_4", "correct_choice", "subject", "scope", "exam_name", "exam_round", + "exam_question_number", "explanation", "source_note", "is_active", } for fname in SIMPLE_FIELDS & fields_set: @@ -676,6 +701,7 @@ async def update_question( explanation=q.explanation, source_note=q.source_note, is_active=q.is_active, + exam_question_number=q.exam_question_number, ai_explanation_status=q.ai_explanation_status, ai_explanation_generated_at=q.ai_explanation_generated_at, ai_explanation_model=q.ai_explanation_model, diff --git a/app/api/study_topics.py b/app/api/study_topics.py index f994655..1c90bae 100644 --- a/app/api/study_topics.py +++ b/app/api/study_topics.py @@ -53,6 +53,9 @@ class StudyTopicCreate(BaseModel): color: str | None = Field(default=None, max_length=20) study_type: str | None = Field(default=None, max_length=40) sort_order: int = 0 + # PR-6: 시험 메타 + exam_round_size: int | None = Field(default=None, ge=1, le=300) + exam_subjects: list[str] = [] class StudyTopicUpdate(BaseModel): @@ -61,6 +64,9 @@ class StudyTopicUpdate(BaseModel): color: str | None = Field(default=None, max_length=20) study_type: str | None = Field(default=None, max_length=40) sort_order: int | None = None + # PR-6: 시험 메타 + exam_round_size: int | None = Field(default=None, ge=1, le=300) + exam_subjects: list[str] | None = None class StudyTopicResponse(BaseModel): @@ -75,6 +81,9 @@ class StudyTopicResponse(BaseModel): session_count: int = 0 document_count: int = 0 question_count: int = 0 + # PR-6: 시험 메타 + exam_round_size: int | None = None + exam_subjects: list[str] = [] created_at: datetime updated_at: datetime @@ -165,6 +174,9 @@ class StudyTopicMeta(BaseModel): color: str | None study_type: str | None sort_order: int + # PR-6: 시험 메타 + exam_round_size: int | None = None + exam_subjects: list[str] = [] created_at: datetime updated_at: datetime @@ -218,6 +230,8 @@ def _meta_from_topic(t: StudyTopic) -> StudyTopicMeta: color=t.color, study_type=t.study_type, sort_order=t.sort_order, + exam_round_size=t.exam_round_size, + exam_subjects=t.exam_subjects or [], created_at=t.created_at, updated_at=t.updated_at, ) @@ -303,6 +317,8 @@ async def list_study_topics( session_count=int(sc), document_count=int(dc), question_count=int(qc), + exam_round_size=t.exam_round_size, + exam_subjects=t.exam_subjects or [], created_at=t.created_at, updated_at=t.updated_at, ) @@ -311,6 +327,72 @@ async def list_study_topics( return StudyTopicListResponse(items=items, total=total) +class ExamRoundProgress(BaseModel): + exam_round: str + question_count: int + max_question_number: int # 0 = exam_question_number 가 모두 NULL + next_question_number: int | None # 도달 시 None + is_complete: bool + + +class ExamRoundsResponse(BaseModel): + exam_round_size: int | None # 토픽 메타. NULL = 미설정 + items: list[ExamRoundProgress] + + +@router.get("/{topic_id}/exam-rounds", response_model=ExamRoundsResponse) +async def list_exam_rounds( + topic_id: int, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """토픽 안 회차별 진행률 집계. 진행률 표시 + 재개 UX 용. + + is_complete = (exam_round_size IS NOT NULL AND question_count >= exam_round_size). + next_question_number = max + 1 (도달 시 NULL). + """ + topic = await session.get(StudyTopic, topic_id) + _verify_topic_ownership(topic, user) + + from models.study_question import StudyQuestion as _SQ + + rows = ( + await session.execute( + select( + _SQ.exam_round, + func.count().label("question_count"), + func.coalesce(func.max(_SQ.exam_question_number), 0).label("max_qnum"), + ) + .where( + _SQ.user_id == user.id, + _SQ.study_topic_id == topic_id, + _SQ.deleted_at.is_(None), + _SQ.exam_round.is_not(None), + ) + .group_by(_SQ.exam_round) + .order_by(_SQ.exam_round.desc()) + ) + ).all() + + size = topic.exam_round_size + items: list[ExamRoundProgress] = [] + for r in rows: + cnt = int(r.question_count) + mx = int(r.max_qnum) + is_complete = bool(size is not None and cnt >= size) + # 도달했으면 next None, 아니면 max+1 (max=0 이면 1번부터) + next_n = None if is_complete else (mx + 1 if mx > 0 else 1) + items.append(ExamRoundProgress( + exam_round=r.exam_round, + question_count=cnt, + max_question_number=mx, + next_question_number=next_n, + is_complete=is_complete, + )) + + return ExamRoundsResponse(exam_round_size=size, items=items) + + @router.get("/by-document/{document_id}", response_model=list[StudyTopicMeta]) async def list_topics_for_document( document_id: int, @@ -351,6 +433,8 @@ async def create_study_topic( color=body.color, study_type=body.study_type, sort_order=body.sort_order, + exam_round_size=body.exam_round_size, + exam_subjects=body.exam_subjects or [], ) session.add(topic) try: @@ -369,6 +453,9 @@ async def create_study_topic( sort_order=topic.sort_order, session_count=0, document_count=0, + question_count=0, + exam_round_size=topic.exam_round_size, + exam_subjects=topic.exam_subjects or [], created_at=topic.created_at, updated_at=topic.updated_at, ) @@ -570,6 +657,11 @@ async def update_study_topic( topic.name = body.name.strip() for fname in {"description", "color", "study_type", "sort_order"} & fields_set: setattr(topic, fname, getattr(body, fname)) + # PR-6: 시험 메타. exam_subjects 가 None 이면 (PATCH 미명시) 기존 유지, 빈 배열은 명시적 클리어 + if "exam_round_size" in fields_set: + topic.exam_round_size = body.exam_round_size + if "exam_subjects" in fields_set and body.exam_subjects is not None: + topic.exam_subjects = body.exam_subjects topic.updated_at = datetime.now(timezone.utc) try: @@ -610,6 +702,8 @@ async def update_study_topic( session_count=int(sc), document_count=int(dc), question_count=int(qc), + exam_round_size=topic.exam_round_size, + exam_subjects=topic.exam_subjects or [], created_at=topic.created_at, updated_at=topic.updated_at, ) diff --git a/app/models/study_question.py b/app/models/study_question.py index e930a9e..cdb2ad9 100644 --- a/app/models/study_question.py +++ b/app/models/study_question.py @@ -43,6 +43,9 @@ class StudyQuestion(Base): is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + # PR-6: 회차 안 문항 번호 (1~exam_round_size). NULL 허용 — 기존 행 + 회차 미설정 입력 + exam_question_number: Mapped[int | None] = mapped_column(SmallInteger) + # PR-3: AI 풀이 캐시 (수동 트리거) # status: none | pending | ready | failed | stale (강한 enum 미사용, VARCHAR 권장값) ai_explanation: Mapped[str | None] = mapped_column(Text) diff --git a/app/models/study_topic.py b/app/models/study_topic.py index 18d25d8..6f5777e 100644 --- a/app/models/study_topic.py +++ b/app/models/study_topic.py @@ -18,6 +18,7 @@ from datetime import datetime from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship from core.database import Base @@ -40,6 +41,10 @@ class StudyTopic(Base): sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + # PR-6: 시험 메타 (회차당 문항 수 + 과목 리스트) + exam_round_size: Mapped[int | None] = mapped_column(Integer) + exam_subjects: Mapped[list] = mapped_column(JSONB, nullable=False, default=list) + created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=datetime.now, nullable=False ) diff --git a/frontend/src/routes/study/topics/+page.svelte b/frontend/src/routes/study/topics/+page.svelte index 0e09bb4..40d16bd 100644 --- a/frontend/src/routes/study/topics/+page.svelte +++ b/frontend/src/routes/study/topics/+page.svelte @@ -41,11 +41,38 @@ let f_description = $state(''); let f_color = $state(''); let f_study_type = $state(''); + // PR-6: 시험 메타 + let f_exam_round_size = $state(''); // 빈 값 = 미설정 + let f_exam_subjects = $state([]); // 문자열 배열 + let f_subject_input = $state(''); let creating = $state(false); + function addSubject() { + const v = f_subject_input.trim(); + if (!v) return; + if (!f_exam_subjects.includes(v)) f_exam_subjects = [...f_exam_subjects, v]; + f_subject_input = ''; + } + function removeSubject(s) { + f_exam_subjects = f_exam_subjects.filter((x) => x !== s); + } + // 편집 모달 - let editing = $state(null); // {id, name, description, color, study_type} + let editing = $state(null); // {id, name, description, color, study_type, exam_round_size, exam_subjects} let savingEdit = $state(false); + let edit_subject_input = $state(''); + + function addEditSubject() { + if (!editing) return; + const v = edit_subject_input.trim(); + if (!v) return; + if (!editing.exam_subjects.includes(v)) editing.exam_subjects = [...editing.exam_subjects, v]; + edit_subject_input = ''; + } + function removeEditSubject(s) { + if (!editing) return; + editing.exam_subjects = editing.exam_subjects.filter((x) => x !== s); + } async function load() { loading = true; @@ -74,6 +101,8 @@ description: f_description || null, color: f_color || null, study_type: f_study_type || null, + exam_round_size: f_exam_round_size ? Number(f_exam_round_size) : null, + exam_subjects: f_exam_subjects, }; const t = await api('/study-topics/', { method: 'POST', @@ -87,6 +116,9 @@ f_description = ''; f_color = ''; f_study_type = ''; + f_exam_round_size = ''; + f_exam_subjects = []; + f_subject_input = ''; } catch (err) { addToast('error', err.detail || '주제 생성 실패'); } finally { @@ -101,7 +133,10 @@ description: t.description ?? '', color: t.color ?? '', study_type: t.study_type ?? '', + exam_round_size: t.exam_round_size ?? '', + exam_subjects: [...(t.exam_subjects ?? [])], }; + edit_subject_input = ''; } async function saveEdit() { @@ -119,6 +154,10 @@ description: editing.description || null, color: editing.color || null, study_type: editing.study_type || null, + exam_round_size: editing.exam_round_size === '' || editing.exam_round_size == null + ? null + : Number(editing.exam_round_size), + exam_subjects: editing.exam_subjects ?? [], }), }); topics = topics.map((x) => (x.id === updated.id ? updated : x)); @@ -179,6 +218,40 @@