feat(study): 시험·회차·문항 관리 (PR-6)
기사시험 회차별 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user