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} +