feat(study): 이론↔문제 브리지 (Stage B) — 개념별 정답률·약점 개념 지도
이론공부 B→A→C 의 B. 완성된 문제풀이에 이론 연결(약점 구동).
- 마이그 382 study_concept_links(개념 doc↔기출, FK 없음) + 백필 SQL(임베딩 코사인 top-k=10·threshold 0.62 → 2362링크·284개념·964문항)
- concept_links 서비스(related_questions·weakness_map 롤업) + GET /concepts/{id}/questions·/concepts/weakness-map(라우트 순서=weakness-map 먼저)
- 리더 관련기출 섹션(정답률·문항 stub→문항상세) + 홈 약점개념 위젯
- 적대리뷰 반영: Promise.all 격리(weakness-map 실패→코어 대시보드 블랙아웃 방지)·q.subject null 폴백. 백필=배포 후 트랜잭션 래핑 실행. 문제풀이 무접촉
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import { BookOpen, PenLine, GraduationCap, FolderKanban, Layers, Repeat, Flag, Inbox, Activity, CalendarCheck } from 'lucide-svelte';
|
||||
import { BookOpen, PenLine, GraduationCap, FolderKanban, Layers, Repeat, Flag, Inbox, Activity, CalendarCheck, Target } from 'lucide-svelte';
|
||||
|
||||
let cardReviewCount = $state(0);
|
||||
let questionFlagCount = $state(0);
|
||||
@@ -12,6 +12,7 @@
|
||||
// 오늘의 공부 (이론 홈)
|
||||
let curriculum = $state(null);
|
||||
let todayConcepts = $state([]);
|
||||
let weakConcepts = $state([]); // 약점 개념(관련 기출 정답률 낮음)
|
||||
let dashLoading = $state(true);
|
||||
|
||||
let readPct = $derived(
|
||||
@@ -28,10 +29,15 @@
|
||||
curriculum = cur;
|
||||
todayConcepts = today?.concepts ?? [];
|
||||
} catch {
|
||||
// 대시보드 실패해도 허브 나머지는 동작 (조용히)
|
||||
// 코어 대시보드 실패해도 허브 나머지는 동작 (조용히)
|
||||
} finally {
|
||||
dashLoading = false;
|
||||
}
|
||||
// 약점 개념 = 비차단(신규 엔드포인트 실패해도 코어 대시보드 블랙아웃 방지)
|
||||
try {
|
||||
const weak = await api('/study/concepts/weakness-map?limit=5');
|
||||
weakConcepts = weak?.weak ?? [];
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function markRead(doc) {
|
||||
@@ -121,6 +127,22 @@
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if weakConcepts.length > 0}
|
||||
<div class="mt-4 pt-3 border-t border-default">
|
||||
<div class="text-xs text-dim mb-2 flex items-center gap-1.5">
|
||||
<Target size={13} class="text-error" /> 약점 개념 <span class="text-faint">(관련 기출 정답률 낮음)</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each weakConcepts as w (w.doc_id)}
|
||||
<a href="/study/read/{w.doc_id}"
|
||||
class="text-xs rounded-full border border-error/40 bg-error/10 text-error px-3 py-1 hover:bg-error/20 transition-colors">
|
||||
{w.title.replace(/^\d+_/, '')} <span class="font-semibold">{w.accuracy}%</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
|
||||
@@ -13,11 +13,12 @@
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import { BookOpen, ArrowLeft, Eye, EyeOff, Check, ChevronLeft, ChevronRight } from 'lucide-svelte';
|
||||
import { BookOpen, ArrowLeft, Eye, EyeOff, Check, ChevronLeft, ChevronRight, FileQuestion } from 'lucide-svelte';
|
||||
|
||||
let docId = $derived($page.params.docId);
|
||||
|
||||
let concept = $state(null);
|
||||
let relatedQ = $state(null); // 관련 기출(이론↔문제, 비차단)
|
||||
let loading = $state(true);
|
||||
let notFound = $state(false);
|
||||
let mode = $state('read'); // 'read' | 'recall'(떠올리기)
|
||||
@@ -25,12 +26,17 @@
|
||||
let marking = $state(false);
|
||||
|
||||
const STAGE_LABEL = { 0: '복습 시작', 1: '복습 1단계', 2: '복습 2단계', 3: '복습 3단계', 4: '학습 완료' };
|
||||
const OUTCOME_MARK = { correct: '○', wrong: '✕', unsure: '?' };
|
||||
const OUTCOME_CLASS = { correct: 'text-success', wrong: 'text-error', unsure: 'text-warning' };
|
||||
const outcomeMark = (o) => OUTCOME_MARK[o] ?? '–';
|
||||
const outcomeClass = (o) => OUTCOME_CLASS[o] ?? 'text-faint';
|
||||
|
||||
async function load() {
|
||||
const reqId = docId; // in-flight 가드: 백링크 연타 시 stale 응답 무시
|
||||
loading = true;
|
||||
notFound = false;
|
||||
concept = null;
|
||||
relatedQ = null;
|
||||
revealed = {};
|
||||
mode = 'read';
|
||||
try {
|
||||
@@ -41,9 +47,15 @@
|
||||
if (reqId !== docId) return;
|
||||
if (e?.status === 404) notFound = true;
|
||||
else addToast('error', '개념을 불러오지 못했습니다');
|
||||
return; // 본문 실패 → 관련기출 스킵
|
||||
} finally {
|
||||
if (reqId === docId) loading = false;
|
||||
}
|
||||
// 관련 기출(비차단 — 실패해도 본문 표시엔 영향 없음)
|
||||
try {
|
||||
const rq = await api(`/study/concepts/${reqId}/questions?limit=6`);
|
||||
if (reqId === docId) relatedQ = rq;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// $effect 가 마운트 1회 + docId 변경(백링크/이전·다음) 재로드를 모두 커버 (onMount 불필요)
|
||||
@@ -202,6 +214,29 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- 관련 기출 (이론↔문제 브리지) -->
|
||||
{#if relatedQ && relatedQ.linked > 0}
|
||||
<section class="mb-5 rounded-lg border border-default bg-surface p-4">
|
||||
<h2 class="text-sm font-semibold text-text mb-2 flex items-center gap-1.5">
|
||||
<FileQuestion size={15} class="text-accent" /> 관련 기출
|
||||
<span class="ml-1 text-xs font-normal text-dim">
|
||||
{relatedQ.linked}문항{#if relatedQ.accuracy !== null} · 정답률 <span class="{relatedQ.accuracy < 60 ? 'text-error' : 'text-text'} font-medium">{relatedQ.accuracy}%</span>{:else} · 아직 안 풂{/if}
|
||||
</span>
|
||||
</h2>
|
||||
<ul class="space-y-0.5">
|
||||
{#each relatedQ.questions as q (q.id)}
|
||||
<li>
|
||||
<a href="/study/topics/4/questions/{q.id}"
|
||||
class="flex items-center gap-2 text-xs py-1 text-dim hover:text-accent transition-colors">
|
||||
<span class="{outcomeClass(q.last_outcome)} shrink-0 w-4 text-center font-bold">{outcomeMark(q.last_outcome)}</span>
|
||||
<span class="truncate">{q.subject ?? '기출'}{#if q.exam_round} · {q.exam_round}{/if}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- 액션바 -->
|
||||
<div class="flex items-center gap-2 border-t border-default pt-4 mt-2">
|
||||
{#if concept.prev_id}
|
||||
|
||||
Reference in New Issue
Block a user