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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user