diff --git a/app/api/study_concepts.py b/app/api/study_concepts.py index 4918f1d..2f7ee40 100644 --- a/app/api/study_concepts.py +++ b/app/api/study_concepts.py @@ -15,6 +15,7 @@ from core.auth import get_current_user from core.database import get_session from models.user import User from services.study import concept_curriculum as cc +from services.study import concept_links as cl router = APIRouter() @@ -43,6 +44,20 @@ async def get_today_concepts( return await cc.today_concepts(session, user.id, topic_id, limit) +@router.get("/concepts/weakness-map") +async def get_weakness_map( + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], + topic_id: int = DEFAULT_TOPIC_ID, + limit: int = 12, +): + """개념 약점 지도 — 링크된 기출 정답률로 약점 개념(정답률<60%) 우선(이론↔문제).""" + name = await cc._topic_name(session, topic_id) + if not name: + return {"weak": [], "weak_total": 0, "evaluated_total": 0} + return await cl.weakness_map(session, user.id, name, limit) + + @router.get("/concepts/{doc_id}") async def get_concept_detail( doc_id: int, @@ -57,6 +72,17 @@ async def get_concept_detail( return detail +@router.get("/concepts/{doc_id}/questions") +async def get_concept_questions( + doc_id: int, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], + limit: int = 20, +): + """개념 관련 기출 + 내 정답률 (이론↔문제 브리지).""" + return await cl.related_questions(session, user.id, doc_id, limit) + + @router.post("/concepts/{doc_id}/read") async def post_concept_read( doc_id: int, diff --git a/app/services/study/concept_links.py b/app/services/study/concept_links.py new file mode 100644 index 0000000..37fec53 --- /dev/null +++ b/app/services/study/concept_links.py @@ -0,0 +1,139 @@ +"""concept_links — 이론↔문제 브리지 롤업 (Stage B). + +study_concept_links(개념 doc ↔ 기출문항, 임베딩 코사인) + study_question_progress(내 풀이상태)를 +조인해 (a) 개념별 관련 기출 + 내 정답률(related_questions), (b) 개념 약점 지도(weakness_map) 산출. +읽기 전용 집계 · LLM 0. 링크 적재는 scripts/concept_links_backfill.sql(임베딩) 배치. +정답률 = 링크된 문항 중 progress.last_outcome 기준(attempted=풀이이력 보유, correct=최근정답). +""" + +from __future__ import annotations + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +_ACCURACY_WEAK_PCT = 60 # 정답률 < 60% = 약점(attempted>0 일 때만) + +_AGG_SQL = text( + """ + SELECT count(*) AS linked, + count(pr.study_question_id) FILTER (WHERE pr.last_outcome IS NOT NULL) AS attempted, + count(*) FILTER (WHERE pr.last_outcome = 'correct') AS correct + FROM study_concept_links l + LEFT JOIN study_question_progress pr + ON pr.study_question_id = l.question_id AND pr.user_id = :uid + WHERE l.concept_doc_id = :doc_id AND l.link_source = 'embedding' + """ +) + +_QROWS_SQL = text( + """ + SELECT q.id AS id, q.subject AS subject, q.exam_round AS exam_round, + q.exam_question_number AS qnum, l.score AS score, + pr.last_outcome AS last_outcome, pr.review_stage AS review_stage + FROM study_concept_links l + JOIN study_questions q ON q.id = l.question_id AND q.deleted_at IS NULL AND q.is_active + LEFT JOIN study_question_progress pr + ON pr.study_question_id = q.id AND pr.user_id = :uid + WHERE l.concept_doc_id = :doc_id AND l.link_source = 'embedding' + ORDER BY l.score DESC + LIMIT :limit + """ +) + +_WEAKNESS_SQL = text( + """ + SELECT d.id AS doc_id, d.title AS title, + split_part(replace(d.user_tags::text, '"', ''), '/', 3) AS subject, + count(l.id) AS linked, + count(pr.study_question_id) FILTER (WHERE pr.last_outcome IS NOT NULL) AS attempted, + count(*) FILTER (WHERE pr.last_outcome = 'correct') AS correct + FROM documents d + JOIN study_concept_links l ON l.concept_doc_id = d.id AND l.link_source = 'embedding' + LEFT JOIN study_question_progress pr + ON pr.study_question_id = l.question_id AND pr.user_id = :uid + WHERE d.user_tags::text LIKE :like AND d.deleted_at IS NULL + GROUP BY d.id, d.title, subject + """ +) + + +async def related_questions( + session: AsyncSession, user_id: int, doc_id: int, limit: int = 20 +) -> dict: + """개념 doc 의 관련 기출 + 내 정답률(전체 링크 기준 집계 + 상위 N 표시용).""" + agg = ( + await session.execute(_AGG_SQL, {"uid": user_id, "doc_id": doc_id}) + ).mappings().first() + rows = ( + await session.execute( + _QROWS_SQL, {"uid": user_id, "doc_id": doc_id, "limit": limit} + ) + ).mappings().all() + + linked = (agg["linked"] if agg else 0) or 0 + attempted = (agg["attempted"] if agg else 0) or 0 + correct = (agg["correct"] if agg else 0) or 0 + accuracy = round(100 * correct / attempted) if attempted else None + + return { + "linked": linked, + "attempted": attempted, + "correct": correct, + "accuracy": accuracy, + "questions": [ + { + "id": r["id"], + "subject": r["subject"], + "exam_round": r["exam_round"], + "qnum": r["qnum"], + "score": round(r["score"], 3) if r["score"] is not None else None, + "last_outcome": r["last_outcome"], + "review_stage": r["review_stage"], + } + for r in rows + ], + } + + +async def weakness_map( + session: AsyncSession, user_id: int, topic_name: str, limit: int = 12 +) -> dict: + """개념 약점 지도 — 링크된 기출 정답률로 개념 채색. 약점(attempted>0·정답률<60%) 우선 정렬.""" + like = f"%@library/{topic_name}/%" + rows = ( + await session.execute(_WEAKNESS_SQL, {"uid": user_id, "like": like}) + ).mappings().all() + + concepts = [] + for r in rows: + attempted = r["attempted"] or 0 + correct = r["correct"] or 0 + accuracy = round(100 * correct / attempted) if attempted else None + if accuracy is None: + state = "unattempted" + elif accuracy < _ACCURACY_WEAK_PCT: + state = "weak" + else: + state = "ok" + concepts.append( + { + "doc_id": r["doc_id"], + "title": r["title"], + "subject": r["subject"], + "linked": r["linked"] or 0, + "attempted": attempted, + "accuracy": accuracy, + "state": state, + } + ) + + # 약점 우선(정답률 오름차순) → 미평가는 뒤로. 홈 위젯용 상위 N. + weak = sorted( + [c for c in concepts if c["state"] == "weak"], + key=lambda c: (c["accuracy"], -c["attempted"], c["doc_id"]), + ) + return { + "weak": weak[:limit], + "weak_total": len(weak), + "evaluated_total": sum(1 for c in concepts if c["state"] != "unattempted"), + } diff --git a/frontend/src/routes/study/+page.svelte b/frontend/src/routes/study/+page.svelte index 871cf92..bb88c74 100644 --- a/frontend/src/routes/study/+page.svelte +++ b/frontend/src/routes/study/+page.svelte @@ -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} {/if} + + {#if weakConcepts.length > 0} +
+
+ 약점 개념 (관련 기출 정답률 낮음) +
+
+ {#each weakConcepts as w (w.doc_id)} + + {w.title.replace(/^\d+_/, '')} {w.accuracy}% + + {/each} +
+
+ {/if} {/if} diff --git a/frontend/src/routes/study/read/[docId]/+page.svelte b/frontend/src/routes/study/read/[docId]/+page.svelte index 3ff9644..f73496c 100644 --- a/frontend/src/routes/study/read/[docId]/+page.svelte +++ b/frontend/src/routes/study/read/[docId]/+page.svelte @@ -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 @@ {/if} + + {#if relatedQ && relatedQ.linked > 0} +
+

+ 관련 기출 + + {relatedQ.linked}문항{#if relatedQ.accuracy !== null} · 정답률 {relatedQ.accuracy}%{:else} · 아직 안 풂{/if} + +

+ +
+ {/if} +
{#if concept.prev_id} diff --git a/migrations/382_study_concept_links.sql b/migrations/382_study_concept_links.sql new file mode 100644 index 0000000..d1b2d1d --- /dev/null +++ b/migrations/382_study_concept_links.sql @@ -0,0 +1,15 @@ +-- 382_study_concept_links.sql — 개념문서 ↔ 기출문항 링크 (이론↔문제 브리지, Stage B). +-- concept_doc_id=documents.id, question_id=study_questions.id — FK 없음(hot 테이블 락 회피, 선례). +-- link_source: 'embedding'(bge-m3 코사인 top-k, 주력) | 'ref'(해설 .md 참조, 후속 enrichment). +-- score=코사인 유사도(0~1). UNIQUE(doc,question,source) — source별 공존 허용(재튜닝=source 전삭제 후 재삽입). +CREATE TABLE IF NOT EXISTS study_concept_links ( + id bigserial PRIMARY KEY, + concept_doc_id bigint NOT NULL, + question_id bigint NOT NULL, + link_source text NOT NULL, + score double precision, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT uq_concept_link UNIQUE (concept_doc_id, question_id, link_source) +); +CREATE INDEX IF NOT EXISTS idx_concept_links_doc ON study_concept_links(concept_doc_id); +CREATE INDEX IF NOT EXISTS idx_concept_links_q ON study_concept_links(question_id); diff --git a/scripts/concept_links_backfill.sql b/scripts/concept_links_backfill.sql new file mode 100644 index 0000000..63db731 --- /dev/null +++ b/scripts/concept_links_backfill.sql @@ -0,0 +1,23 @@ +-- concept_links_backfill.sql — 개념↔문항 임베딩 링크 재생성 (Stage B, 멱등·재실행 안전). +-- 정찰 확정: bge-m3 1024d 코사인, per-concept top-k=10, threshold 0.62 → ~2362링크·284/289개념·964문항. +-- 재튜닝 시 DELETE(embedding 소스만) 후 재삽입 = ref 링크(후속) 불변. 개념 doc = 가스기사 태그. +DELETE FROM study_concept_links WHERE link_source = 'embedding'; +INSERT INTO study_concept_links (concept_doc_id, question_id, link_source, score) +WITH cd AS ( + SELECT id, embedding FROM documents + WHERE user_tags::text LIKE '%@library/가스기사/%' + AND deleted_at IS NULL AND embedding IS NOT NULL +), +ranked AS ( + SELECT cd.id AS concept_doc_id, q.id AS question_id, + 1 - (q.embedding <=> cd.embedding) AS score, + row_number() OVER (PARTITION BY cd.id ORDER BY q.embedding <=> cd.embedding) AS rn + FROM cd + JOIN study_questions q + ON q.study_topic_id = 4 AND q.embedding IS NOT NULL + AND q.deleted_at IS NULL AND q.is_active +) +SELECT concept_doc_id, question_id, 'embedding', score +FROM ranked +WHERE rn <= 10 AND score >= 0.62 +ON CONFLICT (concept_doc_id, question_id, link_source) DO NOTHING;