diff --git a/app/api/study_questions.py b/app/api/study_questions.py
index ad533a8..2ae5d93 100644
--- a/app/api/study_questions.py
+++ b/app/api/study_questions.py
@@ -178,6 +178,28 @@ class AttemptResponse(BaseModel):
stats: QuestionAttemptStats
+# ─── PR-5: 비슷한 문제 검색 (embedding cosine) ───
+
+
+class SimilarQuestionItem(BaseModel):
+ id: int
+ study_topic_id: int
+ question_text: str # 80자 truncate
+ subject: str | None
+ scope: str | None
+ exam_name: str | None
+ exam_round: str | None
+ similarity: float # 1 - cosine_distance, 1.0=동일, 0.0=무관
+ attempt_count: int
+ last_correct: bool | None
+
+
+class SimilarQuestionsResponse(BaseModel):
+ items: list[SimilarQuestionItem]
+ source_status: str # 현재 문제의 embedding_status. 'ready' 가 아니면 빈 결과.
+ source_id: int
+
+
# ─── PR-3: AI 풀이 생성 ───
@@ -714,6 +736,119 @@ async def submit_attempt(
)
+# ─── PR-5: 비슷한 문제 검색 (embedding cosine) ───
+
+
+@router.get(
+ "/study-questions/{question_id}/similar",
+ response_model=SimilarQuestionsResponse,
+)
+async def list_similar_questions(
+ question_id: int,
+ user: Annotated[User, Depends(get_current_user)],
+ session: Annotated[AsyncSession, Depends(get_session)],
+ limit: int = Query(5, ge=1, le=20),
+ topic_only: bool = Query(True, description="True 면 같은 study_topic 안에서만"),
+):
+ """현재 문제의 embedding 기준 cosine 유사도 top-K. 26B 호출 없이 vector search 만.
+
+ 제외:
+ - 자기 자신 (id != current)
+ - soft-deleted
+ - embedding_status != 'ready' (대기 중·실패·미생성)
+
+ source_status='ready' 가 아니면 items 빈 배열 반환 (UI 에서 안내).
+ """
+ src = await session.get(StudyQuestion, question_id)
+ src = _verify_question_ownership(src, user)
+
+ if src.embedding_status != "ready" or src.embedding is None:
+ return SimilarQuestionsResponse(
+ items=[], source_status=src.embedding_status, source_id=question_id
+ )
+
+ # cosine 거리 = embedding <=> ref. 유사도 = 1 - distance.
+ distance_expr = StudyQuestion.embedding.cosine_distance(src.embedding)
+ base = (
+ select(StudyQuestion, distance_expr.label("distance"))
+ .where(
+ StudyQuestion.user_id == user.id,
+ StudyQuestion.id != question_id,
+ StudyQuestion.deleted_at.is_(None),
+ StudyQuestion.embedding_status == "ready",
+ StudyQuestion.embedding.is_not(None),
+ )
+ .order_by(distance_expr.asc())
+ .limit(limit)
+ )
+ if topic_only:
+ base = base.where(StudyQuestion.study_topic_id == src.study_topic_id)
+
+ rows = (await session.execute(base)).all()
+ similar_qs = [(r[0], float(r.distance)) for r in rows]
+
+ # attempt_count + last_correct batch (N+1 회피)
+ qids = [q.id for q, _ in similar_qs]
+ attempt_count_map: dict[int, int] = {}
+ last_correct_map: dict[int, bool] = {}
+ if qids:
+ cnt_rows = (
+ await session.execute(
+ select(
+ StudyQuestionAttempt.study_question_id,
+ func.count().label("total"),
+ )
+ .where(
+ StudyQuestionAttempt.user_id == user.id,
+ StudyQuestionAttempt.study_question_id.in_(qids),
+ )
+ .group_by(StudyQuestionAttempt.study_question_id)
+ )
+ ).all()
+ for r in cnt_rows:
+ attempt_count_map[r.study_question_id] = int(r.total)
+
+ latest_rows = (
+ await session.execute(
+ select(
+ StudyQuestionAttempt.study_question_id,
+ StudyQuestionAttempt.is_correct,
+ )
+ .where(
+ StudyQuestionAttempt.user_id == user.id,
+ StudyQuestionAttempt.study_question_id.in_(qids),
+ )
+ .order_by(
+ StudyQuestionAttempt.study_question_id,
+ StudyQuestionAttempt.answered_at.desc(),
+ )
+ .distinct(StudyQuestionAttempt.study_question_id)
+ )
+ ).all()
+ for r in latest_rows:
+ last_correct_map[r.study_question_id] = bool(r.is_correct)
+
+ def _truncate(text: str, n: int = 80) -> str:
+ return text if len(text) <= n else text[:n].rstrip() + "…"
+
+ items = [
+ SimilarQuestionItem(
+ id=q.id,
+ study_topic_id=q.study_topic_id,
+ question_text=_truncate(q.question_text, 80),
+ subject=q.subject,
+ scope=q.scope,
+ exam_name=q.exam_name,
+ exam_round=q.exam_round,
+ similarity=round(1.0 - dist, 4),
+ attempt_count=attempt_count_map.get(q.id, 0),
+ last_correct=last_correct_map.get(q.id),
+ )
+ for q, dist in similar_qs
+ ]
+ return SimilarQuestionsResponse(items=items, source_status="ready", source_id=question_id)
+
+
# ─── PR-3: AI 풀이 생성 엔드포인트 ───
# MLX 호출 timeout (초). MLX gate + 26B 추론 평균 ~10s, 안전 마진.
diff --git a/frontend/src/routes/study/topics/[id]/questions/[qid]/edit/+page.svelte b/frontend/src/routes/study/topics/[id]/questions/[qid]/edit/+page.svelte
index ab201f9..17ba13d 100644
--- a/frontend/src/routes/study/topics/[id]/questions/[qid]/edit/+page.svelte
+++ b/frontend/src/routes/study/topics/[id]/questions/[qid]/edit/+page.svelte
@@ -11,7 +11,7 @@
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
- import { ArrowLeft, Save, Trash2, AlertCircle, Sparkles } from 'lucide-svelte';
+ import { ArrowLeft, Save, Trash2, AlertCircle, Sparkles, GitCompare, ArrowRight, CheckCircle2, XCircle } 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';
@@ -35,6 +35,24 @@
let is_active = $state(true);
let stats = $state({ attempt_count: 0, correct_count: 0, wrong_count: 0 });
+ // PR-5: 비슷한 문제 검색 결과
+ let similarLoading = $state(false);
+ let similarItems = $state([]);
+ let similarSourceStatus = $state('none');
+
+ async function loadSimilar() {
+ similarLoading = true;
+ try {
+ const res = await api(`/study-questions/${questionId}/similar?limit=5&topic_only=true`);
+ similarItems = res.items ?? [];
+ similarSourceStatus = res.source_status ?? 'none';
+ } catch {
+ similarItems = [];
+ } finally {
+ similarLoading = false;
+ }
+ }
+
// PR-3: AI 풀이 상태 + 본문 캐시
let aiStatus = $state('none'); // none | pending | ready | failed | stale
let aiGeneratedAt = $state(null);
@@ -93,6 +111,8 @@
aiStatus = q.ai_explanation_status ?? 'none';
aiGeneratedAt = q.ai_explanation_generated_at;
aiModel = q.ai_explanation_model;
+ // PR-5: 비슷한 문제 자동 로드 (페이지 로드와 병렬)
+ loadSimilar();
} catch (err) {
addToast('error', err.detail || '문제 로딩 실패');
} finally {
@@ -288,6 +308,69 @@
{/snippet}
+
+
+ {#each similarItems as it (it.id)}
+
+ {/if}
+