Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d447f9cba | |||
| f38ec177d7 |
@@ -8,13 +8,14 @@ from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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,45 @@ 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,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
topic_id: int = DEFAULT_TOPIC_ID,
|
||||
):
|
||||
"""개념 리더 재료 — 구조 파싱(요약/본문/빈출/관련) + 백링크 해소 + 회독/SR + 이전/다음."""
|
||||
detail = await cc.concept_detail(session, user.id, topic_id, doc_id)
|
||||
if detail is None:
|
||||
raise HTTPException(status_code=404, detail="concept not found")
|
||||
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,
|
||||
|
||||
@@ -18,6 +18,7 @@ from models.document_read import DocumentRead
|
||||
from models.study_concept_progress import StudyConceptProgress
|
||||
from models.study_question_progress import StudyQuestionProgress
|
||||
from models.study_topic import StudyTopic
|
||||
from services.study.concept_parser import parse_concept, resolve_related
|
||||
from services.study.sr_schedule import advance, first_due
|
||||
|
||||
# 개념 행 조회 — 태그로 개념문서 필터 + 회독 진행 LEFT JOIN. md_content 는 전송 안 하고
|
||||
@@ -205,3 +206,79 @@ async def mark_read(
|
||||
await session.commit()
|
||||
await session.refresh(prog)
|
||||
return {"ok": True, "review_stage": prog.review_stage, "due_at": prog.due_at}
|
||||
|
||||
|
||||
_CONCEPT_ONE_SQL = text(
|
||||
"""
|
||||
SELECT d.id AS doc_id, d.title AS title, d.md_content AS md_content,
|
||||
split_part(replace(d.user_tags::text, '"', ''), '/', 3) AS subject,
|
||||
(d.md_content LIKE '%★★★%') AS f3,
|
||||
(d.md_content LIKE '%★★%') AS f2,
|
||||
EXISTS (
|
||||
SELECT 1 FROM document_reads r
|
||||
WHERE r.document_id = d.id AND r.user_id = :uid
|
||||
) AS is_read,
|
||||
p.review_stage AS review_stage,
|
||||
p.due_at AS due_at
|
||||
FROM documents d
|
||||
LEFT JOIN study_concept_progress p ON p.concept_doc_id = d.id AND p.user_id = :uid
|
||||
WHERE d.id = :doc_id AND d.deleted_at IS NULL AND d.user_tags::text LIKE :like
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def concept_detail(
|
||||
session: AsyncSession, user_id: int, topic_id: int, doc_id: int
|
||||
) -> dict | None:
|
||||
"""개념 리더 재료 — md 구조 파싱 + 관련개념 백링크 해소 + 회독/SR 상태 + 같은 과목 이전/다음."""
|
||||
name = await _topic_name(session, topic_id)
|
||||
if not name:
|
||||
return None
|
||||
like = f"%@library/{name}/%"
|
||||
row = (
|
||||
await session.execute(
|
||||
_CONCEPT_ONE_SQL, {"uid": user_id, "doc_id": doc_id, "like": like}
|
||||
)
|
||||
).mappings().first()
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
parsed = parse_concept(row["md_content"] or "")
|
||||
|
||||
# 백링크 해소 + 이전/다음 = 같은 토픽 개념 title 인덱스(회독 rows 재사용)
|
||||
idx = await _concept_rows(session, user_id, name)
|
||||
title_index = [(r["doc_id"], r["title"], r["subject"]) for r in idx]
|
||||
resolved = resolve_related(parsed["related"], title_index)
|
||||
|
||||
# 이전/다음 = 같은 과목, title 순
|
||||
same = sorted(
|
||||
[(r["doc_id"], r["title"]) for r in idx if r["subject"] == row["subject"]],
|
||||
key=lambda x: (x[1] or "", x[0]),
|
||||
)
|
||||
ids = [d for d, _ in same]
|
||||
prev_id = next_id = None
|
||||
if doc_id in ids:
|
||||
pos = ids.index(doc_id)
|
||||
if pos > 0:
|
||||
prev_id = ids[pos - 1]
|
||||
if pos < len(ids) - 1:
|
||||
next_id = ids[pos + 1]
|
||||
|
||||
freq = 3 if row["f3"] else (2 if row["f2"] else 1)
|
||||
|
||||
return {
|
||||
"doc_id": row["doc_id"],
|
||||
"db_title": row["title"],
|
||||
"title": parsed["title"] or row["title"],
|
||||
"subject": row["subject"],
|
||||
"freq": freq,
|
||||
"summary": parsed["summary"],
|
||||
"body": parsed["body"],
|
||||
"bincheol": parsed["bincheol"],
|
||||
"related": resolved,
|
||||
"is_read": row["is_read"],
|
||||
"review_stage": row["review_stage"],
|
||||
"due_at": row["due_at"],
|
||||
"prev_id": prev_id,
|
||||
"next_id": next_id,
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
"""concept_parser — 개념노트 markdown 구조 파서 + 관련개념 백링크 해소 (이론 리더용).
|
||||
|
||||
정찰 실측 불변식(273/273): 개념노트는 고정 골격을 100% 따름 —
|
||||
# {H1 제목} (첫 줄, DB title 과 다른 표시용 제목)
|
||||
> **한 줄 요약**: {요약} (blockquote, 라벨 고정)
|
||||
## {본문 라벨} ... (BODY, 자유 라벨 H2 0~N, 트레일 ★ 가능)
|
||||
## 빈출 포인트 (항상, 관련개념 직전)
|
||||
## 관련 개념 (항상, 문서 최종 섹션)
|
||||
|
||||
코드펜스(``` ASCII 도식) 내부의 ##/- 는 무시. 헤딩 트레일 ★ 는 스트립(라벨 정규화).
|
||||
'빈출 포인트'/'관련 개념' 앵커만 이름으로 잡고 나머지 BODY 는 순서·위치로 처리(라벨 화이트리스트 금지).
|
||||
순수 함수 · LLM 0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
_FENCE = re.compile(r"^\s*```")
|
||||
_H1 = re.compile(r"^#\s+(.+?)\s*$")
|
||||
_H2 = re.compile(r"^##\s+(.+?)\s*$") # ### 는 매칭 안 됨(## 뒤 \s 요구)
|
||||
_SUMMARY = re.compile(r"^>\s*\*\*한 줄 요약\*\*:\s*(.+)$")
|
||||
_STAR_SUFFIX = re.compile(r"\s*★+\s*$")
|
||||
_TRAIL_STARS = re.compile(r"★+\s*$")
|
||||
_BINCHEOL_ITEM = re.compile(r"^\s*-\s+(★*)\s*(.+)$")
|
||||
_RELATED_ITEM = re.compile(r"^\s*-\s+(.+)$")
|
||||
_PAREN = re.compile(r"\s*\(.*$") # 괄호부터 끝(clarifier 힌트 절단)
|
||||
_NUM_PREFIX = re.compile(r"^\d+_")
|
||||
_STRIP_SYM = re.compile(r"[\s_·,./()\-]")
|
||||
|
||||
_ANCHOR_BINCHEOL = "빈출 포인트"
|
||||
_ANCHOR_RELATED = "관련 개념"
|
||||
|
||||
|
||||
def parse_concept(md: str) -> dict:
|
||||
"""개념노트 md → {title, summary, body[{label,stars,md}], bincheol[{tier,text}], related[{raw,phrase,hint}]}."""
|
||||
lines = (md or "").split("\n")
|
||||
title: str | None = None
|
||||
summary: str | None = None
|
||||
body: list[dict] = []
|
||||
bincheol_lines: list[str] = []
|
||||
related_lines: list[str] = []
|
||||
|
||||
in_fence = False
|
||||
zone = "pre" # pre | body | bincheol | related
|
||||
body_cur: dict | None = None
|
||||
|
||||
def emit(line: str) -> None:
|
||||
if body_cur is not None:
|
||||
body_cur["_lines"].append(line)
|
||||
elif zone == "bincheol":
|
||||
bincheol_lines.append(line)
|
||||
elif zone == "related":
|
||||
related_lines.append(line)
|
||||
# pre-zone 내용(요약 앞 잡음)은 버림
|
||||
|
||||
for ln in lines:
|
||||
if _FENCE.match(ln):
|
||||
in_fence = not in_fence
|
||||
emit(ln)
|
||||
continue
|
||||
if in_fence:
|
||||
emit(ln)
|
||||
continue
|
||||
|
||||
if title is None:
|
||||
m = _H1.match(ln)
|
||||
if m:
|
||||
title = m.group(1).strip()
|
||||
continue
|
||||
if summary is None:
|
||||
m = _SUMMARY.match(ln)
|
||||
if m:
|
||||
summary = m.group(1).strip()
|
||||
continue
|
||||
|
||||
m2 = _H2.match(ln)
|
||||
if m2:
|
||||
raw_label = m2.group(1).strip()
|
||||
star_m = _TRAIL_STARS.search(raw_label)
|
||||
stars = len(star_m.group(0).strip()) if star_m else 0
|
||||
label = _STAR_SUFFIX.sub("", raw_label).strip()
|
||||
if label == _ANCHOR_BINCHEOL:
|
||||
zone = "bincheol"
|
||||
body_cur = None
|
||||
continue
|
||||
if label == _ANCHOR_RELATED:
|
||||
zone = "related"
|
||||
body_cur = None
|
||||
continue
|
||||
body_cur = {"label": label, "stars": stars, "_lines": []}
|
||||
body.append(body_cur)
|
||||
zone = "body"
|
||||
continue
|
||||
|
||||
emit(ln)
|
||||
|
||||
body_out = []
|
||||
for s in body:
|
||||
text = "\n".join(s["_lines"]).strip()
|
||||
if text or s["label"]:
|
||||
body_out.append({"label": s["label"], "stars": s["stars"], "md": text})
|
||||
|
||||
bincheol = []
|
||||
for ln in bincheol_lines:
|
||||
m = _BINCHEOL_ITEM.match(ln)
|
||||
if m:
|
||||
bincheol.append({"tier": len(m.group(1)), "text": m.group(2).strip()})
|
||||
|
||||
related = []
|
||||
for ln in related_lines:
|
||||
m = _RELATED_ITEM.match(ln)
|
||||
if m:
|
||||
raw = m.group(1).strip()
|
||||
phrase = _PAREN.sub("", raw).strip()
|
||||
hint = raw[len(phrase):].strip() if len(raw) > len(phrase) else ""
|
||||
if phrase:
|
||||
related.append({"raw": raw, "phrase": phrase, "hint": hint})
|
||||
|
||||
return {
|
||||
"title": title,
|
||||
"summary": summary,
|
||||
"body": body_out,
|
||||
"bincheol": bincheol,
|
||||
"related": related,
|
||||
}
|
||||
|
||||
|
||||
def _normalize(s: str) -> str:
|
||||
"""해소용 정규화: NN_ 접두 제거 → 소문자 → 공백/기호 제거. 영문은 lowercase 유지."""
|
||||
s = _NUM_PREFIX.sub("", s or "")
|
||||
s = s.lower()
|
||||
s = _STRIP_SYM.sub("", s)
|
||||
return s
|
||||
|
||||
|
||||
def resolve_related(related: list[dict], title_index: list[tuple]) -> list[dict]:
|
||||
"""관련개념 구절 → 개념 doc 해소. title_index = [(doc_id, title, subject), ...].
|
||||
|
||||
다단 fallback(정찰 ~79%): 정규화 exact → 양방향 substring(≥2자 가드) → 미해소=dangling(doc_id None).
|
||||
"""
|
||||
norm_exact: dict[str, int] = {}
|
||||
norm_list: list[tuple[str, int, str]] = []
|
||||
for did, ttl, _subj in title_index:
|
||||
n = _normalize(ttl)
|
||||
if n:
|
||||
norm_exact.setdefault(n, did)
|
||||
norm_list.append((n, did, ttl))
|
||||
|
||||
out = []
|
||||
for it in related:
|
||||
pn = _normalize(it["phrase"])
|
||||
did: int | None = None
|
||||
rtitle: str | None = None
|
||||
if pn and len(pn) >= 2:
|
||||
if pn in norm_exact:
|
||||
did = norm_exact[pn]
|
||||
else:
|
||||
# substring 폴백: title-norm ⊆ phrase-norm 방향만(짧은 phrase 가 더 큰 title 을
|
||||
# 삼키는 오결선 방지, 예: '염산'→'염산나트륨' X) + 길이차 최소(가장 구체적) +
|
||||
# doc_id tiebreak(순서 무관 결정성). 후보 없으면 dangling(doc_id None).
|
||||
cands = [
|
||||
(abs(len(n) - len(pn)), cand, ttl)
|
||||
for n, cand, ttl in norm_list
|
||||
if len(n) >= 2 and n in pn
|
||||
]
|
||||
if cands:
|
||||
cands.sort(key=lambda c: (c[0], c[1]))
|
||||
_, did, rtitle = cands[0]
|
||||
if did is not None and rtitle is None:
|
||||
rtitle = next((t for d, t, _ in title_index if d == did), None)
|
||||
out.append(
|
||||
{"phrase": it["phrase"], "hint": it["hint"], "doc_id": did, "title": rtitle}
|
||||
)
|
||||
return out
|
||||
@@ -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) {
|
||||
@@ -110,7 +116,7 @@
|
||||
{#each todayConcepts as c (c.doc_id)}
|
||||
<li class="flex items-center gap-2 rounded border border-default px-3 py-2">
|
||||
<span class="text-accent shrink-0 text-xs" title="빈출">{#each Array(c.freq) as _}★{/each}</span>
|
||||
<a href="/documents/{c.doc_id}" class="text-sm text-text hover:text-accent truncate flex-1">{c.title}</a>
|
||||
<a href="/study/read/{c.doc_id}" class="text-sm text-text hover:text-accent truncate flex-1">{c.title}</a>
|
||||
<span class="shrink-0 text-[10px] rounded-full px-2 py-0.5 {c.reason === '재복습' ? 'bg-accent/15 text-accent' : 'bg-surface border border-default text-dim'}">{c.reason}</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
<script>
|
||||
/**
|
||||
* /study/read/[docId] — 개념 학습 리더.
|
||||
* 개념노트(가스기사 documents)를 구조(요약/본문/빈출★/관련개념)로 렌더 +
|
||||
* '떠올리기' 능동 회상 토글 + 회독 SR(POST read) + 관련개념 백링크 + 이전/다음.
|
||||
* 본문 렌더 = MarkdownDoc(KaTeX + docimg 내장). 서버 파싱 = /api/study/concepts/{id}.
|
||||
*/
|
||||
import { page } from '$app/stores';
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import { renderMathMarkdownInline } from '$lib/utils/mathMarkdown';
|
||||
import MarkdownDoc from '$lib/components/MarkdownDoc.svelte';
|
||||
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, 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'(떠올리기)
|
||||
let revealed = $state({}); // {sectionIndex: true}
|
||||
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 {
|
||||
const data = await api(`/study/concepts/${reqId}`);
|
||||
if (reqId !== docId) return; // 그새 다른 개념으로 이동 → 폐기
|
||||
concept = data;
|
||||
} catch (e) {
|
||||
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 불필요)
|
||||
$effect(() => {
|
||||
void docId;
|
||||
load();
|
||||
});
|
||||
|
||||
function toggleMode() {
|
||||
mode = mode === 'read' ? 'recall' : 'read';
|
||||
revealed = {};
|
||||
}
|
||||
function reveal(i) {
|
||||
revealed = { ...revealed, [i]: true };
|
||||
}
|
||||
function shown(i) {
|
||||
return mode === 'read' || revealed[i];
|
||||
}
|
||||
|
||||
async function markRead() {
|
||||
marking = true;
|
||||
try {
|
||||
const r = await api(`/study/concepts/${docId}/read`, { method: 'POST' });
|
||||
if (concept) {
|
||||
concept.is_read = true;
|
||||
concept.review_stage = r?.review_stage ?? concept.review_stage;
|
||||
concept.due_at = r?.due_at ?? concept.due_at;
|
||||
}
|
||||
addToast('success', '회독 완료 — 다음 복습에 다시 나옵니다');
|
||||
} catch {
|
||||
addToast('error', '회독 처리 실패');
|
||||
} finally {
|
||||
marking = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{concept?.title ?? '개념'} — 공부</title></svelte:head>
|
||||
|
||||
<div class="p-4 md:p-6 max-w-3xl mx-auto">
|
||||
<!-- 상단 네비 -->
|
||||
<div class="flex items-center gap-2 text-xs md:text-sm mb-4 min-w-0">
|
||||
<a href="/study" class="text-dim hover:text-text flex items-center gap-1 shrink-0">
|
||||
<ArrowLeft size={14} /> 공부
|
||||
</a>
|
||||
{#if concept?.subject}
|
||||
<span class="text-faint shrink-0">/</span>
|
||||
<span class="text-dim truncate">{concept.subject}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<Skeleton h="h-10" rounded="card" />
|
||||
<div class="mt-3 space-y-2">
|
||||
{#each Array(4) as _}<Skeleton h="h-24" rounded="card" />{/each}
|
||||
</div>
|
||||
{:else if notFound}
|
||||
<EmptyState icon={BookOpen} title="개념을 찾을 수 없습니다" description="삭제되었거나 잘못된 주소입니다." />
|
||||
{:else if concept}
|
||||
<!-- 제목 + 빈출 tier -->
|
||||
<header class="mb-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<h1 class="text-xl md:text-2xl font-semibold text-text flex-1">{concept.title}</h1>
|
||||
<span class="text-accent text-sm shrink-0 mt-1" title="빈출도">
|
||||
{#each Array(concept.freq) as _}★{/each}
|
||||
</span>
|
||||
</div>
|
||||
{#if concept.is_read || (concept.review_stage !== null && concept.review_stage !== undefined)}
|
||||
<div class="mt-1 text-xs text-dim">
|
||||
{#if concept.review_stage !== null && concept.review_stage !== undefined}
|
||||
{STAGE_LABEL[concept.review_stage] ?? '복습 중'}
|
||||
{:else}회독함{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<!-- 한 줄 요약 (고정 표시) -->
|
||||
{#if concept.summary}
|
||||
<div class="mb-4 rounded-lg border-l-4 border-accent bg-accent/10 px-4 py-3 markdown-body text-sm text-text">
|
||||
{@html renderMathMarkdownInline(concept.summary)}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 모드 토글 -->
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<Button variant={mode === 'recall' ? 'primary' : 'secondary'} size="sm" icon={mode === 'recall' ? EyeOff : Eye} onclick={toggleMode}>
|
||||
{mode === 'recall' ? '떠올리기 모드' : '읽기 모드'}
|
||||
</Button>
|
||||
{#if mode === 'recall'}
|
||||
<span class="text-xs text-dim">각 섹션을 떠올린 뒤 확인하세요</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 본문 섹션 -->
|
||||
{#if concept.body.length > 0}
|
||||
<div class="space-y-3 mb-5">
|
||||
{#each concept.body as sec, i (i)}
|
||||
<section class="rounded-lg border border-default bg-surface overflow-hidden">
|
||||
<div class="flex items-center gap-2 px-4 py-2.5 border-b border-default bg-surface-hover">
|
||||
<h2 class="text-sm font-semibold text-text flex-1">{sec.label}</h2>
|
||||
{#if sec.stars > 0}
|
||||
<span class="text-accent text-xs shrink-0">{#each Array(sec.stars) as _}★{/each}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if shown(i)}
|
||||
<div class="px-4 py-3">
|
||||
<MarkdownDoc documentId={concept.doc_id} mdContent={sec.md} mdStatus={null}
|
||||
class="markdown-body max-w-none text-text" />
|
||||
</div>
|
||||
{:else}
|
||||
<button type="button" onclick={() => reveal(i)}
|
||||
class="w-full px-4 py-6 text-center text-sm text-dim hover:text-accent hover:bg-accent/5 transition-colors">
|
||||
<Eye size={16} class="inline mr-1" /> 떠올린 뒤 확인
|
||||
</button>
|
||||
{/if}
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 빈출 포인트 -->
|
||||
{#if concept.bincheol.length > 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">
|
||||
<span class="text-accent">★</span> 빈출 포인트
|
||||
</h2>
|
||||
<ul class="space-y-1.5">
|
||||
{#each concept.bincheol as item}
|
||||
<li class="flex gap-2 text-sm text-text">
|
||||
<span class="text-accent shrink-0 text-xs mt-0.5">{#each Array(item.tier || 1) as _}★{/each}</span>
|
||||
<span class="markdown-body flex-1">{@html renderMathMarkdownInline(item.text)}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- 관련 개념 (백링크) -->
|
||||
{#if concept.related.length > 0}
|
||||
<section class="mb-5">
|
||||
<h2 class="text-xs text-dim mb-2">관련 개념</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each concept.related as rel}
|
||||
{#if rel.doc_id}
|
||||
<a href="/study/read/{rel.doc_id}"
|
||||
class="text-xs rounded-full border border-accent/40 bg-accent/10 text-accent px-3 py-1 hover:bg-accent/20 transition-colors">
|
||||
{rel.phrase}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="text-xs rounded-full border border-default bg-surface text-faint px-3 py-1" title="아직 없는 개념">
|
||||
{rel.phrase}
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</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}
|
||||
<Button variant="ghost" size="sm" icon={ChevronLeft} href="/study/read/{concept.prev_id}">이전</Button>
|
||||
{/if}
|
||||
<div class="flex-1"></div>
|
||||
<Button variant="primary" size="sm" icon={Check} onclick={markRead} loading={marking}>
|
||||
{concept.is_read ? '다시 회독' : '회독 완료'}
|
||||
</Button>
|
||||
{#if concept.next_id}
|
||||
<Button variant="secondary" size="sm" icon={ChevronRight} href="/study/read/{concept.next_id}">다음 개념</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user