feat(study): 비슷한 문제 검색 (PR-5)

study_questions 자동 임베딩(PR-4 bge-m3 1024차원) 기반 cosine 유사도
top-K. 26B 호출 없음, vector search 만. additive UI — 기존 입력·복습
흐름 영향 없음.

백엔드: GET /api/study-questions/{id}/similar?limit=5&topic_only=true
- 자기 자신/soft-deleted/embedding_status!=ready 제외
- topic_only=true (default) 면 같은 study_topic 안에서만
- 응답: items[{id, question_text(80자 truncate), subject, scope, exam_round,
  similarity(1-cosine), attempt_count, last_correct}], source_status, source_id
- 현재 문제 embedding 미생성/실패/stale 시 빈 결과 + source_status 안내
- attempt_count + last_correct batch 조회 (N+1 회피)

프론트:
- 편집 화면(/questions/[qid]/edit): 페이지 로드 시 자동 GET /similar →
  카드 5개. 본문 truncate + subject/scope/exam_round + 유사도 % + attempt
  배지 (정/오답 아이콘). 카드 클릭 시 해당 문제 편집 페이지로 이동.
- 복습 화면(/review): 답 제출 후 "비슷한 문제 보기" 토글 → expand 5개 카드.
  같은 형태. 다음 문제로 cursor 이동 시 자동 닫힘.
- 통합뷰: 변경 없음 (이미 편집 진입점이 시각적 cue 역할).

source_status별 안내 (pending/failed/stale/none): 임베딩이 아직 준비 안 됐을
때 "약 1분 안에 cron 자동 처리" 메시지 노출.

후속 PR 예정: subject/scope 자동 추천(PR-6), 오답노트/통계(PR-7),
AI 풀이 idle batch(PR-8). 현재 PR-5 는 vector search 결과 노출까지만.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-28 09:05:55 +09:00
parent 5a8c7595d7
commit 5b55274368
3 changed files with 313 additions and 2 deletions
+135
View File
@@ -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, 안전 마진.
@@ -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}
</Card>
<!-- PR-5: 비슷한 문제 5개 (embedding cosine, 26B 호출 없음) -->
<Card class="mb-3">
{#snippet children()}
<div class="p-4 flex flex-col gap-3">
<div class="flex items-center gap-2">
<GitCompare size={16} class="text-accent" />
<span class="text-sm font-semibold text-text">비슷한 문제</span>
<span class="text-[11px] text-dim">{similarItems.length}건</span>
</div>
{#if similarLoading}
<div class="text-xs text-dim">불러오는 중…</div>
{:else if similarSourceStatus !== 'ready'}
<div class="text-xs text-dim">
{#if similarSourceStatus === 'pending'}
임베딩 생성 중입니다. 잠시 후 새로고침하면 비슷한 문제가 표시됩니다.
{:else if similarSourceStatus === 'failed'}
임베딩 생성 실패. 다음 cron 틱(약 1분)에 자동 재시도됩니다.
{:else if similarSourceStatus === 'stale'}
문제 본문 변경 후 임베딩 재계산 대기 중입니다. 잠시 후 새로고침해주세요.
{:else}
임베딩 미생성 (none). 약 1분 안에 cron 이 자동 처리합니다.
{/if}
</div>
{:else if similarItems.length === 0}
<div class="text-xs text-dim">이 주제에 비슷한 문제가 아직 없습니다.</div>
{:else}
<ul class="flex flex-col gap-1.5">
{#each similarItems as it (it.id)}
<li>
<a
href={`/study/topics/${it.study_topic_id}/questions/${it.id}/edit`}
class="flex items-center gap-3 p-2.5 rounded border border-default bg-bg/30 hover:border-accent hover:bg-accent/5 transition-colors"
>
<div class="flex-1 min-w-0">
<div class="text-sm text-text truncate">{it.question_text}</div>
<div class="text-[11px] 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}
{#if it.attempt_count > 0}
<span class="ml-auto flex items-center gap-1">
{#if it.last_correct === true}
<CheckCircle2 size={11} class="text-success" />
{:else if it.last_correct === false}
<XCircle size={11} class="text-error" />
{/if}
<span>{it.attempt_count}</span>
</span>
{/if}
</div>
</div>
<span class="text-[11px] text-accent font-medium shrink-0">{Math.round(it.similarity * 100)}%</span>
<ArrowRight size={12} class="text-dim shrink-0" />
</a>
</li>
{/each}
</ul>
{/if}
</div>
{/snippet}
</Card>
<div class="flex items-center justify-between gap-2 flex-wrap">
<div class="flex gap-2">
<Button href={`/study/topics/${topicId}`} variant="ghost" icon={ArrowLeft}>주제로</Button>
@@ -11,7 +11,7 @@
import { page } from '$app/stores';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import { ArrowLeft, Play, CheckCircle2, XCircle, RotateCcw, Sparkles, AlertCircle } from 'lucide-svelte';
import { ArrowLeft, Play, CheckCircle2, XCircle, RotateCcw, Sparkles, AlertCircle, GitCompare, ArrowRight } 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';
@@ -54,6 +54,38 @@
aiExpl = null;
aiExplError = null;
aiExplLoading = false;
// PR-5: 비슷한 문제 expand 도 함께 reset
simOpen = false;
simItems = [];
simLoading = false;
simSourceStatus = 'none';
}
// PR-5: 비슷한 문제 더 풀기
let simOpen = $state(false);
let simLoading = $state(false);
let simItems = $state([]);
let simSourceStatus = $state('none');
async function toggleSimilar() {
if (simOpen) {
simOpen = false;
return;
}
simOpen = true;
if (simItems.length > 0) return; // 이미 로드됨
const q = questions[cursor];
if (!q) return;
simLoading = true;
try {
const res = await api(`/study-questions/${q.id}/similar?limit=5&topic_only=true`);
simItems = res.items ?? [];
simSourceStatus = res.source_status ?? 'none';
} catch {
simItems = [];
} finally {
simLoading = false;
}
}
async function loadAiExplanation(regenerate = false) {
@@ -343,6 +375,67 @@
{/if}
</div>
<!-- PR-5: 비슷한 문제 더 풀기 (embedding cosine, 26B 호출 없음) -->
<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}/edit`}
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.attempt_count > 0}
<span class="ml-auto flex items-center gap-1">
{#if it.last_correct === true}
<CheckCircle2 size={10} class="text-success" />
{:else if it.last_correct === false}
<XCircle size={10} class="text-error" />
{/if}
<span>{it.attempt_count}</span>
</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 justify-end">
<Button onclick={next}>{cursor + 1 >= questions.length ? '결과 보기' : '다음 문제'}</Button>
</div>