diff --git a/app/api/study_questions.py b/app/api/study_questions.py index 5531f99..ce19e2c 100644 --- a/app/api/study_questions.py +++ b/app/api/study_questions.py @@ -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: 이미지 업로드/조회/삭제 ─── diff --git a/app/api/study_topics.py b/app/api/study_topics.py index 5fd871d..993e535 100644 --- a/app/api/study_topics.py +++ b/app/api/study_topics.py @@ -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() + } + ) diff --git a/app/services/study/related_types.py b/app/services/study/related_types.py new file mode 100644 index 0000000..fca3b1a --- /dev/null +++ b/app/services/study/related_types.py @@ -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 diff --git a/frontend/src/routes/study/topics/[id]/+page.svelte b/frontend/src/routes/study/topics/[id]/+page.svelte index 7993ca5..b3c457a 100644 --- a/frontend/src/routes/study/topics/[id]/+page.svelte +++ b/frontend/src/routes/study/topics/[id]/+page.svelte @@ -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; + } + } @@ -743,6 +776,7 @@ {/if}
{#each items as q (q.id)} + {@const rc = relatedCounts[q.id] ?? null}
{#if q.subject}{q.subject}{/if} {#if q.scope}· {q.scope}{/if} + + {#if rc?.repeat_round_count >= 2} + 🔥 {rc.repeat_round_count}개 회차 + {/if} + {#if rc?.similar_round_count >= 2} + 🧩 {rc.similar_round_count}개 회차 + {/if} {#if q.attempt_count > 0} {#if q.last_correct === true} diff --git a/frontend/src/routes/study/topics/[id]/questions/[qid]/+page.svelte b/frontend/src/routes/study/topics/[id]/questions/[qid]/+page.svelte index be341d1..43a0e1a 100644 --- a/frontend/src/routes/study/topics/[id]/questions/[qid]/+page.svelte +++ b/frontend/src/routes/study/topics/[id]/questions/[qid]/+page.svelte @@ -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}
- - + + {@render relatedSections()}
@@ -413,3 +361,103 @@ {/if}
+ + +{#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} +
반복 출제·유사 유형 분석 중…
+ {:else if related} + {#if sourceStatus !== 'ready'} +
+ {#if sourceStatus === 'pending'}임베딩 생성 중입니다. 잠시 후 다시. + {:else if sourceStatus === 'failed'}임베딩 생성 실패. 다음 cron 자동 재시도. + {:else if sourceStatus === 'stale'}본문 변경 후 임베딩 재계산 대기 중. + {:else}임베딩 미생성. 약 1분 안에 cron 처리. + {/if} +
+ {:else if !sourceRound} +
+ 회차가 지정되지 않은 문제는 회차 간 반복 출제 분석을 할 수 없습니다. +
+ {:else if repeats.length === 0 && sims.length === 0} + + {:else} + {#if repeats.length > 0} +
+
+ 🔥 + 반복 출제 + · {repeatRound}개 회차 +
+
+ 거의 같은 형태의 문제가 여러 회차에 출제되었습니다. 문제와 정답 구조를 확실히 기억해두는 것이 좋습니다. +
+
관련 반복 문제 {repeatRel}개
+ +
+ {/if} + + {#if sims.length > 0} +
+
+ 🧩 + 유사 유형 + · {similarRound}개 회차 +
+
+ 보기나 수치는 다르지만 같은 개념을 묻는 문제입니다. 공식 적용 방식과 조건 해석을 함께 복습하세요. +
+
관련 {similarRel}문제
+ +
+ {/if} + {/if} + {/if} +{/snippet} diff --git a/frontend/src/routes/study/topics/[id]/quiz-sessions/[sid]/+page.svelte b/frontend/src/routes/study/topics/[id]/quiz-sessions/[sid]/+page.svelte index 66c202a..fb7c5a4 100644 --- a/frontend/src/routes/study/topics/[id]/quiz-sessions/[sid]/+page.svelte +++ b/frontend/src/routes/study/topics/[id]/quiz-sessions/[sid]/+page.svelte @@ -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}