Files
Community_Nursing_Atlas/pipeline/build.py
T
jsahn 4e943700fc 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.
2026-10-06 23:14:45 +09:00

632 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()