From 1cf64fd11e9802754b89f76cbb71241648f00a0d Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Wed, 29 Apr 2026 07:01:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(study):=20=EB=AC=B8=EC=A0=9C=20=ED=9A=8C?= =?UTF-8?q?=EC=B0=A8=EB=B3=84=20=EA=B7=B8=EB=A3=B9=20+=20=EC=9D=BD?= =?UTF-8?q?=EA=B8=B0=EC=A0=84=EC=9A=A9=20=EB=B3=B4=EA=B8=B0=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20(PR-11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 통합뷰 문제 섹션: 평면 리스트 → 회차별 아코디언 (디폴트 모두 접힘) - 회차 정렬: "YYYY년 N회" 파싱 → year desc / round desc (localeCompare 단독 회귀 차단) - 회차 행 라벨: "총 시도 N건 · 마지막 결과: 정답 K / 오답 M" (누적/마지막 혼동 회피) - 회차 미지정 그룹은 노란 톤 + 안내, 표시 문자열은 UI 전용 (원본 NULL 분리) - 본문 / [편집] 링크 구조 분리로 이벤트 버블링 충돌 차단 - /study/topics/{tid}/questions/{qid} 신규 — KaTeX 마크다운 렌더 + 정답 표시 + AI 해설 5상태 (idle/loading/success/stale/error) + 비슷한 문제 + prev/next - prev/next URL 직접 접근 — 단건 fetch + 같은 회차 목록 fetch 자체 처리 - page_size=200 만땅 + total>200 시 토스트 안내 (조용히 자르지 않음) - 사용자 입력 해설/이미지 없으면 섹션 숨김, exam_round NULL 이면 prev/next 비활성 - StudyTopicQuestionSummary 에 exam_question_number 추가 (회차 안 정렬 키) Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/study_topics.py | 2 + .../src/routes/study/topics/[id]/+page.svelte | 182 +++++++- .../topics/[id]/questions/[qid]/+page.svelte | 415 ++++++++++++++++++ 3 files changed, 576 insertions(+), 23 deletions(-) create mode 100644 frontend/src/routes/study/topics/[id]/questions/[qid]/+page.svelte diff --git a/app/api/study_topics.py b/app/api/study_topics.py index f362b55..5fd871d 100644 --- a/app/api/study_topics.py +++ b/app/api/study_topics.py @@ -154,6 +154,7 @@ class StudyTopicQuestionSummary(BaseModel): scope: str | None exam_name: str | None exam_round: str | None + exam_question_number: int | None # PR-11: 회차별 그룹 안 정렬 키 is_active: bool attempt_count: int last_correct: bool | None @@ -629,6 +630,7 @@ async def get_study_topic( scope=q.scope, exam_name=q.exam_name, exam_round=q.exam_round, + exam_question_number=q.exam_question_number, is_active=q.is_active, attempt_count=q_attempt_count.get(q.id, 0), last_correct=q_last_correct.get(q.id), diff --git a/frontend/src/routes/study/topics/[id]/+page.svelte b/frontend/src/routes/study/topics/[id]/+page.svelte index 035c89e..7993ca5 100644 --- a/frontend/src/routes/study/topics/[id]/+page.svelte +++ b/frontend/src/routes/study/topics/[id]/+page.svelte @@ -27,6 +27,9 @@ // PR-10: 문제풀이 세션 (진행 중 + 최근 완료) let quizSessions = $state({ active: null, recent_done: [] }); + // PR-11: 문제 섹션 회차별 그룹 expand/collapse 상태. key = exam_round 표시 문자열. + let roundsExpanded = $state({}); + // 자료 추가 모달 let docModalOpen = $state(false); let docSearch = $state(''); @@ -338,6 +341,75 @@ function fmtDate(s) { return new Date(s).toLocaleDateString('ko-KR', { dateStyle: 'medium' }); } + + // PR-11: 문제 회차별 그룹. + // - "(회차 미지정)"은 UI group key 전용 — q.exam_round 원본 값(NULL/문자열)과 절대 섞지 않음. + // - 정렬은 "YYYY년 N회" 파싱해서 year desc → round desc. localeCompare 단독 사용 금지 (2024년 10회 vs 2024년 2회 회귀). + const UNCAT_ROUND_KEY = '(회차 미지정)'; + + function parseRoundKey(round) { + if (typeof round !== 'string') return { year: -1, round: -1 }; + const m = round.match(/(\d{4}).*?(\d+)/); + if (!m) return { year: -1, round: -1 }; + return { year: Number(m[1]), round: Number(m[2]) }; + } + + /** [['2024년 1회', [q,...]], ..., ['(회차 미지정)', [...]]] desc 정렬, 미지정은 맨 아래. */ + let questionsByRound = $derived.by(() => { + const groups = new Map(); + for (const q of detail?.sections?.questions ?? []) { + const key = q.exam_round || UNCAT_ROUND_KEY; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(q); + } + // 그룹 내부 정렬: exam_question_number asc, 미설정이면 created_at desc fallback. + for (const arr of groups.values()) { + arr.sort((a, b) => { + const an = a.exam_question_number; + const bn = b.exam_question_number; + if (an != null && bn != null) return an - bn; + if (an != null) return -1; + if (bn != null) return 1; + return new Date(b.created_at).getTime() - new Date(a.created_at).getTime(); + }); + } + return [...groups.entries()].sort(([a], [b]) => { + if (a === UNCAT_ROUND_KEY) return 1; + if (b === UNCAT_ROUND_KEY) return -1; + const pa = parseRoundKey(a); + const pb = parseRoundKey(b); + if (pb.year !== pa.year) return pb.year - pa.year; + if (pb.round !== pa.round) return pb.round - pa.round; + return b.localeCompare(a); + }); + }); + + /** 회차 행 라벨용 합산 — 누적 시도 / "마지막 결과: 정답/오답" (last_correct 기반). */ + function roundAggregate(items) { + let attempts = 0; + let lastOk = 0; + let lastNg = 0; + for (const q of items) { + attempts += q.attempt_count ?? 0; + if (q.last_correct === true) lastOk += 1; + else if (q.last_correct === false) lastNg += 1; + } + return { attempts, lastOk, lastNg }; + } + + function toggleRound(key) { + roundsExpanded = { ...roundsExpanded, [key]: !roundsExpanded[key] }; + } + + function expandAllRounds() { + const next = {}; + for (const [k] of questionsByRound) next[k] = true; + roundsExpanded = next; + } + + function collapseAllRounds() { + roundsExpanded = {}; + } @@ -547,14 +619,23 @@ {/if} - +

문제 - {detail.sections.questions?.length ?? 0} + + {detail.sections.questions?.length ?? 0}개 + {#if questionsByRound.length > 0}· 회차 {questionsByRound.length}개{/if} +

+ {#if questionsByRound.length > 0} + + · + + + {/if} {#if (detail.sections.questions?.length ?? 0) > 0} @@ -622,29 +703,84 @@
{/if} -
- {#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}회 - + +
+ {#each questionsByRound as [roundKey, items] (roundKey)} + {@const isExpanded = roundsExpanded[roundKey] === true} + {@const isUncategorized = roundKey === UNCAT_ROUND_KEY} + {@const agg = roundAggregate(items)} +
+ + + {#if isExpanded} + {#if isUncategorized} +
+ 회차를 지정하면 회차 카드 페이지에서 진행률 추적이 가능합니다. +
+ {/if} + -
- + {/if}
{/each}
diff --git a/frontend/src/routes/study/topics/[id]/questions/[qid]/+page.svelte b/frontend/src/routes/study/topics/[id]/questions/[qid]/+page.svelte new file mode 100644 index 0000000..6a24bea --- /dev/null +++ b/frontend/src/routes/study/topics/[id]/questions/[qid]/+page.svelte @@ -0,0 +1,415 @@ + + +문제 상세 — {topicName || '주제'} + +
+
+ 공부 + / + 주제 + / + {topicName || '...'} + / + 문제 #{qid} +
+ + {#if loading} + + {:else if !q} + +
+ +
+ {:else} + + {#snippet children()} +
+ +
+
+

문제 상세

+
+ {#if q.subject}{q.subject}{/if} + {#if q.scope}· {q.scope}{/if} + {#if q.exam_round} + · + {q.exam_round} + + {/if} + {#if q.exam_question_number}· {q.exam_question_number}번{/if} + {#if !q.is_active}· 비활성{/if} +
+
+ +
+ + +
+ {@html renderMathMarkdown(q.question_text)} +
+ + + {#if q.images?.length > 0} +
+ {#each q.images as img (img.id)} + + {/each} +
+ {/if} + + +
+ {#each choices as ch (ch.number)} + {@const isCorrect = q.correct_choice === ch.number} +
+ {ch.number} + {@html renderMathMarkdownInline(ch.text)} + {#if isCorrect}{/if} +
+ {/each} +
+ + + {#if q.explanation} +
+
사용자 입력 해설
+
{@html renderMathMarkdown(q.explanation)}
+
+ {/if} + + +
+
+ + AI 해설 + {#if aiState === 'stale'} + stale + {:else if aiState === 'success' && aiData?.from_cache} + 캐시 + {/if} + + {#if aiState === 'idle'} + + {:else if aiState === 'loading'} + + {:else if aiState === 'stale'} + + {:else if aiState === 'error'} + + {:else} + + {/if} + +
+ + {#if aiState === 'loading'} +
해설을 생성하는 중입니다… (캐시 hit 시 1초, miss 시 최대 30초)
+ {:else if aiState === 'stale'} +
+ + 이 풀이는 정답·문제가 수정된 후의 이전 풀이입니다. "다시 생성" 으로 새로 만들 수 있습니다. +
+ {:else if aiState === 'error'} +
{aiError || 'AI 해설을 불러오지 못했습니다. 다시 시도해 주세요.'}
+ {/if} + + {#if (aiState === 'success' || aiState === 'stale') && aiData?.ai_explanation} +
+ {@html renderMathMarkdown(aiData.ai_explanation)} +
+ {#if aiData.evidence?.length} +
+ 참고 근거 {aiData.evidence.length}건 +
    + {#each aiData.evidence as ev} +
  • + {ev.source_type === 'document' ? '📄' : '❓'} {ev.title} +
    {ev.snippet}
    +
  • + {/each} +
+
+ {/if} + {/if} +
+ + +
+ 누적 시도 {q.stats?.attempt_count ?? 0}회 · + 정답 {q.stats?.correct_count ?? 0} · + 오답 {q.stats?.wrong_count ?? 0} +
+ + +
+
+ + 비슷한 문제 + {#if simItems.length > 0}{simItems.length}건{/if} + + + +
+ {#if simOpen} + {#if simSourceStatus !== 'ready'} +
+ {#if simSourceStatus === 'pending'}임베딩 생성 중입니다. 잠시 후 다시. + {:else if simSourceStatus === 'failed'}임베딩 생성 실패. 다음 cron 자동 재시도. + {:else if simSourceStatus === 'stale'}본문 변경 후 임베딩 재계산 대기 중. + {:else}임베딩 미생성. 약 1분 안에 cron 처리. + {/if} +
+ {:else if simItems.length === 0} +
이 주제에 비슷한 문제가 없습니다.
+ {:else} + + {/if} + {/if} +
+ + +
+ + {#if q.exam_round} + + {/if} +
+ + {#if q.exam_round && roundSiblings.length > 0} + + {currentSiblingIdx + 1} / {roundSiblings.length} + + {/if} + +
+
+
+ {/snippet} +
+ {/if} +