feat: add common utilities and feedback handling scripts

- Introduced common.js for shared functions including API calls, error reporting, and UI helpers.
- Implemented feedback.js for the feedback reporting page, including form handling and displaying feedback status.
- Added layout.js for managing galaxy and section layouts in the visualization.
- Created login.js for user authentication with session management.
- Developed search.js for term searching based on user input and synonyms.
- Implemented signup.js for user registration with validation and immediate login.
This commit is contained in:
jsahn
2026-10-06 23:14:45 +09:00
parent 04158ac442
commit 4e943700fc
62 changed files with 158771 additions and 0 deletions
+631
View File
@@ -0,0 +1,631 @@
"""PDF → data/atlas.json 빌드.
.venv/bin/python -m pipeline.build
자동 추출 결과는 모두 review_status='draft' 이며 method 필드로 출처(색인/목차/본문/시안)를 남긴다.
"""
import hashlib
import json
import re
import sys
import time
import unicodedata
from collections import defaultdict
from functools import lru_cache
import pymupdf
from . import curated
from .config import (BOOKS, DATA_DIR, GALAXIES, SECONDARY_CHAPTER_MAP, SECONDARY_KEYWORD_MAP,
SECONDARY_SECTION_OVERRIDES)
from .index import extract_index
from .pdfutil import body_text, label_to_index
from .toc import extract_toc
HANGUL = re.compile(r"[가-힣]")
def log(*a):
print(*a, file=sys.stderr, flush=True)
def norm_key(s: str) -> str:
s = unicodedata.normalize("NFC", s).lower()
s = re.sub(r"[\s·\-–_‧∙/'’\.]", "", s)
return s
def tid(key: str) -> str:
return "t_" + hashlib.sha1(key.encode()).hexdigest()[:10]
def rid(a: str, b: str, typ: str) -> str:
return "r_" + hashlib.sha1(f"{a}|{b}|{typ}".encode()).hexdigest()[:10]
def clean_alias(a: str):
"""동의어 정리: 하이픈 줄바꿈 잇기, 연도·숫자만 있는 값 제거."""
a = re.sub(r"(?<=[A-Za-z])- (?=[a-z])", "", a).strip(" ,;")
if not a or re.fullmatch(r"[\d\s.,~\-]+", a) or len(a) < 2:
return None
return a
def split_name(raw: str):
"""'건강 신념 모형(Health Belief Model; HBM)' → ('건강 신념 모형', ['Health Belief Model', 'HBM'])"""
raw = raw.strip()
m = re.match(r"^(.+?)\s*[\((]([^()()]+)[\))]?\s*$", raw)
if m and m.group(1).strip():
name = m.group(1).strip()
inner = m.group(2)
aliases = [c for c in (clean_alias(a) for a in re.split(r"[;,]", inner)) if c]
return name, aliases
return raw, []
# ---------------------------------------------------------------- 단원 매핑
class ChapterIndex:
def __init__(self, toc_by_book):
self.toc = toc_by_book
self.sorted = {b: sorted(n, key=lambda x: (x["page"], x["level"])) for b, n in toc_by_book.items()}
self.by_id = {n["id"]: n for nodes in toc_by_book.values() for n in nodes}
def locate(self, book_id: str, page: int):
"""페이지가 속한 가장 깊은 목차 노드."""
best = None
for n in self.sorted.get(book_id, []):
if n["page"] <= page:
if best is None or n["page"] > best["page"] or (n["page"] == best["page"] and n["level"] >= best["level"]):
best = n
else:
break
return best
def ancestors(self, node):
out = []
while node:
out.append(node)
node = self.by_id.get(node["parent_id"])
return out[::-1]
def chapter_section(self, node):
chain = self.ancestors(node) if node else []
chapter = next((n for n in chain if n["kind"] == "chapter"), None)
section = next((n for n in chain if n["kind"] == "section"), None)
return chapter, section, chain
def galaxy_for(book, chapter, chain):
if book["primary"]:
if chapter is None:
return None
no = int(chapter["number"])
return next((g["id"] for g in GALAXIES if g["chapter_no"] == no), None)
titles = " ".join(n["title"] for n in chain[1:]) if chain else ""
for kw, g in SECONDARY_KEYWORD_MAP:
if kw in titles:
return g
if chapter is not None:
g = SECONDARY_CHAPTER_MAP.get((book["book_id"], int(chapter["number"])))
if g:
return g
return SECONDARY_CHAPTER_MAP.get((book["book_id"], None))
# ---------------------------------------------------------------- 본문 문장
SENT_SPLIT = re.compile(r"(?<=[다요음함됨임])\.\s+|(?<=\.)\s+(?=[가-힣A-Z①-⑳•\-])|\n")
DEF_PATTERNS = [
(re.compile(r"(이)?란\s"), 4),
(re.compile(r"(이)?라\s?(함은|한다|하며|하고|부른다)"), 4),
(re.compile(r"(말한다|의미한다|뜻한다|정의한다|정의된다|일컫는다|가리킨다|이르는 말)"), 4),
(re.compile(r"정의"), 2),
]
class Corpus:
def __init__(self):
self.docs = {}
self.label_maps = {}
def open(self, book):
bid = book["book_id"]
if bid not in self.docs:
self.docs[bid] = pymupdf.open(book["file"]())
self.label_maps[bid] = label_to_index(self.docs[bid])
return self.docs[bid]
def pdf_index(self, book, page: int):
self.open(book)
return self.label_maps[book["book_id"]].get(str(page))
@lru_cache(maxsize=4096)
def sentences(self, book_id: str, pdf_page: int) -> tuple[str, ...]:
doc = self.docs[book_id]
text = body_text(doc, pdf_page)
# 쪽 끝에서 끊긴 문장은 다음 쪽 첫 문장과 잇는다
if text and not re.search(r"[.?!)」』]\s*$", text) and pdf_page + 1 < len(doc):
nxt = body_text(doc, pdf_page + 1)
m = re.match(r"^(.{1,300}?(?:다|요|음)\.)", nxt)
if m:
joiner = "" if re.search(r"[가-힣]$", text) and re.match(r"^[가-힣]", nxt) else " "
text = text + joiner + m.group(1)
text = rejoin_lines(text)
out = []
for s in SENT_SPLIT.split(text):
s = s.strip(" •-·")
if 8 <= len(s) <= 400:
out.append(s if s.endswith(".") or not s.endswith("다") else s + ".")
return tuple(out)
HEADING = re.compile(r"^(\d+[\).]|[①-⑳]|[가-하][\).]|\(\d+\)|[•▪■□○●]|표\s?\d|그림\s?\d)")
WORD_END = set("의은는이가을를에로고며서과와도한된인적및등면데때중게")
def rejoin_lines(text: str) -> str:
"""줄 단위로 끊긴 본문을 문장으로 다시 잇는다. 제목·목록 줄은 그대로 둔다."""
out = []
for line in text.split("\n"):
line = line.strip()
if not line:
continue
if out:
prev = out[-1]
ends = re.search(r"[.?!:)」』]$", prev) or len(prev) < 22 or HEADING.match(line) or "=" in prev
if not ends:
if re.search(r"[가-힣]$", prev) and re.match(r"^[가-힣]", line) and prev[-1] not in WORD_END:
out[-1] = prev + line # 단어 중간에서 줄바꿈
else:
out[-1] = prev + " " + line
continue
out.append(line)
return "\n".join(out)
PARTICLE = r"(?=\s|\(|(|은|는|이|가|란|을|를|의|과|와|에|도|로|으로|,|·|라|$)"
def score_sentence(sent: str, name: str, key: str):
"""정의 문장일수록 높은 점수. 용어가 없으면 None."""
flat = norm_key(sent)
if key not in flat or len(flat) - len(key) < 8: # 제목줄 등 너무 짧은 문장 제외
return None
pat = re.escape(name).replace(r"\ ", r"\s?")
m = re.search(pat + PARTICLE, sent)
if not m:
return 0
sc = 1
paren = r"\s?(?:\([^)]*\)|([^)]*))?\s?"
if re.match(r"^\s*" + pat + paren + r"(?:이란|란|은|는|이라 함은|라 함은|이라고 함은)\s", sent):
sc += 4
if re.search(r"(말한다|의미한다|뜻한다|정의한다|정의된다|일컫는다|가리킨다|이다|것이다)\.?$", sent):
sc += 3
elif re.search(r"(정의|의미|개념|나타내는|지표|방법|과정|상태|활동|체계|이론|모형)", sent):
sc += 2
elif re.search(pat + paren + r"(?:이?라고?\s?(?:한다|부른다|일컫는다|정의한다|명명한다))", sent):
sc += 6
if re.match(r"^(에|을|를|의|은|는|이|가|와|과|로|으로|하여|하고|되어|및|또는|등)\s", sent):
sc -= 6
if 20 <= len(sent) <= 220:
sc += 1
if len(sent) > 300:
sc -= 3
return sc
EN_PAREN = re.compile(r"\(\s*([A-Za-z][A-Za-z0-9 ,;:'&\-/\.]{1,90}?)\s*\)")
def english_aliases(sent: str, name: str):
"""문장에서 '용어(English, ABC)' 꼴을 찾아 영문 표기를 돌려줌."""
flat_name = re.escape(name).replace(r"\ ", r"\s?")
m = re.search(flat_name + r"\s?" + EN_PAREN.pattern, sent)
if not m:
return []
return [c for c in (clean_alias(a) for a in re.split(r"[;,]|:", m.group(1))) if c]
# ---------------------------------------------------------------- 메인
def build():
t0 = time.time()
corpus = Corpus()
toc = {}
entries = []
for b in BOOKS:
doc = corpus.open(b)
toc[b["book_id"]] = extract_toc(doc, b)
es = extract_index(doc, b)
for e in es:
e["book_id"] = b["book_id"]
entries.extend(es)
log(f"[{b['book_id']}] 목차 {len(toc[b['book_id']])}개, 색인 {len(es)}개")
chidx = ChapterIndex(toc)
books_by_id = {b["book_id"]: b for b in BOOKS}
# ---- 1) 색인 항목 → 용어 통합
terms: dict[str, dict] = {}
alias_index: dict[str, str] = {} # 별칭 키 → 용어 키
xrefs = []
def get_term(name, kind):
key = norm_key(name)
t = terms.get(key)
if t is None:
t = terms[key] = {
"key": key, "id": tid(key), "name": name, "kind": kind, "aliases": [],
"sources": [], "index_entries": [],
}
return t
# 한글/인물 먼저, 영문은 뒤에서 별칭 연결을 시도
for e in sorted(entries, key=lambda e: e["section"] == "en"):
if e.get("see"):
xrefs.append(e)
continue
name, aliases = split_name(e["term"])
if e["section"] == "en" and not HANGUL.search(name):
e["_name"], e["_aliases"] = name, aliases
continue
kind = "person" if e["section"] == "person" else "concept"
t = get_term(name, kind)
for a in aliases:
if a not in t["aliases"]:
t["aliases"].append(a)
alias_index.setdefault(norm_key(a), t["key"])
t["index_entries"].append({"book_id": e["book_id"], "raw": e["term"], "pages": e["pages"]})
for p in e["pages"]:
t["sources"].append({"book_id": e["book_id"], "page": p})
# ---- 2) 본문에서 영문 표기 찾기 (색인 페이지의 문장)
log("본문 문장 분석 중…")
for t in terms.values():
for s in t["sources"]:
b = books_by_id[s["book_id"]]
pi = corpus.pdf_index(b, s["page"])
if pi is None:
continue
for sent in corpus.sentences(b["book_id"], pi):
for a in english_aliases(sent, t["name"]):
if a not in t["aliases"] and norm_key(a) != t["key"]:
t["aliases"].append(a)
alias_index.setdefault(norm_key(a), t["key"])
# ---- 3) 영문 색인 항목: 별칭과 일치하면 합치고, 아니면 독립 용어
en_new = 0
for e in entries:
if "_name" not in e:
continue
k = norm_key(e["_name"])
target = alias_index.get(k) or next((alias_index[norm_key(a)] for a in e["_aliases"] if norm_key(a) in alias_index), None)
if target is None and k in terms:
target = k
if target:
t = terms[target]
if e["_name"] not in t["aliases"] and norm_key(e["_name"]) != t["key"]:
t["aliases"].append(e["_name"])
else:
t = get_term(e["_name"], "concept")
t["lang"] = "en"
en_new += 1
for a in e["_aliases"]:
if a not in t["aliases"]:
t["aliases"].append(a)
t["index_entries"].append({"book_id": e["book_id"], "raw": e["term"], "pages": e["pages"]})
for p in e["pages"]:
if {"book_id": e["book_id"], "page": p} not in t["sources"]:
t["sources"].append({"book_id": e["book_id"], "page": p})
log(f"영문 독립 용어 {en_new}개")
for x in xrefs:
src = terms.get(norm_key(split_name(x["see"])[0]))
if src and x["term"] not in src["aliases"]:
src["aliases"].append(x["term"])
# ---- 4) 출처마다 단원·은하·문장 연결, 정의 후보 선택
log("출처·정의 연결 중…")
for t in terms.values():
best_def = None
galaxy_votes = defaultdict(float)
memberships = {}
for s in t["sources"]:
b = books_by_id[s["book_id"]]
node = chidx.locate(b["book_id"], s["page"])
chapter, section, chain = chidx.chapter_section(node)
s["toc_id"] = node["id"] if node else None
s["chapter_id"] = chapter["id"] if chapter else None
s["section_id"] = section["id"] if section else None
s["path"] = " › ".join(n["title"] for n in chain[1:]) if chain else ""
g = galaxy_for(b, chapter, chain)
s["galaxy_id"] = g
if g:
galaxy_votes[g] += 3 if b["primary"] else 1
if b["primary"] and section:
memberships[section["id"]] = g
item = next((n for n in chain if n["kind"] == "item"), None)
if item and section["id"] not in t.setdefault("items", {}):
t["items"][section["id"]] = item["id"]
pi = corpus.pdf_index(b, s["page"])
s["pdf_page"] = pi + 1 if pi is not None else None
s["snippet"] = None
if pi is None:
continue
cand = []
for sent in corpus.sentences(b["book_id"], pi):
sc = score_sentence(sent, t["name"], t["key"])
if sc is not None:
cand.append((sc, sent))
if cand:
cand.sort(key=lambda x: (-x[0], len(x[1])))
sc, sent = cand[0]
s["snippet"] = sent
weight = sc + (1 if b["primary"] else 0)
if sc >= 7 and (best_def is None or weight > best_def[0]):
best_def = (weight, sent, b["book_id"], s["page"])
t["galaxy_id"] = max(galaxy_votes, key=galaxy_votes.get) if galaxy_votes else None
# 주 소속 절: 주 은하에 속한 절 가운데 처음 나온 것
t["section_ids"] = list(dict.fromkeys(memberships))
prim_secs = [sid for sid, g in memberships.items() if g == t["galaxy_id"]]
t["section_id"] = prim_secs[0] if prim_secs else None
if best_def:
t["definition"] = {"text": best_def[1], "book_id": best_def[2], "page": best_def[3], "method": "auto"}
else:
t["definition"] = None
# 보조 교재에만 있는 용어: 2026 절(하위 항목 제목 포함)과 출처 경로의 글자 bigram 유사도로 배정
def bigrams(text):
k = norm_key(text)
return {k[i:i + 2] for i in range(len(k) - 1)}
primary_nodes = [n for b in BOOKS if b["primary"] for n in toc[b["book_id"]]]
sec_nodes = [n for n in primary_nodes if n["kind"] == "section"]
sec_galaxy, sec_bag, item_bag, sec_items = {}, {}, {}, defaultdict(list)
for n in sec_nodes:
ch, _, chain = chidx.chapter_section(n)
sec_galaxy[n["id"]] = galaxy_for(books_by_id[n["book_id"]], ch, chain)
sec_bag[n["id"]] = bigrams(n["title"]) | bigrams(n["title"])
for n in primary_nodes:
if n["kind"] in ("item", "subitem"):
chain = chidx.ancestors(n)
sec = next((c for c in chain if c["kind"] == "section"), None)
if sec:
sec_bag[sec["id"]] |= bigrams(n["title"])
if n["kind"] == "item":
sec_items[sec["id"]].append(n)
item_bag[n["id"]] = bigrams(n["title"])
else:
it = next((c for c in chain if c["kind"] == "item"), None)
if it:
item_bag.setdefault(it["id"], set()).update(bigrams(n["title"]))
def sim(a, b):
return len(a & b) / ((len(a) * len(b)) ** 0.5 or 1)
# 보조 교재의 절 → 2026 절 매핑 (제목·상위·하위 항목 제목 전체의 bigram 유사도)
sec_map = {}
for b in BOOKS:
if b["primary"]:
continue
nodes = toc[b["book_id"]]
kids = defaultdict(list)
for n in nodes:
kids[n["parent_id"]].append(n)
def desc_bag(n):
out = bigrams(n["title"])
for k in kids[n["id"]]:
out |= desc_bag(k)
return out
for n in nodes:
if n["kind"] not in ("section", "chapter"):
continue
chain = chidx.ancestors(n)
ch = next((c for c in chain if c["kind"] == "chapter"), None)
mapped = galaxy_for(b, ch, chain)
titles = " ".join(c["title"] for c in chain[1:])
forced = next((p for kw, title in SECONDARY_SECTION_OVERRIDES if kw in titles
for p in sec_nodes if p["title"].strip() == title), None)
if forced:
sec_map[n["id"]] = forced["id"]
continue
bag = desc_bag(n) | {x for c in chain[1:-1] for x in bigrams(c["title"])}
sec_map[n["id"]] = max(sec_nodes, key=lambda p: sim(bag, sec_bag[p["id"]]) + (0.08 if sec_galaxy[p["id"]] == mapped else 0))["id"]
for t in terms.values():
if t.get("section_id"):
continue
votes = defaultdict(int)
for s in t["sources"]:
if books_by_id[s["book_id"]]["primary"]:
continue
key = s.get("section_id") or s.get("chapter_id")
if key in sec_map:
votes[sec_map[key]] += 1
if votes:
best = max(votes, key=votes.get)
else:
best = max(sec_nodes, key=lambda n: sim(bigrams(t["name"]), sec_bag[n["id"]]))["id"]
t["section_id"] = best
t["galaxy_id"] = sec_galaxy[best]
t["section_guess"] = True
for t in terms.values():
if not t["galaxy_id"]:
t["galaxy_id"] = sec_galaxy.get(t["section_id"], "g1")
# 절 안의 항목(지도에서 작은 묶음): 원문 위치가 없으면 이름 유사도로 추정
item = t.get("items", {}).get(t["section_id"])
if not item and sec_items.get(t["section_id"]):
bag = bigrams(t["name"]) | {b for s in t["sources"] for b in bigrams(s.get("path", "").split(" › ")[-1])}
item = max(sec_items[t["section_id"]], key=lambda n: sim(bag, item_bag.get(n["id"], set())))["id"]
t["item_id"] = item
# ---- 5) 시안 예시 설명 붙이기
def find(name):
k = norm_key(name)
if k in terms:
return terms[k]
if k in alias_index:
return terms[alias_index[k]]
return None
for name, desc, example, aliases in curated.WIREFRAME_TERMS:
t = find(name)
if not t:
log(f" (시안 용어 없음) {name}")
continue
t["easy"] = desc
t["example"] = example
t["search_terms"] = aliases.split()
# 쉬운 뜻·사례 초안 (data/drafts/out_*.json, 교재 문장을 바탕으로 작성한 검토 전 초안)
drafts = {}
for f in sorted((DATA_DIR / "drafts").glob("out_*.json")):
try:
drafts.update({k: v for k, v in json.loads(f.read_text()).items()
if v and v.get("easy") and v.get("confidence") != "low"}) # 신뢰도 낮은 초안은 제외
except json.JSONDecodeError as e:
log(f" 초안 파일 오류 {f.name}: {e}")
used = 0
for t in terms.values():
dr = drafts.get(t["id"])
if dr and not t.get("easy"):
t["easy"] = dr["easy"].strip()
t["example"] = (dr.get("example") or "").strip() or None
t["easy_method"] = "draft"
used += 1
log(f"쉬운 뜻 초안 {used}개 반영")
# ---- 6) 관계 초안
relations = {}
def add_rel(a, b, typ, label, method, evidence=None, directed=True):
if a is None or b is None or a["id"] == b["id"]:
return
r = rid(a["id"], b["id"], typ)
if r in relations or rid(b["id"], a["id"], typ) in relations and not directed:
return
relations[r] = {
"relation_id": r, "source": a["id"], "target": b["id"], "type": typ, "label": label,
"directed": directed, "method": method, "evidence": evidence or [], "review_status": "draft",
}
for a, b, typ, label in curated.WIREFRAME_RELATIONS:
add_rel(find(a), find(b), typ, label, "wireframe")
for a, b, label in curated.CONTRAST_PAIRS:
add_rel(find(a), find(b), "contrasts_with", label, "curated", directed=False)
# 목차 계층: 제목이 용어인 항목 → 상위 항목 (part_of), '과정' 아래 형제는 순서(precedes)
for b in BOOKS:
nodes = toc[b["book_id"]]
by_id = {n["id"]: n for n in nodes}
kids = defaultdict(list)
for n in nodes:
kids[n["parent_id"]].append(n)
for n in nodes:
parent = by_id.get(n["parent_id"])
tn = find(n["title"])
if parent and tn:
tp = find(parent["title"])
if tp:
add_rel(tn, tp, "part_of", f"목차에서 ‘{parent['title']}’의 하위 항목", "toc",
[{"book_id": b["book_id"], "page": n["page"]}])
for pid, ks in kids.items():
parent = by_id.get(pid)
if not parent or "과정" not in parent["title"] and parent["kind"] != "chapter":
continue
if "과정" not in parent["title"]:
continue
seq = [(k, find(k["title"])) for k in ks]
seq = [(k, t) for k, t in seq if t]
for (k1, t1), (k2, t2) in zip(seq, seq[1:]):
add_rel(t1, t2, "precedes", "다음 단계", "toc",
[{"book_id": b["book_id"], "page": k2["page"]}])
# 정의 문장 속에 등장하는 다른 용어 (가장 긴 것 최대 3개)
concept_terms = [t for t in terms.values() if t["kind"] == "concept" and len(t["key"]) >= 2 and HANGUL.search(t["name"])]
by_len = sorted(concept_terms, key=lambda t: -len(t["key"]))
for t in terms.values():
d = t.get("definition")
if not d:
continue
flat = norm_key(d["text"])
found = []
for o in by_len:
if o is t or len(o["key"]) < 3 or o["key"] in t["key"]:
continue
if o["key"] in flat and not any(o["key"] in f["key"] for f in found):
found.append(o)
if len(found) >= 3:
break
for o in found:
add_rel(t, o, "related_to", f"정의 문장에 ‘{o['name']}’이(가) 등장", "definition",
[{"book_id": d["book_id"], "page": d["page"]}])
# 이름 포함: '건강증진사업' → '건강증진' (같은 은하, 가장 긴 포함어 1개)
by_galaxy = defaultdict(list)
for t in concept_terms:
by_galaxy[t["galaxy_id"]].append(t)
for t in concept_terms:
best = None
for o in by_galaxy[t["galaxy_id"]]:
if o is t or len(o["key"]) < 3 or len(o["key"]) >= len(t["key"]):
continue
if o["key"] in t["key"] and (best is None or len(o["key"]) > len(best["key"])):
best = o
if best:
add_rel(t, best, "related_to", f"‘{best['name']}’에서 갈라져 나온 개념", "name")
# ---- 7) 출력
toc_out = []
for b in BOOKS:
for n in toc[b["book_id"]]:
ch, _, chain = chidx.chapter_section(n)
n = dict(n)
n["galaxy_id"] = galaxy_for(b, ch, chain)
toc_out.append(n)
term_out = []
for t in sorted(terms.values(), key=lambda t: t["name"]):
term_out.append({
"term_id": t["id"],
"name": t["name"],
"kind": t["kind"],
"lang": t.get("lang", "ko"),
"aliases": t["aliases"],
"search_terms": t.get("search_terms", []),
"galaxy_id": t["galaxy_id"],
"section_id": t["section_id"],
"section_guess": t.get("section_guess", False),
"item_id": t.get("item_id"),
"section_ids": t["section_ids"],
"definition": t["definition"],
"easy": t.get("easy"),
"example": t.get("example"),
"easy_method": t.get("easy_method") or ("wireframe" if t.get("easy") else None),
"sources": t["sources"],
"index_entries": t["index_entries"],
"review_status": "draft",
})
out = {
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
"books": [{k: v for k, v in b.items() if k not in ("file", "toc_pages", "index_pages")} | {"filename": b["file"]().split("/")[-1]} for b in BOOKS],
"galaxies": GALAXIES,
"toc": toc_out,
"terms": term_out,
"relations": list(relations.values()),
}
DATA_DIR.mkdir(exist_ok=True)
path = DATA_DIR / "atlas.json"
path.write_text(json.dumps(out, ensure_ascii=False, indent=1))
by_method = defaultdict(int)
for r in relations.values():
by_method[r["method"]] += 1
log(f"용어 {len(term_out)}개 (정의 후보 {sum(1 for t in term_out if t['definition'])}개), "
f"관계 {len(relations)}개 {dict(by_method)}, {time.time()-t0:.1f}s → {path}")
if __name__ == "__main__":
build()