feat(study): 문제 회차별 그룹 + 읽기전용 보기 페이지 (PR-11)

- 통합뷰 문제 섹션: 평면 리스트 → 회차별 아코디언 (디폴트 모두 접힘)
- 회차 정렬: "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) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-29 07:01:27 +09:00
parent 6e25523600
commit 1cf64fd11e
3 changed files with 576 additions and 23 deletions
+2
View File
@@ -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),
@@ -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 = {};
}
</script>
<svelte:head>
@@ -547,14 +619,23 @@
{/if}
</section>
<!-- 문제 (PR-2) -->
<!-- 문제 (PR-2 → PR-11: 회차별 그룹 + 접기) -->
<section class="mb-5">
<div class="flex items-center justify-between mb-2 flex-wrap gap-2">
<h2 class="text-sm font-semibold text-text flex items-center gap-2">
<HelpCircle size={14} class="text-accent" /> 문제
<span class="text-[10px] text-dim">{detail.sections.questions?.length ?? 0}</span>
<span class="text-[10px] text-dim">
{detail.sections.questions?.length ?? 0}
{#if questionsByRound.length > 0}· 회차 {questionsByRound.length}{/if}
</span>
</h2>
<div class="flex items-center gap-1 flex-wrap">
{#if questionsByRound.length > 0}
<button type="button" onclick={expandAllRounds} class="text-[11px] text-accent hover:underline px-1">모두 펼치기</button>
<span class="text-faint text-[11px]">·</span>
<button type="button" onclick={collapseAllRounds} class="text-[11px] text-dim hover:text-text px-1">모두 접기</button>
<span class="w-1"></span>
{/if}
<Button href={`/study/topics/${topicId}/exam-rounds`} size="sm" variant="ghost" icon={ListChecks}>회차 보기</Button>
<Button href={`/study/topics/${topicId}/questions/new`} size="sm" variant="ghost" icon={Plus}> 문제</Button>
{#if (detail.sections.questions?.length ?? 0) > 0}
@@ -622,29 +703,84 @@
</div>
{/if}
<div class="flex flex-col gap-2">
{#each detail.sections.questions as q (q.id)}
<div class="flex items-center gap-3 p-3 rounded-lg border border-default bg-surface hover:bg-surface/80">
<div class="flex-1 min-w-0">
<div class="text-sm text-text line-clamp-2">{q.question_text}</div>
<div class="text-[11px] text-dim mt-1 flex items-center gap-2 flex-wrap">
{#if q.subject}<span>{q.subject}</span>{/if}
{#if q.scope}<span>· {q.scope}</span>{/if}
{#if q.exam_round}<span>· {q.exam_round}</span>{/if}
{#if q.attempt_count > 0}
<span class="ml-auto flex items-center gap-1">
{#if q.last_correct === true}
<CheckCircle2 size={11} class="text-success" />
{:else if q.last_correct === false}
<XCircle size={11} class="text-error" />
{/if}
<span>{q.attempt_count}</span>
</span>
<!-- PR-11: 회차별 아코디언 (디폴트 모두 접힘) -->
<div class="flex flex-col gap-1.5">
{#each questionsByRound as [roundKey, items] (roundKey)}
{@const isExpanded = roundsExpanded[roundKey] === true}
{@const isUncategorized = roundKey === UNCAT_ROUND_KEY}
{@const agg = roundAggregate(items)}
<div class="rounded-lg border {isUncategorized ? 'border-warning/30 bg-warning/5' : 'border-default bg-surface'}">
<button
type="button"
onclick={() => toggleRound(roundKey)}
class="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-bg/30 transition-colors rounded-lg"
>
{#if isExpanded}
<ChevronDown size={14} class="text-dim shrink-0" />
{:else}
<ChevronRight size={14} class="text-dim shrink-0" />
{/if}
<span class="text-sm text-text font-medium truncate {isUncategorized ? 'italic' : ''}">{roundKey}</span>
<span class="ml-auto text-[11px] text-dim flex items-center gap-2 flex-wrap">
<span>{items.length}문제</span>
{#if agg.attempts > 0}
<span>· 총 시도 {agg.attempts}</span>
{#if agg.lastOk + agg.lastNg > 0}
<span>· 마지막 결과:</span>
<span class="text-success">정답 {agg.lastOk}</span>
<span class="text-dim">/</span>
<span class="text-error">오답 {agg.lastNg}</span>
{/if}
{/if}
{#if !q.is_active}<span class="text-warning">· 비활성</span>{/if}
</span>
</button>
{#if isExpanded}
{#if isUncategorized}
<div class="px-3 pb-2 text-[11px] text-warning/80 italic">
회차를 지정하면 회차 카드 페이지에서 진행률 추적이 가능합니다.
</div>
{/if}
<div class="px-2 pb-2 flex flex-col gap-1.5">
{#each items as q (q.id)}
<!-- 본문 링크와 [편집] 링크는 형제 구조로 분리해 이벤트 버블링 충돌 회피. -->
<div class="flex items-stretch gap-1 rounded border border-default bg-surface hover:border-accent/40 transition-colors overflow-hidden">
<a
href={`/study/topics/${topicId}/questions/${q.id}`}
title="상세 보기"
class="flex-1 min-w-0 p-3 hover:bg-accent/5 transition-colors"
>
<div class="text-sm text-text line-clamp-2">
{#if q.exam_question_number}<span class="text-dim mr-1.5">{q.exam_question_number}.</span>{/if}{q.question_text}
</div>
<div class="text-[11px] text-dim mt-1 flex items-center gap-2 flex-wrap">
{#if q.subject}<span>{q.subject}</span>{/if}
{#if q.scope}<span>· {q.scope}</span>{/if}
{#if q.attempt_count > 0}
<span class="ml-auto flex items-center gap-1">
{#if q.last_correct === true}
<CheckCircle2 size={11} class="text-success" />
{:else if q.last_correct === false}
<XCircle size={11} class="text-error" />
{/if}
<span>{q.attempt_count}</span>
</span>
{/if}
{#if !q.is_active}<span class="text-warning">· 비활성</span>{/if}
</div>
</a>
<a
href={`/study/topics/${topicId}/questions/${q.id}/edit`}
title="편집"
class="flex items-center px-3 text-dim hover:text-accent hover:bg-accent/10 transition-colors border-l border-default"
aria-label="편집"
>
<Edit size={14} />
</a>
</div>
{/each}
</div>
</div>
<Button href={`/study/topics/${topicId}/questions/${q.id}/edit`} size="sm" variant="ghost" icon={Edit}>편집</Button>
{/if}
</div>
{/each}
</div>
@@ -0,0 +1,415 @@
<script>
/**
* /study/topics/[id]/questions/[qid] — 문제 상세 (PR-11 신규).
*
* 읽기전용. 정답을 미리 보여주는 "검토/학습 자료" 페이지.
* 풀이 페이지가 아니므로 "문제 상세" 라벨 사용.
*
* URL 직접 접근 가능 — 통합뷰 detail 의존 없이 자체 fetch:
* 1) GET /study-questions/{qid} → 본문/보기/정답/해설/이미지/통계
* 2) 응답의 study_topic_id + exam_round 확인
* 3) GET /study-topics/{tid}/questions?page_size=200 → 같은 회차 prev/next
* (200건 만땅이면 토스트 — 조용히 자르지 않음)
*
* AI 해설 5상태: idle / loading / success / stale / error.
* regenerate=true 옵션으로 강제 재생성 (PR-3 검증된 동작).
*
* 이미지/사용자 해설 없으면 섹션 숨김.
*/
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import {
ArrowLeft, ArrowRight, Edit, Sparkles, GitCompare, AlertCircle, CheckCircle2, XCircle, ListChecks,
} from 'lucide-svelte';
import { renderMathMarkdown, renderMathMarkdownInline } from '$lib/utils/mathMarkdown';
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';
import ImgAuth from '$lib/components/ImgAuth.svelte';
let topicId = $derived(Number($page.params.id));
let qid = $derived(Number($page.params.qid));
let topicName = $state('');
let q = $state(null); // GET /study-questions/{qid} 응답
let loading = $state(true);
// 같은 회차 prev/next — exam_round 있을 때만.
let roundSiblings = $state([]); // [{id, exam_question_number, created_at}, ...] 정렬됨
// AI 해설 상태머신: 'idle' | 'loading' | 'success' | 'stale' | 'error'
let aiState = $state('idle');
let aiData = $state(null); // AIExplanationResponse
let aiError = $state(null);
// 비슷한 문제 (PR-5)
let simOpen = $state(false);
let simLoading = $state(false);
let simItems = $state([]);
let simSourceStatus = $state('none');
async function loadTopic() {
try {
const t = await api(`/study-topics/${topicId}`);
topicName = t?.topic?.name ?? '';
} catch {}
}
/** 같은 회차 prev/next 후보 fetch. exam_round 가 truthy 일 때만. */
async function loadRoundSiblings() {
roundSiblings = [];
if (!q?.exam_round) return; // 원본 값 truthy 체크 (표시 문자열과 분리)
try {
const res = await api(`/study-topics/${topicId}/questions?page_size=200`);
const items = res.items ?? [];
// 백엔드 max=200. 200건 만땅 + total>200 이면 회차 안 일부가 누락될 수 있음 → 안내.
if (items.length === 200 && (res.total ?? 0) > 200) {
addToast('info', '이 토픽에 200문제 초과 — 회차 prev/next 가 일부 누락될 수 있습니다');
}
const siblings = items.filter((it) => it.exam_round === q.exam_round);
siblings.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(a.created_at).getTime() - new Date(b.created_at).getTime();
});
roundSiblings = siblings;
} catch {
roundSiblings = [];
}
}
async function load() {
loading = true;
try {
q = await api(`/study-questions/${qid}`);
// 페이지 진입 시 AI 해설 상태 초기화 — q.ai_explanation_status 가 ready/stale 이면 1회 prefetch (캐시 hit).
// 사용자가 [해설 보기] 누르기 전엔 본문 표시 안 함, 다만 상태는 미리 알 수 있음.
aiState = 'idle';
aiData = null;
aiError = null;
// simExplOpen reset
simOpen = false; simItems = []; simSourceStatus = 'none';
await loadRoundSiblings();
} catch (err) {
addToast('error', err?.detail || '문제 로드 실패');
q = null;
} finally {
loading = false;
}
}
onMount(async () => {
await loadTopic();
await load();
});
// qid 가 prev/next 로 바뀌면 다시 로드
$effect(() => {
void qid;
if (Number.isFinite(qid)) {
// onMount 첫 호출과 중복 방지: q?.id 와 qid 가 다를 때만.
if (q && q.id !== qid) load();
}
});
/** AI 해설 호출. regenerate 옵션 명시. */
async function loadAi(regenerate = false) {
aiState = 'loading';
aiError = null;
try {
const res = await api(`/study-questions/${qid}/ai-explanation`, {
method: 'POST',
body: JSON.stringify({ regenerate }),
});
aiData = res;
if (res.ai_explanation_status === 'failed') {
aiState = 'error';
aiError = '풀이 생성 실패. 다시 시도해주세요.';
} else if (res.is_stale) {
aiState = 'stale';
} else {
aiState = 'success';
}
} catch (err) {
aiState = 'error';
aiError = err?.status === 409
? '다른 호출이 풀이를 생성 중입니다. 잠시 후 다시 시도해주세요.'
: (err?.detail || 'AI 해설을 불러오지 못했습니다. 다시 시도해 주세요.');
}
}
async function toggleSimilar() {
if (simOpen) { simOpen = false; return; }
simOpen = true;
if (simItems.length > 0) return;
simLoading = true;
try {
const res = await api(`/study-questions/${qid}/similar?limit=5&topic_only=true`);
simItems = res.items ?? [];
simSourceStatus = res.source_status ?? 'none';
} catch {
simItems = [];
} finally {
simLoading = false;
}
}
// 보기 4지선다 derived (마크업에서 {@const} 위치 제약 회피).
let choices = $derived(q ? [
{ number: 1, text: q.choice_1 },
{ number: 2, text: q.choice_2 },
{ number: 3, text: q.choice_3 },
{ number: 4, text: q.choice_4 },
] : []);
// prev/next 계산. exam_round truthy 일 때만 활성.
let currentSiblingIdx = $derived(roundSiblings.findIndex((s) => s.id === qid));
let prevSibling = $derived(currentSiblingIdx > 0 ? roundSiblings[currentSiblingIdx - 1] : null);
let nextSibling = $derived(
currentSiblingIdx >= 0 && currentSiblingIdx < roundSiblings.length - 1
? roundSiblings[currentSiblingIdx + 1]
: null
);
function goSibling(s) {
if (!s) return;
goto(`/study/topics/${topicId}/questions/${s.id}`);
}
function fmtDateTime(s) {
if (!s) return '';
return new Date(s).toLocaleString('ko-KR', { dateStyle: 'short', timeStyle: 'short' });
}
</script>
<svelte:head><title>문제 상세 — {topicName || '주제'}</title></svelte:head>
<div class="p-4 md:p-6 max-w-3xl mx-auto">
<div class="flex items-center gap-2 text-xs md:text-sm mb-3">
<a href="/study" class="text-dim hover:text-text">공부</a>
<span class="text-faint">/</span>
<a href="/study/topics" class="text-dim hover:text-text">주제</a>
<span class="text-faint">/</span>
<a href={`/study/topics/${topicId}`} class="text-dim hover:text-text truncate">{topicName || '...'}</a>
<span class="text-faint">/</span>
<span class="text-text font-medium">문제 #{qid}</span>
</div>
{#if loading}
<Skeleton h="h-32" rounded="lg" />
{:else if !q}
<EmptyState title="문제를 찾을 수 없습니다" description="삭제되었거나 권한이 없는 문제입니다." />
<div class="mt-3">
<Button href={`/study/topics/${topicId}`} icon={ArrowLeft}>주제로</Button>
</div>
{:else}
<Card>
{#snippet children()}
<div class="p-5 flex flex-col gap-4">
<!-- 헤더 -->
<div class="flex items-start justify-between gap-3 flex-wrap pb-3 border-b border-default">
<div class="flex-1 min-w-0">
<h1 class="text-base font-semibold text-text">문제 상세</h1>
<div class="text-[11px] text-dim mt-1 flex items-center gap-2 flex-wrap">
{#if q.subject}<span>{q.subject}</span>{/if}
{#if q.scope}<span>· {q.scope}</span>{/if}
{#if q.exam_round}
<span>·
<a href={`/study/topics/${topicId}/exam-rounds`} class="hover:text-accent">{q.exam_round}</a>
</span>
{/if}
{#if q.exam_question_number}<span>· {q.exam_question_number}</span>{/if}
{#if !q.is_active}<span class="text-warning">· 비활성</span>{/if}
</div>
</div>
<Button href={`/study/topics/${topicId}/questions/${qid}/edit`} size="sm" variant="ghost" icon={Edit}>편집</Button>
</div>
<!-- 본문 -->
<div class="text-base text-text leading-relaxed math-area">
{@html renderMathMarkdown(q.question_text)}
</div>
<!-- 첨부 이미지 (있을 때만) -->
{#if q.images?.length > 0}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
{#each q.images as img (img.id)}
<ImgAuth src={img.served_url} class="w-full max-h-72 object-contain rounded border border-default bg-bg/30" />
{/each}
</div>
{/if}
<!-- 4지선다 + 정답 표시 -->
<div class="flex flex-col gap-1.5">
{#each choices as ch (ch.number)}
{@const isCorrect = q.correct_choice === ch.number}
<div class="p-2.5 rounded border flex items-start gap-3 text-sm
{isCorrect ? 'border-success bg-success/10 text-text' : 'border-default bg-surface text-dim'}"
>
<span class="font-semibold w-5 shrink-0">{ch.number}</span>
<span class="flex-1 math-area overflow-x-auto">{@html renderMathMarkdownInline(ch.text)}</span>
{#if isCorrect}<CheckCircle2 size={14} class="text-success shrink-0" />{/if}
</div>
{/each}
</div>
<!-- 사용자 입력 해설 (있을 때만) -->
{#if q.explanation}
<div class="rounded border border-default bg-bg/30 p-3 text-sm text-text math-area">
<div class="text-[10px] text-dim mb-1.5 font-medium">사용자 입력 해설</div>
<div>{@html renderMathMarkdown(q.explanation)}</div>
</div>
{/if}
<!-- AI 해설 (PR-3) — 5상태 명시 -->
<div class="rounded border border-default bg-bg/30 p-3 flex flex-col gap-2">
<div class="flex items-center gap-2 flex-wrap">
<Sparkles size={14} class="text-accent" />
<span class="text-xs font-semibold text-text">AI 해설</span>
{#if aiState === 'stale'}
<span class="text-[10px] text-warning border border-warning/40 rounded px-1.5 py-0.5">stale</span>
{:else if aiState === 'success' && aiData?.from_cache}
<span class="text-[10px] text-dim">캐시</span>
{/if}
<span class="ml-auto flex items-center gap-1.5">
{#if aiState === 'idle'}
<Button size="sm" variant="ghost" onclick={() => loadAi(false)}>해설 보기</Button>
{:else if aiState === 'loading'}
<Button size="sm" variant="ghost" loading={true}>생성 …</Button>
{:else if aiState === 'stale'}
<Button size="sm" onclick={() => loadAi(true)}>다시 생성</Button>
{:else if aiState === 'error'}
<Button size="sm" onclick={() => loadAi(true)}>다시 시도</Button>
{:else}
<Button size="sm" variant="ghost" onclick={() => loadAi(true)}>다시 생성</Button>
{/if}
</span>
</div>
{#if aiState === 'loading'}
<div class="text-[11px] text-dim">해설을 생성하는 중입니다… (캐시 hit 시 1초, miss 시 최대 30초)</div>
{:else if aiState === 'stale'}
<div class="text-[11px] text-warning bg-warning/5 border border-warning/30 rounded px-2 py-1.5 flex items-start gap-1.5">
<AlertCircle size={12} class="mt-0.5 shrink-0" />
<span>이 풀이는 정답·문제가 수정된 후의 이전 풀이입니다. "다시 생성" 으로 새로 만들 수 있습니다.</span>
</div>
{:else if aiState === 'error'}
<div class="text-[11px] text-error">{aiError || 'AI 해설을 불러오지 못했습니다. 다시 시도해 주세요.'}</div>
{/if}
{#if (aiState === 'success' || aiState === 'stale') && aiData?.ai_explanation}
<div class="text-xs text-text leading-relaxed prose prose-sm prose-invert max-w-none math-area">
{@html renderMathMarkdown(aiData.ai_explanation)}
</div>
{#if aiData.evidence?.length}
<details class="text-[10px] text-dim">
<summary class="cursor-pointer hover:text-text">참고 근거 {aiData.evidence.length}</summary>
<ul class="mt-1 flex flex-col gap-1">
{#each aiData.evidence as ev}
<li>
<span class="text-text">{ev.source_type === 'document' ? '📄' : '❓'} {ev.title}</span>
<div class="pl-4 truncate">{ev.snippet}</div>
</li>
{/each}
</ul>
</details>
{/if}
{/if}
</div>
<!-- 누적 통계 -->
<div class="text-[11px] text-dim border-t border-default pt-3">
누적 시도 {q.stats?.attempt_count ?? 0}회 ·
정답 {q.stats?.correct_count ?? 0} ·
오답 {q.stats?.wrong_count ?? 0}
</div>
<!-- 비슷한 문제 (PR-5) -->
<div class="rounded border border-default bg-bg/30 p-3 flex flex-col gap-2">
<div class="flex items-center gap-2 flex-wrap">
<GitCompare size={14} class="text-accent" />
<span class="text-xs font-semibold text-text">비슷한 문제</span>
{#if simItems.length > 0}<span class="text-[10px] text-dim">{simItems.length}</span>{/if}
<span class="ml-auto">
<Button size="sm" variant="ghost" onclick={toggleSimilar} loading={simLoading}>
{simOpen ? '접기' : '비슷한 문제 보기'}
</Button>
</span>
</div>
{#if simOpen}
{#if simSourceStatus !== 'ready'}
<div class="text-[11px] text-dim">
{#if simSourceStatus === 'pending'}임베딩 생성 중입니다. 잠시 후 다시.
{:else if simSourceStatus === 'failed'}임베딩 생성 실패. 다음 cron 자동 재시도.
{:else if simSourceStatus === 'stale'}본문 변경 후 임베딩 재계산 대기 중.
{:else}임베딩 미생성. 약 1분 안에 cron 처리.
{/if}
</div>
{:else if simItems.length === 0}
<div class="text-[11px] text-dim">이 주제에 비슷한 문제가 없습니다.</div>
{:else}
<ul class="flex flex-col gap-1.5">
{#each simItems as it (it.id)}
<li>
<a
href={`/study/topics/${it.study_topic_id}/questions/${it.id}`}
class="flex items-center gap-3 p-2 rounded border border-default bg-surface hover:border-accent transition-colors"
>
<div class="flex-1 min-w-0">
<div class="text-xs text-text truncate">{it.question_text}</div>
<div class="text-[10px] text-dim mt-0.5 flex items-center gap-2 flex-wrap">
{#if it.subject}<span>{it.subject}</span>{/if}
{#if it.scope}<span>· {it.scope}</span>{/if}
{#if it.exam_round}<span>· {it.exam_round}</span>{/if}
</div>
</div>
<span class="text-[10px] text-accent font-medium shrink-0">{Math.round(it.similarity * 100)}%</span>
<ArrowRight size={11} class="text-dim shrink-0" />
</a>
</li>
{/each}
</ul>
{/if}
{/if}
</div>
<!-- 푸터 네비게이션 -->
<div class="flex items-center justify-between gap-2 pt-3 border-t border-default flex-wrap">
<Button href={`/study/topics/${topicId}`} variant="ghost" size="sm" icon={ArrowLeft}>주제로</Button>
{#if q.exam_round}
<Button href={`/study/topics/${topicId}/exam-rounds`} variant="ghost" size="sm" icon={ListChecks}>회차 카드</Button>
{/if}
<div class="flex items-center gap-2 ml-auto">
<Button
size="sm"
variant="ghost"
icon={ArrowLeft}
disabled={!prevSibling}
onclick={() => goSibling(prevSibling)}
>이전</Button>
{#if q.exam_round && roundSiblings.length > 0}
<span class="text-[11px] text-dim">
{currentSiblingIdx + 1} / {roundSiblings.length}
</span>
{/if}
<Button
size="sm"
icon={ArrowRight}
iconPosition="right"
disabled={!nextSibling}
onclick={() => goSibling(nextSibling)}
>다음</Button>
</div>
</div>
</div>
{/snippet}
</Card>
{/if}
</div>