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} + + + {#snippet children()} +
+
+ + 비슷한 문제 + {similarItems.length}건 +
+ + {#if similarLoading} +
불러오는 중…
+ {:else if similarSourceStatus !== 'ready'} +
+ {#if similarSourceStatus === 'pending'} + 임베딩 생성 중입니다. 잠시 후 새로고침하면 비슷한 문제가 표시됩니다. + {:else if similarSourceStatus === 'failed'} + 임베딩 생성 실패. 다음 cron 틱(약 1분)에 자동 재시도됩니다. + {:else if similarSourceStatus === 'stale'} + 문제 본문 변경 후 임베딩 재계산 대기 중입니다. 잠시 후 새로고침해주세요. + {:else} + 임베딩 미생성 (none). 약 1분 안에 cron 이 자동 처리합니다. + {/if} +
+ {:else if similarItems.length === 0} +
이 주제에 비슷한 문제가 아직 없습니다.
+ {:else} + + {/if} +
+ {/snippet} +
+
diff --git a/frontend/src/routes/study/topics/[id]/review/+page.svelte b/frontend/src/routes/study/topics/[id]/review/+page.svelte index e30b1ba..4e94cd3 100644 --- a/frontend/src/routes/study/topics/[id]/review/+page.svelte +++ b/frontend/src/routes/study/topics/[id]/review/+page.svelte @@ -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}
+ +
+
+ + 비슷한 문제 더 풀기 + {#if simItems.length > 0} + {simItems.length}건 + {/if} + + + +
+ + {#if simOpen} + {#if simSourceStatus !== 'ready'} +
+ {#if simSourceStatus === 'pending'}임베딩 생성 중입니다. 잠시 후 다시. + {:else if simSourceStatus === 'failed'}임베딩 생성 실패. 다음 cron 자동 재시도. + {:else if simSourceStatus === 'stale'}본문 변경 후 임베딩 재계산 대기 중. + {:else}임베딩 미생성. 약 1분 안에 cron 처리. + {/if} +
+ {:else if simItems.length === 0} +
이 주제에 비슷한 문제가 없습니다.
+ {:else} + + {/if} + {/if} +
+