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:
@@ -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: 이미지 업로드/조회/삭제 ───
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user