f38ec177d7
이론공부 개선 B→A→C 의 A. 개념노트를 구조(요약/본문/빈출★/관련개념)로 렌더 + 능동 회상(떠올리기) + 관련개념 백링크 + 이전/다음.
- concept_parser: md 골격 파서(273/273 불변식) + 관련개념 백링크 해소(exact→title⊆phrase substring, 과대매치 가드)
- concept_curriculum.concept_detail + GET /api/study/concepts/{id} (개념문서 태그 스코프)
- /study/read/[docId] 리더(MarkdownDoc KaTeX+docimg 재사용·읽기/떠올리기 모드) + 홈 오늘의개념 링크 연결
- 적대리뷰 5건 반영(이중로드·substring 오결선·엔드포인트 스코프·prev/next 결정성·in-flight 가드). 마이그 없음·문제풀이 무접촉
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
176 lines
6.2 KiB
Python
176 lines
6.2 KiB
Python
"""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
|