diff --git a/app/api/study_topics.py b/app/api/study_topics.py index a931d6a..a4da1be 100644 --- a/app/api/study_topics.py +++ b/app/api/study_topics.py @@ -1242,6 +1242,9 @@ class QuizSessionStartRequest(BaseModel): # 우회하고 select_questions_for_quiz 사용. size 가 명시되면 size 만큼 출제 (subject 무관). stage: str | None = Field(default=None, pattern="^(intro|learning|pre_exam)$") size: int | None = Field(default=None, ge=1, le=200) + # Phase 2-E: 복습함에서 명시 선택된 문제로 세션. 우선순위 stage > question_ids > 기존 경로. + # 출제 순서는 클라이언트가 보낸 그대로 보존 (사용자 인지 순서). 한도 200, 중복 제거됨. + question_ids: list[int] | None = Field(default=None) class QuizSessionSummary(BaseModel): @@ -1572,6 +1575,7 @@ async def start_quiz_session( await session.flush() # Phase 1-E: stage 명시 시 bucket + 비율 기반 선별 (단일 풀이 진입점 vision). + # Phase 2-E: question_ids 명시 시 복습함 multi-select 경로 — 검증만 + 순서 보존. # stage 미명시 시 기존 subject bucket + spacing (PR-12-B 호환). if body.stage is not None: from services.study.quiz_selection import select_questions_for_quiz @@ -1588,6 +1592,40 @@ async def start_quiz_session( raise HTTPException(status_code=400, detail=str(e)) if not qids: raise HTTPException(status_code=400, detail="출제 가능한 문제가 없습니다") + elif body.question_ids is not None: + # 중복 제거 (순서 보존) + seen: set[int] = set() + wanted: list[int] = [] + for qid in body.question_ids: + if qid not in seen: + seen.add(qid) + wanted.append(qid) + if not wanted: + raise HTTPException(status_code=400, detail="문제를 1개 이상 선택해주세요") + if len(wanted) > 200: + raise HTTPException(status_code=400, detail="한 세션에 최대 200문제까지 선택 가능합니다") + # 유효성 검증 — user × topic 소속 + 미삭제. soft-deleted 또는 다른 user/topic 의 qid 는 제거. + valid_rows = ( + await session.execute( + select(StudyQuestion.id, StudyQuestion.subject) + .where( + StudyQuestion.id.in_(wanted), + StudyQuestion.user_id == user.id, + StudyQuestion.study_topic_id == topic_id, + StudyQuestion.deleted_at.is_(None), + ) + ) + ).all() + valid_set = {r.id for r in valid_rows} + qids = [qid for qid in wanted if qid in valid_set] + if not qids: + raise HTTPException(status_code=400, detail="선택한 문제가 이 주제에 존재하지 않습니다") + # subject distribution 계산 (결과 카드 통계용) + subject_by_id = {r.id: (r.subject or "(미분류)") for r in valid_rows} + distribution: dict[str, int] = {} + for qid in qids: + sub = subject_by_id.get(qid, "(미분류)") + distribution[sub] = distribution.get(sub, 0) + 1 else: # 기존 PR-12-B 경로: subject bucket + type spacing. apply_spacing = body.quiz_mode == QuizMode.random diff --git a/frontend/src/routes/study/topics/[id]/review-queue/+page.svelte b/frontend/src/routes/study/topics/[id]/review-queue/+page.svelte index 10f8d63..adced28 100644 --- a/frontend/src/routes/study/topics/[id]/review-queue/+page.svelte +++ b/frontend/src/routes/study/topics/[id]/review-queue/+page.svelte @@ -13,12 +13,15 @@ import { addToast } from '$lib/stores/toast'; import { ArrowLeft, AlertCircle, Clock, RotateCcw, Repeat2, CheckCircle2, ChevronLeft, ChevronRight, + Play, Square, CheckSquare, } 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 EmptyState from '$lib/components/ui/EmptyState.svelte'; + const MAX_PER_SESSION = 200; + const TABS = [ { key: 'due_today', label: '오늘 할 일', icon: Clock, accent: 'text-warning' }, { key: 'pending_review', label: '미확인', icon: AlertCircle, accent: 'text-error' }, @@ -39,6 +42,10 @@ let loading = $state(false); let counts = $state({}); // { tab: total } + // 멀티 셀렉트 (현재 페이지 보이는 카드 기준 선택. 페이지 이동 시 유지). + let selected = $state(new Set()); // Set — question_id + let starting = $state(false); + async function loadTopic() { try { const t = await api(`/study-topics/${topicId}`); @@ -81,6 +88,7 @@ if (activeTab === key) return; activeTab = key; currentPage = 1; + selected = new Set(); // 탭 바뀌면 선택 초기화 — 다른 의도의 묶음일 가능성 // URL 동기 — replaceState 로 history 폭발 방지 const url = new URL($page.url); url.searchParams.set('tab', key); @@ -89,6 +97,71 @@ void loadTab(); } + function toggleSelect(qid) { + const next = new Set(selected); + if (next.has(qid)) next.delete(qid); + else { + if (next.size >= MAX_PER_SESSION) { + addToast('warning', `한 세션에 최대 ${MAX_PER_SESSION}문제까지 선택 가능합니다`); + return; + } + next.add(qid); + } + selected = next; + } + + function toggleSelectVisible() { + const visibleIds = items.map((it) => it.question_id); + const allSelected = visibleIds.length > 0 && visibleIds.every((id) => selected.has(id)); + const next = new Set(selected); + if (allSelected) { + visibleIds.forEach((id) => next.delete(id)); + } else { + for (const id of visibleIds) { + if (next.size >= MAX_PER_SESSION) break; + next.add(id); + } + } + selected = next; + } + + function clearSelection() { + selected = new Set(); + } + + /** 선택된 N개로 quiz_session 시작. 기존 in_progress 있으면 confirm 후 abandon. */ + async function startSelectedSession() { + if (selected.size === 0) return; + if (starting) return; + // 기존 in_progress 가 있는지 확인 — 토픽 페이지의 startNewQuiz 패턴 참고 + let hasActive = false; + try { + const sessions = await api(`/study-topics/${topicId}/quiz-sessions?limit=1`); + hasActive = !!sessions?.active; + } catch {} + if (hasActive) { + if (!confirm( + `진행 중인 문제풀이 세션이 있습니다. 새로 시작하면 그 세션은 포기됩니다. 계속할까요?`, + )) return; + } + starting = true; + try { + const qids = Array.from(selected); + const res = await api(`/study-topics/${topicId}/quiz-sessions`, { + method: 'POST', + body: JSON.stringify({ + question_ids: qids, + quiz_mode: 'random', + abandon_existing: hasActive, + }), + }); + goto(`/study/topics/${topicId}/review?session=${res.id}`); + } catch (err) { + addToast('error', err?.detail || '풀이 시작 실패'); + starting = false; + } + } + function setPage(p) { if (p < 1 || (p - 1) * PAGE_SIZE >= total) return; currentPage = p; @@ -151,6 +224,9 @@ } let lastPage = $derived(Math.max(1, Math.ceil(total / PAGE_SIZE))); + let allVisibleSelected = $derived( + items.length > 0 && items.every((it) => selected.has(it.question_id)) + ); 복습함 — {topicName || '주제'} @@ -204,13 +280,39 @@ } /> {:else} + +
+ + {#if selected.size > 0} + · + + {/if} +
+