feat(study): 반복 출제 / 유사 유형 분리 표시 (PR-12-A)

학습 의미가 회차 간 반복성 — 차단/제거가 아니라 패턴 표시 frame.

- 신규 service `related_types.py` — threshold/회차 필터/round_count 계산 공유
  - REPEAT >= 0.95 / SIMILAR 0.88~0.95
  - 회차 조건 백엔드 강제 (자기 자신/같은 회차/null exam_round candidate 제외)
  - round_count: related_count == 0 → 0 (현재 회차만 1로 채우지 않음)
- GET /study-questions/{qid}/related-types — 단건 분류 (repeat_questions / similar_questions)
- POST /study-topics/{tid}/related-types-bulk — 카드 배지용 카운트 batch
  - 비교 대상 = 토픽 전체 ready pool (입력 qid 끼리 비교 X)
  - 응답 키 보존 — 권한 없음/임베딩 미준비 등도 (0,0,0,0)
- 보기 페이지: PR-11 비슷한 문제 토글 제거 + 🔥 반복 출제 / 🧩 유사 유형 두 섹션 자동 노출
  - 헤더 = round_count "N개 회차", 본문 위 = related_count "관련 N문제"
  - source_status / source_exam_round 안내 분기
- 결과 페이지 (틀린/모르겠음 카드): bulk 호출 후 round_count >= 2 일 때만 배지
- 통합뷰 회차 expand 시 lazy bulk 호출 — 같은 회차 캐시
- 기존 /similar 엔드포인트 유지 (raw 디버깅용)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-29 08:09:14 +09:00
parent 8525c9aefb
commit cbe852bb37
6 changed files with 630 additions and 75 deletions
+108
View File
@@ -1079,6 +1079,114 @@ async def list_similar_questions(
return SimilarQuestionsResponse(items=items, source_status="ready", source_id=question_id)
# ─── PR-12-A: 반복 출제 / 유사 유형 분류 ───
class RelatedQuestionItem(BaseModel):
id: int
study_topic_id: int
question_text: str
subject: str | None
scope: str | None
exam_round: str | None
exam_question_number: int | None
similarity: float
class RelatedTypesResponse(BaseModel):
source_id: int
source_status: str # ready | pending | failed | stale | none
source_exam_round: str | None # null/empty 면 두 리스트 모두 빈 + 카운트 0
# 다른 user / 같은 회차 / 자기 자신은 백엔드에서 모두 제외됨
repeat_questions: list[RelatedQuestionItem] # similarity >= 0.95
similar_questions: list[RelatedQuestionItem] # 0.88 <= similarity < 0.95
# 카운트 — round_count 가 1차 표시 기준 (회차 간 반복성).
repeat_related_count: int
repeat_round_count: int
similar_related_count: int
similar_round_count: int
@router.get(
"/study-questions/{question_id}/related-types",
response_model=RelatedTypesResponse,
)
async def list_related_types(
question_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""반복 출제(🔥) / 유사 유형(🧩) 분리. 회차 조건 백엔드 강제.
학습 의미:
- 반복 출제 = 거의 같은 형태가 다른 회차에 다시 등장 (암기/패턴 고정 가치)
- 유사 유형 = 같은 개념·풀이 패턴이 다른 회차에 등장 (개념 복습 가치)
공통 service 함수 사용 — bulk endpoint 와 분류 로직 공유 (drift 회피).
"""
from services.study.related_types import classify_related_for_question
src = await session.get(StudyQuestion, question_id)
src = _verify_question_ownership(src, user)
src_round = (src.exam_round or "").strip()
src_round_or_none = src_round if src_round else None
# 임베딩 미준비 → 빈 응답.
if src.embedding_status != "ready" or src.embedding is None:
return RelatedTypesResponse(
source_id=question_id,
source_status=src.embedding_status,
source_exam_round=src_round_or_none,
repeat_questions=[],
similar_questions=[],
repeat_related_count=0,
repeat_round_count=0,
similar_related_count=0,
similar_round_count=0,
)
cls = await classify_related_for_question(session, user_id=user.id, source=src)
return RelatedTypesResponse(
source_id=question_id,
source_status="ready",
source_exam_round=src_round_or_none,
repeat_questions=[
RelatedQuestionItem(
id=c.id,
study_topic_id=c.study_topic_id,
question_text=c.question_text,
subject=c.subject,
scope=c.scope,
exam_round=c.exam_round,
exam_question_number=c.exam_question_number,
similarity=round(c.similarity, 4),
)
for c in cls.repeat
],
similar_questions=[
RelatedQuestionItem(
id=c.id,
study_topic_id=c.study_topic_id,
question_text=c.question_text,
subject=c.subject,
scope=c.scope,
exam_round=c.exam_round,
exam_question_number=c.exam_question_number,
similarity=round(c.similarity, 4),
)
for c in cls.similar
],
repeat_related_count=cls.repeat_related_count,
repeat_round_count=cls.repeat_round_count,
similar_related_count=cls.similar_related_count,
similar_round_count=cls.similar_round_count,
)
# ─── PR-8: 이미지 업로드/조회/삭제 ───
+55
View File
@@ -1658,3 +1658,58 @@ async def patch_quiz_session(
qs.updated_at = datetime.now(timezone.utc)
await session.commit()
return await _build_session_summary(qs, session)
# ─── PR-12-A: 반복 출제 / 유사 유형 배치 카운트 ───
class RelatedTypesBulkRequest(BaseModel):
question_ids: list[int] = Field(..., min_length=1, max_length=200)
class RelatedTypesBulkItem(BaseModel):
repeat_related_count: int
repeat_round_count: int
similar_related_count: int
similar_round_count: int
class RelatedTypesBulkResponse(BaseModel):
# 입력 question_ids 전체 보존 — 권한 없음/임베딩 미준비/회차 미지정도 (0,0,0,0).
items: dict[int, RelatedTypesBulkItem]
@router.post("/{topic_id}/related-types-bulk", response_model=RelatedTypesBulkResponse)
async def related_types_bulk(
topic_id: int,
body: RelatedTypesBulkRequest,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""카드별 배지(round_count) 표시용 배치 카운트.
비교 대상 = 같은 topic 안 모든 ready 문제 (입력 qid 끼리만 비교 X).
응답 dict 는 입력 qid 전체 보존 — 누락 X.
"""
from services.study.related_types import classify_related_bulk
topic = await session.get(StudyTopic, topic_id)
_verify_topic_ownership(topic, user)
counts = await classify_related_bulk(
session,
user_id=user.id,
study_topic_id=topic_id,
question_ids=body.question_ids,
)
return RelatedTypesBulkResponse(
items={
qid: RelatedTypesBulkItem(
repeat_related_count=c.repeat_related_count,
repeat_round_count=c.repeat_round_count,
similar_related_count=c.similar_related_count,
similar_round_count=c.similar_round_count,
)
for qid, c in counts.items()
}
)
+276
View File
@@ -0,0 +1,276 @@
"""반복 출제 / 유사 유형 분류 service (PR-12-A).
학습 의미가 회차 기반이라 응답이 의미. 회차 조건은 백엔드가 강제한다.
단건 endpoint(`/related-types`) + 배치 endpoint(`/related-types-bulk`)
같은 service 함수를 공유해 threshold/round_count 정의가 drift 나지 않게 한다.
분류:
🔥 반복 출제 similarity >= 0.95, 다른 exam_round
🧩 유사 유형 0.88 <= similarity < 0.95, 다른 exam_round
round_count 정의:
관련 후보가 0 이면 round_count 0 (현재 회차만 1 채우지 않는다).
관련 후보가 있으면 distinct(candidate.exam_round) {source.exam_round} 크기.
"""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from models.study_question import StudyQuestion
# 임계값 / 응답 cap (튜닝 후보 — 한 곳에서만 정의)
REPEAT_THRESHOLD = 0.95
SIMILAR_THRESHOLD = 0.88
REPEAT_K = 10
SIMILAR_K = 8
RELATED_QUERY_LIMIT = 50 # cosine top-K 1차 fetch (회차 필터 + threshold split 후 K cap)
@dataclass
class RelatedCandidate:
"""단건 endpoint 응답 item 직렬화 직전 단계."""
id: int
study_topic_id: int
question_text: str
subject: str | None
scope: str | None
exam_round: str | None
exam_question_number: int | None
similarity: float
@dataclass
class RelatedClassification:
"""단건 endpoint 응답에 그대로 매핑되는 결과 묶음."""
repeat: list[RelatedCandidate]
similar: list[RelatedCandidate]
repeat_related_count: int
repeat_round_count: int
similar_related_count: int
similar_round_count: int
def _norm_round(s: str | None) -> str:
return (s or "").strip()
def _truncate(text: str, n: int = 80) -> str:
if not text:
return ""
s = text.strip()
return s if len(s) <= n else s[:n].rstrip() + ""
def _round_count(related: list[RelatedCandidate], source_round: str) -> int:
"""관련 후보가 없으면 0. 있으면 distinct(candidate.exam_round) {source} 크기.
candidate.exam_round 이미 trim source 다른 값으로 필터된 상태.
"""
if not related:
return 0
rounds: set[str] = {_norm_round(source_round)}
for c in related:
nr = _norm_round(c.exam_round)
if nr:
rounds.add(nr)
return len(rounds)
async def classify_related_for_question(
session: AsyncSession,
*,
user_id: int,
source: StudyQuestion,
) -> RelatedClassification:
"""단건 분류. source 의 임베딩으로 같은 토픽 안 다른 회차 candidate 끌어와 임계값 split.
호출자는 source ownership 미리 검증해야 한다. 함수는 user_id 비교만 강제.
"""
src_round = _norm_round(source.exam_round)
# 가드 1: 임베딩 미준비
if source.embedding_status != "ready" or source.embedding is None:
return RelatedClassification([], [], 0, 0, 0, 0)
# 가드 2: source 회차 미지정 → 회차 간 반복성 분석 불가
if not src_round:
return RelatedClassification([], [], 0, 0, 0, 0)
distance_expr = StudyQuestion.embedding.cosine_distance(source.embedding)
stmt = (
select(StudyQuestion, distance_expr.label("distance"))
.where(
StudyQuestion.id != source.id,
StudyQuestion.user_id == user_id,
StudyQuestion.study_topic_id == source.study_topic_id,
StudyQuestion.deleted_at.is_(None),
StudyQuestion.embedding_status == "ready",
StudyQuestion.embedding.is_not(None),
StudyQuestion.exam_round.is_not(None),
func.btrim(StudyQuestion.exam_round) != "",
func.btrim(StudyQuestion.exam_round) != src_round,
)
.order_by(distance_expr.asc())
.limit(RELATED_QUERY_LIMIT)
)
rows = (await session.execute(stmt)).all()
repeat: list[RelatedCandidate] = []
similar: list[RelatedCandidate] = []
for q, distance in rows:
sim = 1.0 - float(distance)
if sim >= REPEAT_THRESHOLD:
target = repeat
elif sim >= SIMILAR_THRESHOLD:
target = similar
else:
# cosine asc 정렬이라 한 번 임계값 미만이면 이후도 다 미만.
break
target.append(RelatedCandidate(
id=q.id,
study_topic_id=q.study_topic_id,
question_text=_truncate(q.question_text, 80),
subject=q.subject,
scope=q.scope,
exam_round=q.exam_round,
exam_question_number=q.exam_question_number,
similarity=sim,
))
# round_count 는 K cap 전 전체 후보 기준 (배지 일관성 유지용).
repeat_round = _round_count(repeat, src_round)
similar_round = _round_count(similar, src_round)
# 응답 리스트만 K cap (count 는 cap 전 기준 유지).
repeat_display = repeat[:REPEAT_K]
similar_display = similar[:SIMILAR_K]
return RelatedClassification(
repeat=repeat_display,
similar=similar_display,
repeat_related_count=len(repeat_display),
repeat_round_count=repeat_round,
similar_related_count=len(similar_display),
similar_round_count=similar_round,
)
@dataclass
class BulkCounts:
repeat_related_count: int
repeat_round_count: int
similar_related_count: int
similar_round_count: int
_ZERO_COUNTS = BulkCounts(0, 0, 0, 0)
async def classify_related_bulk(
session: AsyncSession,
*,
user_id: int,
study_topic_id: int,
question_ids: list[int],
) -> dict[int, BulkCounts]:
"""배치 카운트. 입력 question_ids 끼리 비교가 아니라 토픽 전체 ready pool 과 비교.
응답 dict 입력한 모든 qid 키로 보존 (권한 없음/임베딩 미준비/회차 미지정 0,0,0,0).
"""
if not question_ids:
return {}
# 입력 qid 의 임베딩 + exam_round 모두 fetch (권한 + 토픽 일치 강제).
input_rows = (
await session.execute(
select(
StudyQuestion.id,
StudyQuestion.embedding,
StudyQuestion.embedding_status,
StudyQuestion.exam_round,
)
.where(
StudyQuestion.id.in_(question_ids),
StudyQuestion.user_id == user_id,
StudyQuestion.study_topic_id == study_topic_id,
StudyQuestion.deleted_at.is_(None),
)
)
).all()
input_map = {r.id: r for r in input_rows}
# 토픽 전체 ready pool 한 번에 (회차 trim 후 non-empty).
pool_rows = (
await session.execute(
select(
StudyQuestion.id,
StudyQuestion.embedding,
StudyQuestion.exam_round,
)
.where(
StudyQuestion.user_id == user_id,
StudyQuestion.study_topic_id == study_topic_id,
StudyQuestion.deleted_at.is_(None),
StudyQuestion.embedding_status == "ready",
StudyQuestion.embedding.is_not(None),
StudyQuestion.exam_round.is_not(None),
func.btrim(StudyQuestion.exam_round) != "",
)
)
).all()
pool = [(r.id, list(r.embedding), _norm_round(r.exam_round)) for r in pool_rows]
out: dict[int, BulkCounts] = {qid: _ZERO_COUNTS for qid in question_ids}
if not pool:
return out
# cosine: a·b / (|a||b|). pool 벡터 norm 미리 계산.
import math
pool_norms = [math.sqrt(sum(v * v for v in vec)) or 1e-9 for _, vec, _ in pool]
for qid in question_ids:
row = input_map.get(qid)
if row is None:
continue
if row.embedding_status != "ready" or row.embedding is None:
continue
src_round = _norm_round(row.exam_round)
if not src_round:
continue
src_vec = list(row.embedding)
src_norm = math.sqrt(sum(v * v for v in src_vec)) or 1e-9
repeat_rounds: set[str] = set()
similar_rounds: set[str] = set()
repeat_n = 0
similar_n = 0
for (cid, cvec, cround), cnorm in zip(pool, pool_norms, strict=True):
if cid == qid:
continue
if cround == src_round:
continue
dot = 0.0
for a, b in zip(src_vec, cvec, strict=True):
dot += a * b
sim = dot / (src_norm * cnorm)
if sim >= REPEAT_THRESHOLD:
repeat_n += 1
repeat_rounds.add(cround)
elif sim >= SIMILAR_THRESHOLD:
similar_n += 1
similar_rounds.add(cround)
out[qid] = BulkCounts(
repeat_related_count=repeat_n,
repeat_round_count=(len(repeat_rounds) + 1) if repeat_n > 0 else 0,
similar_related_count=similar_n,
similar_round_count=(len(similar_rounds) + 1) if similar_n > 0 else 0,
)
return out
@@ -30,6 +30,10 @@
// PR-11: 문제 섹션 회차별 그룹 expand/collapse 상태. key = exam_round 표시 문자열.
let roundsExpanded = $state({});
// PR-12-A: 회차별 첫 expand 시 한 번만 bulk fetch — qid → {repeat_round_count, similar_round_count, ...}
let relatedCounts = $state({});
let roundsRelatedFetched = $state({}); // exam_round 표시 키 단위 fetch 여부
// 자료 추가 모달
let docModalOpen = $state(false);
let docSearch = $state('');
@@ -398,18 +402,47 @@
}
function toggleRound(key) {
roundsExpanded = { ...roundsExpanded, [key]: !roundsExpanded[key] };
const willExpand = !roundsExpanded[key];
roundsExpanded = { ...roundsExpanded, [key]: willExpand };
if (willExpand) {
void fetchRoundRelatedCounts(key);
}
}
function expandAllRounds() {
const next = {};
for (const [k] of questionsByRound) next[k] = true;
for (const [k] of questionsByRound) {
next[k] = true;
void fetchRoundRelatedCounts(k);
}
roundsExpanded = next;
}
function collapseAllRounds() {
roundsExpanded = {};
}
/** PR-12-A: 회차 expand 첫 진입 시 그 회차 안 카드 qid 들의 round_count 배지 batch fetch.
* 백엔드는 입력 qid 끼리만 비교하지 않고 topic 전체 ready pool 과 비교 — 다른 회차 출제 여부 잡힘.
* 같은 회차 다시 접고 펴도 추가 호출 안 함 (roundsRelatedFetched 캐시). */
async function fetchRoundRelatedCounts(roundKey) {
if (roundsRelatedFetched[roundKey]) return;
const group = (questionsByRound.find(([k]) => k === roundKey) ?? [, []])[1];
const ids = group.map((q) => q.id);
if (ids.length === 0) return;
roundsRelatedFetched = { ...roundsRelatedFetched, [roundKey]: true };
try {
const res = await api(`/study-topics/${topicId}/related-types-bulk`, {
method: 'POST',
body: JSON.stringify({ question_ids: ids }),
});
relatedCounts = { ...relatedCounts, ...(res.items ?? {}) };
} catch {
// 실패 시 다음 expand 에서 재시도 가능하게 캐시 플래그 해제.
const { [roundKey]: _, ...rest } = roundsRelatedFetched;
roundsRelatedFetched = rest;
}
}
</script>
<svelte:head>
@@ -743,6 +776,7 @@
{/if}
<div class="px-2 pb-2 flex flex-col gap-1.5">
{#each items as q (q.id)}
{@const rc = relatedCounts[q.id] ?? null}
<!-- 본문 링크와 [편집] 링크는 형제 구조로 분리해 이벤트 버블링 충돌 회피. -->
<div class="flex items-stretch gap-1 rounded border border-default bg-surface hover:border-accent/40 transition-colors overflow-hidden">
<a
@@ -756,6 +790,13 @@
<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}
<!-- PR-12-A: 반복 출제 / 유사 유형 배지 — round_count >= 2 일 때만 -->
{#if rc?.repeat_round_count >= 2}
<span class="text-error/90">🔥 {rc.repeat_round_count}개 회차</span>
{/if}
{#if rc?.similar_round_count >= 2}
<span class="text-accent/80">🧩 {rc.similar_round_count}개 회차</span>
{/if}
{#if q.attempt_count > 0}
<span class="ml-auto flex items-center gap-1">
{#if q.last_correct === true}
@@ -22,7 +22,7 @@
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import {
ArrowLeft, ArrowRight, Edit, Sparkles, GitCompare, AlertCircle, CheckCircle2, XCircle, ListChecks,
ArrowLeft, ArrowRight, Edit, Sparkles, AlertCircle, CheckCircle2, XCircle, ListChecks,
} from 'lucide-svelte';
import { renderMathMarkdown, renderMathMarkdownInline } from '$lib/utils/mathMarkdown';
import Button from '$lib/components/ui/Button.svelte';
@@ -46,11 +46,9 @@
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');
// PR-12-A: 반복 출제 / 유사 유형 (자동 fetch). PR-5 의 비슷한 문제 토글 대체.
let relatedLoading = $state(false);
let related = $state(null); // RelatedTypesResponse
async function loadTopic() {
try {
@@ -94,9 +92,9 @@
aiState = 'idle';
aiData = null;
aiError = null;
// simExplOpen reset
simOpen = false; simItems = []; simSourceStatus = 'none';
await loadRoundSiblings();
related = null;
// 회차 prev/next + 반복 출제/유사 유형 병렬 로드.
await Promise.all([loadRoundSiblings(), loadRelatedTypes()]);
} catch (err) {
addToast('error', err?.detail || '문제 로드 실패');
q = null;
@@ -105,6 +103,18 @@
}
}
/** PR-12-A: 반복 출제 / 유사 유형 분리. 회차 조건은 백엔드가 강제. */
async function loadRelatedTypes() {
relatedLoading = true;
try {
related = await api(`/study-questions/${qid}/related-types`);
} catch {
related = null;
} finally {
relatedLoading = false;
}
}
onMount(async () => {
await loadTopic();
await load();
@@ -145,22 +155,6 @@
}
}
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 },
@@ -331,54 +325,8 @@
오답 {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>
<!-- PR-12-A: 반복 출제 / 유사 유형 -->
{@render relatedSections()}
<!-- 푸터 네비게이션 -->
<div class="flex items-center justify-between gap-2 pt-3 border-t border-default flex-wrap">
@@ -413,3 +361,103 @@
</Card>
{/if}
</div>
<!-- PR-12-A: 반복 출제 (🔥) + 유사 유형 (🧩) 분리 표시. 회차 조건 백엔드 강제. -->
{#snippet relatedSections()}
{@const repeats = related?.repeat_questions ?? []}
{@const sims = related?.similar_questions ?? []}
{@const repeatRound = related?.repeat_round_count ?? 0}
{@const similarRound = related?.similar_round_count ?? 0}
{@const repeatRel = related?.repeat_related_count ?? 0}
{@const similarRel = related?.similar_related_count ?? 0}
{@const sourceStatus = related?.source_status ?? 'none'}
{@const sourceRound = related?.source_exam_round ?? null}
{#if relatedLoading && !related}
<div class="text-[11px] text-dim">반복 출제·유사 유형 분석 중…</div>
{:else if related}
{#if sourceStatus !== 'ready'}
<div class="rounded border border-default bg-bg/30 p-3 text-[11px] text-dim">
{#if sourceStatus === 'pending'}임베딩 생성 중입니다. 잠시 후 다시.
{:else if sourceStatus === 'failed'}임베딩 생성 실패. 다음 cron 자동 재시도.
{:else if sourceStatus === 'stale'}본문 변경 후 임베딩 재계산 대기 중.
{:else}임베딩 미생성. 약 1분 안에 cron 처리.
{/if}
</div>
{:else if !sourceRound}
<div class="rounded border border-default bg-bg/30 p-3 text-[11px] text-dim">
회차가 지정되지 않은 문제는 회차 간 반복 출제 분석을 할 수 없습니다.
</div>
{:else if repeats.length === 0 && sims.length === 0}
<!-- 둘 다 없으면 섹션 자체 숨김 -->
{:else}
{#if repeats.length > 0}
<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">
<span class="text-base">🔥</span>
<span class="text-xs font-semibold text-text">반복 출제</span>
<span class="text-[11px] text-dim">· {repeatRound}개 회차</span>
</div>
<div class="text-[11px] text-dim">
거의 같은 형태의 문제가 여러 회차에 출제되었습니다. 문제와 정답 구조를 확실히 기억해두는 것이 좋습니다.
</div>
<div class="text-[10px] text-dim">관련 반복 문제 {repeatRel}</div>
<ul class="flex flex-col gap-1.5">
{#each repeats as it (it.id)}
<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">
{#if it.exam_round}<span class="text-dim">{it.exam_round}{#if it.exam_question_number} {it.exam_question_number}{/if} ·</span> {/if}{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}
</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>
{/each}
</ul>
</div>
{/if}
{#if sims.length > 0}
<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">
<span class="text-base">🧩</span>
<span class="text-xs font-semibold text-text">유사 유형</span>
<span class="text-[11px] text-dim">· {similarRound}개 회차</span>
</div>
<div class="text-[11px] text-dim">
보기나 수치는 다르지만 같은 개념을 묻는 문제입니다. 공식 적용 방식과 조건 해석을 함께 복습하세요.
</div>
<div class="text-[10px] text-dim">관련 {similarRel}문제</div>
<ul class="flex flex-col gap-1.5">
{#each sims as it (it.id)}
<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">
{#if it.exam_round}<span class="text-dim">{it.exam_round}{#if it.exam_question_number} {it.exam_question_number}{/if} ·</span> {/if}{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}
</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>
{/each}
</ul>
</div>
{/if}
{/if}
{/if}
{/snippet}
@@ -33,6 +33,9 @@
let activeTab = $state('wrong'); // 'correct' | 'wrong' | 'unsure'
let perCard = $state({}); // { [qid]: { open, kind, loading, error, data } }
// PR-12-A: 카드별 round_count 배지 (틀린/모르겠음 헤더에 표시).
let relatedCounts = $state({}); // { [qid]: { repeat_round_count, similar_round_count, ... } }
async function loadTopic() {
try {
const t = await api(`/study-topics/${topicId}`);
@@ -48,6 +51,8 @@
if ((detail.summary.wrong_count ?? 0) > 0) activeTab = 'wrong';
else if ((detail.summary.unsure_count ?? 0) > 0) activeTab = 'unsure';
else activeTab = 'correct';
// PR-12-A: 카드별 반복 출제/유사 유형 배지 — 1회 bulk 호출.
void loadRelatedCounts();
} catch (err) {
addToast('error', err?.detail || '결과 로드 실패');
detail = null;
@@ -56,6 +61,20 @@
}
}
async function loadRelatedCounts() {
if (!detail?.attempts?.length) return;
const ids = detail.attempts.map((a) => a.question_id);
try {
const res = await api(`/study-topics/${topicId}/related-types-bulk`, {
method: 'POST',
body: JSON.stringify({ question_ids: ids }),
});
relatedCounts = res.items ?? {};
} catch {
relatedCounts = {};
}
}
onMount(async () => {
await loadTopic();
await load();
@@ -287,6 +306,7 @@
{@const cardState = perCard[it.q.id] ?? {}}
{@const isOpen = cardState.open === true}
{@const reviewed = !!it.attempt.reviewed_at}
{@const rc = relatedCounts[it.q.id] ?? null}
<li class="rounded border bg-surface overflow-hidden
{kind !== 'correct' && reviewed ? 'border-success/30 bg-success/5' : 'border-default'}">
<button
@@ -300,6 +320,13 @@
{#if it.q.subject}<span>{it.q.subject}</span>{/if}
{#if it.q.scope}<span>· {it.q.scope}</span>{/if}
{#if it.q.exam_round}<span>· {it.q.exam_round}</span>{/if}
<!-- PR-12-A: 반복 출제 / 유사 유형 배지 — round_count >= 2 일 때만 -->
{#if rc?.repeat_round_count >= 2}
<span class="text-error/90">🔥 반복 출제 · {rc.repeat_round_count}개 회차</span>
{/if}
{#if rc?.similar_round_count >= 2}
<span class="text-accent/80">🧩 유사 유형 · {rc.similar_round_count}개 회차</span>
{/if}
<span class="ml-auto">
{#if kind === 'correct'}
<span class="text-success">선택 {it.attempt.selected_choice}번 = 정답</span>