Initial commit: ABAP indexing pipeline (ingest, parser, summarize, index, query, wiki)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
byeongwook.choi
2026-09-21 13:23:37 +09:00
co-authored by Claude Fable 5.1
commit 11ae3629b2
453 changed files with 259183 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Stage 3 → OKF v0.2 위키 출력 (계획서 v4 §5.7)."""
+173
View File
@@ -0,0 +1,173 @@
"""엔티티 위키 페이지 — tables/ functions/ tcodes/ (계획서 v4 §5.7, 수정사항 8번).
`table_ref` / `call_edge` 에서 **LLM 없이 결정론적으로** 만든다. 에이전트가
"이 테이블을 쓰는 프로그램 전부", "이 FM 을 호출하는 곳 전부" 를 한 문서에서 보게 하는 게 목적이다.
프로그램당 파일이 아니라 **엔티티당 파일**이라 1만 본으로 늘어도 파일 수는 DDIC 테이블·FM 개수로
묶인다 (구조 변경으로 없어진 units/ 의 70만 파일 문제가 재발하지 않는다).
`type` 값: abap-table / abap-function / abap-tcode.
"""
from __future__ import annotations
import re
import sqlite3
from pathlib import Path
from config.settings import settings
from parser.refs import FUNCTION_CALL_KINDS
from .merge import merge_write
from .okf_writer import PIPELINE, _fm, _lst, _now, _q
MAX_ROWS = 300 # 한 문서에 싣는 사용처 상한
def _safe(name: str) -> str:
return re.sub(r"[^0-9A-Za-z가-힣_.\-]", "_", name or "_")
def _unit_name(unit_id: str | None) -> str:
if not unit_id:
return ""
return unit_id.split("#")[3] if unit_id.count("#") >= 3 else unit_id
# ------------------------------------------------------------------ tables/
def _render_table(con: sqlite3.Connection, name: str) -> str:
rows = con.execute(
"SELECT tr.program, tr.mode, tr.unit_id, u.name AS unit_name, u.unit_type, "
"p.title_ko, p.devclass "
"FROM table_ref tr LEFT JOIN unit u ON u.unit_id=tr.unit_id "
"LEFT JOIN program p ON p.name=tr.program "
"WHERE tr.table_name=? ORDER BY tr.mode, tr.program, u.name",
(name,),
).fetchall()
readers = sorted({r["program"] for r in rows if r["mode"] == "read"})
writers = sorted({r["program"] for r in rows if r["mode"] == "write"})
ddic = con.execute("SELECT text_ko FROM ddic_table WHERE name=?", (name,)).fetchone()
text_ko = (ddic["text_ko"] if ddic else "") or ""
desc = f"{name} — 읽기 {len(readers)}개 / 쓰기 {len(writers)}개 프로그램"
fm = [
"type: abap-table",
f"title: {_q(name + ('' + text_ko if text_ko else ''))}",
f"description: {_q(text_ko or desc)}",
f"resource: {_q('abap://table/' + name)}",
f"tags: {_lst([t for t in ([text_ko] if text_ko else [])][:5])}",
f"generated: {{ by: {_q(PIPELINE)}, at: {_q(_now())} }}",
"status: stable",
f"x-read-programs: {len(readers)}",
f"x-write-programs: {len(writers)}",
]
body: list[str] = []
if not text_ko:
# DD02T 미확보 (ASSUMPTIONS.md §5) — 데이터가 들어오면 타이틀이 채워진다
body += ["> DDIC 테이블 텍스트 미확보 — 이름·사용처만 제공한다.", ""]
body += [f"## 쓰기 ({len(writers)}개 프로그램)", ""]
body += ([f"- [{p}](/programs/{p}.md)" for p in writers] or ["- (없음)"])
body += ["", f"## 읽기 ({len(readers)}개 프로그램)", ""]
body += ([f"- [{p}](/programs/{p}.md)" for p in readers] or ["- (없음)"])
body += ["", "## 사용 지점 (unit 단위)", "", "| 프로그램 | unit | 유형 | 모드 |", "|---|---|---|---|"]
for r in rows[:MAX_ROWS]:
body.append(f"| [{r['program']}](/programs/{r['program']}.md) "
f"| {r['unit_name'] or _unit_name(r['unit_id'])} | {r['unit_type'] or ''} | {r['mode']} |")
if len(rows) > MAX_ROWS:
body.append(f"| … | {len(rows) - MAX_ROWS}건 더 | | |")
body.append("")
return _fm(fm) + "\n" + "\n".join(body)
# --------------------------------------------------------------- functions/
def _render_function(con: sqlite3.Connection, name: str) -> str:
ph = ",".join("?" * len(FUNCTION_CALL_KINDS))
rows = con.execute(
f"SELECT e.program, e.from_unit, e.call_type, e.line, u.name AS unit_name, u.unit_type, p.title_ko "
f"FROM call_edge e LEFT JOIN unit u ON u.unit_id=e.from_unit "
f"LEFT JOIN program p ON p.name=e.program "
f"WHERE e.external_name=? AND e.call_type IN ({ph}) ORDER BY e.program, e.line",
(name, *FUNCTION_CALL_KINDS),
).fetchall()
callers = sorted({r["program"] for r in rows})
rfc = any(r["call_type"] == "call_function_rfc" for r in rows)
fm = [
"type: abap-function",
f"title: {_q(name)}",
f"description: {_q(f'{name} — 호출 프로그램 {len(callers)}' + (' (RFC 호출 포함)' if rfc else ''))}",
f"resource: {_q('abap://function/' + name)}",
f"tags: {_lst(['RFC'] if rfc else [])}",
f"generated: {{ by: {_q(PIPELINE)}, at: {_q(_now())} }}",
"status: stable",
f"x-caller-programs: {len(callers)}",
f"x-call-sites: {len(rows)}",
]
body = ["> 이 펑션모듈 자체의 소스는 수집 대상이 아니다 (ASSUMPTIONS.md §4) — 호출처만 제공한다.", "",
f"## 호출 프로그램 ({len(callers)}개)", ""]
body += ([f"- [{p}](/programs/{p}.md)" for p in callers] or ["- (없음)"])
body += ["", "## 호출 지점", "", "| 프로그램 | unit | 종류 | 줄 |", "|---|---|---|---|"]
for r in rows[:MAX_ROWS]:
body.append(f"| [{r['program']}](/programs/{r['program']}.md) "
f"| {r['unit_name'] or _unit_name(r['from_unit'])} | {r['call_type']} | {r['line']} |")
if len(rows) > MAX_ROWS:
body.append(f"| … | {len(rows) - MAX_ROWS}건 더 | | |")
body.append("")
return _fm(fm) + "\n" + "\n".join(body)
# ------------------------------------------------------------------ tcodes/
def _render_tcode(con: sqlite3.Connection, code: str, program: str, text_ko: str) -> str:
fm = [
"type: abap-tcode",
f"title: {_q(code + ('' + text_ko if text_ko else ''))}",
f"description: {_q(text_ko or code)}",
f"resource: {_q('abap://tcode/' + code)}",
"tags: []",
f"generated: {{ by: {_q(PIPELINE)}, at: {_q(_now())} }}",
"status: stable",
f"x-program: {program or ''}",
]
body = ["## 프로그램", "",
f"- [{program}](/programs/{program}.md)" if program else "- (미확보)", ""]
return _fm(fm) + "\n" + "\n".join(body)
# ------------------------------------------------------------------ 진입점
def write_entity_wiki(con: sqlite3.Connection, wiki_dir: Path | None = None,
limit: int | None = None) -> dict:
"""tables/ functions/ tcodes/ 를 (재)생성한다. 결정론적이라 프로그램 적재 후 언제든 호출 가능."""
wiki_dir = wiki_dir or settings.wiki_dir
stats = {"tables": 0, "functions": 0, "tcodes": 0}
names = [r["table_name"] for r in con.execute(
"SELECT DISTINCT table_name FROM table_ref WHERE table_name IS NOT NULL AND table_name!='' "
"ORDER BY table_name")]
for name in names[:limit] if limit else names:
rel = f"tables/{_safe(name)}.md"
merge_write(wiki_dir / rel, _render_table(con, name), code_hash=None,
wiki_dir=wiki_dir, doc_rel=rel)
stats["tables"] += 1
ph = ",".join("?" * len(FUNCTION_CALL_KINDS))
fnames = [r["external_name"] for r in con.execute(
f"SELECT DISTINCT external_name FROM call_edge WHERE external_name IS NOT NULL "
f"AND external_name!='' AND call_type IN ({ph}) ORDER BY external_name",
FUNCTION_CALL_KINDS)]
for name in fnames[:limit] if limit else fnames:
rel = f"functions/{_safe(name)}.md"
merge_write(wiki_dir / rel, _render_function(con, name), code_hash=None,
wiki_dir=wiki_dir, doc_rel=rel)
stats["functions"] += 1
# TSTC 미확보 상태에서는 0건 — 데이터가 들어오면 그대로 생성된다 (ASSUMPTIONS.md §5)
for r in con.execute("SELECT tcode, program, text_ko FROM tcode ORDER BY tcode"):
rel = f"tcodes/{_safe(r['tcode'])}.md"
merge_write(wiki_dir / rel, _render_tcode(con, r["tcode"], r["program"] or "", r["text_ko"] or ""),
code_hash=None, wiki_dir=wiki_dir, doc_rel=rel)
stats["tcodes"] += 1
return stats
+119
View File
@@ -0,0 +1,119 @@
"""사람 교정 보존 병합 (계획서 v4 §5.7).
대상은 git 에 커밋되는 program/package/concept 문서. units/ 는 재생성 산출물이라
merge 없이 항상 덮어쓴다(호출부에서 결정).
사람 교정 여부는 OKF v0.2 표준 필드로 판별한다 (docs/okf-version.md):
verified: { by: "human:<id>", at: <ISO-8601> } ← 사람이 검토·수정 후 저장할 때 추가
(구 계획서의 x-human-edited: true 규약도 하위호환으로 인식한다.)
규칙:
- verified(human) 없는 문서 → 새 내용으로 덮어쓴다.
- verified(human) 있는 문서 → 본문을 보존하고 관리 대상 사실 필드만 갱신한다.
코드 해시가 바뀐 경우에는 새 요약을 "## (자동 생성) 변경 후 요약" 섹션으로 덧붙이고
status: draft 로 내린 뒤 wiki/_review.md 에 재검토 항목을 올린다.
"""
from __future__ import annotations
import re
from datetime import date
from pathlib import Path
FM_RE = re.compile(r"\A---\r?\n(.*?)\r?\n---\r?\n?", re.S)
AUTO_SECTION = "## (자동 생성) 변경 후 요약"
# 배치가 항상 갱신하는 사실 필드 (모두 한 줄로 emit 됨 — okf_writer 참조)
MANAGED_KEYS = (
"resource", "tags", "generated",
"x-lines", "x-tables-read", "x-tables-write", "x-calls", "x-outputs",
"x-code-hash", "x-prompt-version", "x-unit-count", "x-chunk-count",
)
def split_frontmatter(text: str) -> tuple[str | None, str]:
"""(frontmatter 내용, 본문) — frontmatter 가 없으면 (None, 전체)."""
m = FM_RE.match(text)
if not m:
return None, text
return m.group(1), text[m.end():]
def is_human_verified(frontmatter: str | None) -> bool:
if not frontmatter:
return False
if re.search(r"""by:\s*['"]?human:""", frontmatter):
return True
return bool(re.search(r"^x-human-edited:\s*true\s*$", frontmatter, re.M)) # 하위호환
def get_scalar(frontmatter: str, key: str) -> str | None:
m = re.search(rf"""^{re.escape(key)}:\s*['"]?(.*?)['"]?\s*$""", frontmatter, re.M)
return m.group(1) if m else None
def _replace_or_append_line(fm: str, key: str, line: str) -> str:
pattern = re.compile(rf"^{re.escape(key)}:.*$", re.M)
if pattern.search(fm):
return pattern.sub(line.replace("\\", "\\\\"), fm, count=1)
return fm.rstrip("\n") + "\n" + line
def _strip_auto_section(body: str) -> str:
idx = body.find(AUTO_SECTION)
return body if idx < 0 else body[:idx].rstrip("\n") + "\n"
def add_review_entry(wiki_dir: Path, doc_rel: str, reason: str) -> None:
review = wiki_dir / "_review.md"
lines = review.read_text(encoding="utf-8").splitlines() if review.exists() else [
"# 재검토 필요 문서", "",
"코드가 바뀌어 사람 교정본과 어긋날 수 있는 문서 목록 (배치가 자동 추가).", "",
]
entry = f"- [ ] {doc_rel}{reason} ({date.today().isoformat()})"
if not any(doc_rel in ln for ln in lines):
lines.append(entry)
review.write_text("\n".join(lines) + "\n", encoding="utf-8")
def merge_write(path: Path, new_text: str, *, code_hash: str | None,
wiki_dir: Path, doc_rel: str) -> str:
"""새로 생성한 문서(new_text)를 사람 교정 보존 규칙에 따라 path 에 쓴다.
반환: created | overwritten | facts_updated | draft_appended
"""
path.parent.mkdir(parents=True, exist_ok=True)
if not path.exists():
path.write_text(new_text, encoding="utf-8")
return "created"
old = path.read_text(encoding="utf-8")
fm_old, body_old = split_frontmatter(old)
if not is_human_verified(fm_old):
path.write_text(new_text, encoding="utf-8")
return "overwritten"
# --- 사람 검토 문서: 본문 보존, 사실 필드만 갱신 ---
fm_new, body_new = split_frontmatter(new_text)
assert fm_new is not None
fm = fm_old or ""
for key in MANAGED_KEYS:
m = re.search(rf"^{re.escape(key)}:.*$", fm_new, re.M)
if m:
fm = _replace_or_append_line(fm, key, m.group(0))
old_hash = get_scalar(fm_old or "", "x-code-hash")
changed = bool(code_hash and old_hash and old_hash != code_hash)
if changed:
fm = _replace_or_append_line(fm, "status", "status: draft")
body = (_strip_auto_section(body_old).rstrip("\n")
+ f"\n\n{AUTO_SECTION}\n\n"
+ "> 코드가 변경되어 아래 요약을 새로 생성했습니다. 위 본문(사람 검토됨)과 대조해 반영해 주세요.\n\n"
+ body_new.lstrip("\n"))
result = "draft_appended"
add_review_entry(wiki_dir, doc_rel, "코드 변경 — 사람 교정본과 대조 필요")
else:
body = body_old
result = "facts_updated"
path.write_text(f"---\n{fm.strip()}\n---\n{body}", encoding="utf-8")
return result
+460
View File
@@ -0,0 +1,460 @@
"""OKF v0.2 마크다운 위키 출력 (계획서 v4 §5.7, docs/logic-chunk-design.md).
- 문서 단위는 프로그램. LLM 이 골라낸 로직 조각(logic_chunk)은 프로그램 문서 안의
`## 로직 조각` 섹션(조각마다 H3, resource 줄 범위)으로 들어간다. unit 별 개별 문서는 만들지 않는다
(1만 프로그램 × 수십 unit 파일 문제 회피).
- 요약이 아직 없는 program 도 파서 구조 사실만으로 문서를 생성한다
(원칙: 사실은 파서가, 해석은 LLM이 — 요약이 생기면 다음 실행에서 채워짐).
- 커밋·사람 교정 대상: programs/ packages/ concepts/ (merge 규칙 적용).
- OKF v0.2 필드 매핑(스펙 확인 2026-08-26, docs/okf-version.md):
generated: {by, at} = 생성 주체 / verified(human:) = 사람 교정 / status: draft = 재검토 필요.
x-* 는 스펙이 허용하는 확장 필드(소비자는 미지 키를 보존해야 함).
"""
from __future__ import annotations
import re
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from config.settings import settings
from index import decls as decls_mod
from index.db import connect, loads
from parser.refs import EXTERNAL_CALL_KINDS
from .merge import merge_write
PIPELINE = "abap-indexing/wiki_out"
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def _q(s: str) -> str:
"""YAML 이중따옴표 스칼라."""
return '"' + str(s).replace("\\", "\\\\").replace('"', '\\"') + '"'
def _lst(items) -> str:
"""한 줄 YAML 리스트 — merge.py 가 줄 단위로 갱신할 수 있게 반드시 한 줄."""
return "[" + ", ".join(_q(i) for i in items) + "]"
def _safe_name(name: str) -> str:
return re.sub(r"[^0-9A-Za-z_.\-#]", "_", name or "_")
def _fm(lines: list[str]) -> str:
return "---\n" + "\n".join(lines) + "\n---\n"
def _anchor(text: str) -> str:
"""H3 제목 → 마크다운 링크용 앵커 (영문·숫자·한글·하이픈만)."""
return re.sub(r"[^0-9a-z가-힣\-]", "", text.lower().replace(" ", "-"))
# ------------------------------------------------------------- 로직 조각 섹션
MAX_CHUNK_CODE_LINES = 120 # 이보다 긴 조각은 앞부분만 싣고 나머지는 API 로 안내
def _chunk_code(lines: list[str], line_start: int, line_end: int) -> tuple[list[str], int]:
"""조각의 소스 원문을 '줄번호| 코드' 형태로. (표시 줄, 생략된 줄 수)"""
out: list[str] = []
end = min(line_end, line_start + MAX_CHUNK_CODE_LINES - 1)
for no in range(line_start, end + 1):
if 1 <= no <= len(lines):
out.append(f"{no:6d}| {lines[no - 1]}")
return out, max(0, line_end - end)
def _render_chunks(con: sqlite3.Connection, program: str,
unit_by_id: dict[str, sqlite3.Row]) -> tuple[list[str], dict[tuple, dict]]:
"""프로그램 문서의 `## 로직 조각` 섹션 — **소스 원문 + 자연어 설명**을 한 쌍으로 싣는다.
조각의 핵심은 "이 코드가 업무적으로 무엇을 하는가" 이므로 설명만 있으면 검증할 수 없다.
코드 원문을 함께 실어 읽는 사람이 설명이 맞는지 바로 대조하게 한다.
순서는 **실행 순서(topo)** 를 따른다. include 이름 순으로 놓으면 공용 인클루드(ZFIALV·ZFICOM)의
유틸 조각이 프로그램 고유 업무 로직보다 앞에 나와 문서가 엉뚱해 보인다.
"""
needed: dict[tuple, dict] = {} # 조각들이 실제로 쓰는 선언 (정의부 섹션이 링크 대상으로 쓴다)
chunks = con.execute("SELECT * FROM logic_chunk WHERE program=?", (program,)).fetchall()
if not chunks:
return (["## 로직 조각", "",
"(아직 추출된 로직 조각이 없다 — LLM 조각 추출 전. `python -m summarize.runner` 참고)", ""],
needed)
topo = {r["unit_id"]: r["ord"] for r in
con.execute("SELECT unit_id, ord FROM topo WHERE program=?", (program,))}
code_by_inc = {r["include"]: (r["code"] or "").split("\n") for r in
con.execute("SELECT include, code FROM include WHERE program=?", (program,))}
ordered = sorted(chunks, key=lambda c: (topo.get(c["unit_id"], 9_999), c["include"],
c["line_start"]))
n_units = len({c["unit_id"] for c in chunks})
body = [
f"## 로직 조각 ({len(chunks)}건 / unit {n_units}개)", "",
"LLM 이 코드에서 골라낸 업무 로직 단위다. **조각마다 소스 원문과 자연어 설명을 함께 싣는다** — "
"설명이 코드와 맞는지 바로 대조할 수 있어야 하기 때문이다.",
"테이블·호출은 LLM 이 쓴 값이 아니라 파서가 조각 범위에서 다시 뽑은 사실이다. 순서는 실행 순서다.",
"",
]
current_unit = None
for c in ordered:
u = unit_by_id.get(c["unit_id"])
if c["unit_id"] != current_unit:
current_unit = c["unit_id"]
label = f"{u['unit_type']} {u['name']}" if u else c["unit_id"]
head = f"### {label}"
if u:
head += f" — `{c['include']}` L{u['line_start']}-L{u['line_end']} ({u['loc']}줄)"
body += [head, ""]
if u:
us = loads(u["summary_json"]) or {}
if us.get("purpose_ko"):
body += [f"*{us['purpose_ko']}*", ""]
cov = us.get("coverage")
if cov is not None:
body += [f"> 이 unit 의 조각 {us.get('chunk_count', 0)}개가 "
f"{us.get('covered_lines', 0)}/{u['loc']}줄({cov:.0%})을 덮는다. "
f"나머지는 선언·화면설정 등 업무 의미가 없다고 판단해 버린 구간이다.", ""]
body += [f"#### [{c['kind']}] {c['purpose_ko']}", ""]
# --- 소스 원문 ---
lines = code_by_inc.get(c["include"], [])
chunk_code = "\n".join(lines[c["line_start"] - 1 : c["line_end"]])
need = decls_mod.for_chunk(con, c, chunk_code)
shown, omitted = _chunk_code(lines, c["line_start"], c["line_end"])
if shown:
body += [f"```abap", *shown]
if omitted:
body.append(f" … ({omitted}줄 생략 — 전체는 "
f"GET /chunks/{c['chunk_id'].replace('#', '%23')})")
body += ["```", ""]
# --- 설명·사실 ---
if c["purpose_en"]:
body.append(f"- **EN**: {c['purpose_en']}")
tr, tw = loads(c["tables_read"]) or [], loads(c["tables_write"]) or []
calls, objs = loads(c["calls"]) or [], loads(c["sap_objects"]) or []
kw_ko = loads(c["keywords_ko"]) or []
kw_en = loads(c["keywords_en"]) or []
if tr or tw:
read_links = ", ".join(f"[{t}](/tables/{t}.md)" for t in tr) or "-"
write_links = ", ".join(f"[{t}](/tables/{t}.md)" for t in tw) or "-"
body.append(f"- 테이블(파서): read {read_links} / write {write_links}")
if calls:
body.append(f"- 호출(파서): {', '.join(calls)}")
if objs:
body.append(f"- SAP 객체: {', '.join(objs)}")
if kw_ko:
body.append(f"- 업무 키워드: {', '.join(kw_ko)}")
if kw_en:
body.append(f"- English: {', '.join(kw_en)}")
# 정의부 — 이 조각을 복사해 갈 때 함께 필요한 선언. 원문은 아래 `## 정의부` 에 한 번만 싣고
# 여기서는 **이름 + 그 선언으로 가는 링크**만 둔다 (같은 구조 선언이 조각마다 반복되면
# 문서를 읽을 수 없다). 링크는 문서 안 앵커라 뷰어에서는 눌러서 이동하고,
# LLM 이 읽을 때는 그냥 이름으로 읽힌다.
if need["declarations"]:
needed.update({_decl_key(d): d for d in need["declarations"]})
names = ", ".join(f"[`{d['name']}`](#{_decl_anchor(d)})" for d in need["declarations"][:12])
more = f"{len(need['declarations']) - 12}" if len(need["declarations"]) > 12 else ""
body.append(f"- 정의부(복사 시 함께 필요): {names}{more}")
if need["unresolved"]:
body.append(f"- 선언을 못 찾은 이름: {', '.join(need['unresolved'][:8])} "
f"(수집 안 된 인클루드·함수 인터페이스일 수 있다)")
body.append(f"- 위치: `abap://{program}/{c['include']}#L{c['line_start']}-L{c['line_end']}` "
f"· confidence {c['confidence']} · `{c['chunk_id']}`")
body.append("")
return body, needed
# ------------------------------------------------------------- 정의부 섹션
MAX_DECL_CODE_LINES = 60 # 한 선언이 이보다 길면 앞부분만 (구조체 수백 줄짜리가 있다)
def _decl_key(d: dict) -> tuple[str, str, int]:
"""선언 한 건을 가리키는 키. 같은 이름이 인클루드·unit 마다 따로 있을 수 있어 위치까지 쓴다."""
return (d["include"], d["name"], d["line_start"])
def _decl_anchor(d: dict) -> str:
"""조각 → 정의부 항목으로 가는 문서 내 앵커 id."""
inc, name, line = _decl_key(d)
return re.sub(r"[^0-9a-z가-힣_-]", "-", f"decl-{inc}-{line}-{name}".lower())
def _render_declarations(con: sqlite3.Connection, program: str,
needed: dict[tuple, dict]) -> list[str]:
"""`## 정의부` — 이 프로그램이 선언한 내부테이블·스트럭처·상수·필드심볼의 **원문**.
조각 코드는 로직만이라 그대로 붙여넣으면 컴파일되지 않는다. 조각마다 필요한 선언은 위에서
이름으로 가리키고(앵커 링크), 원문은 여기 한 번만 싣는다. 붙여넣기용 조립본은
`GET /chunks/<id>` 의 `declaration_code` 다 (index/decls.py).
싣는 것: 전역 선언 전체 + **조각이 실제로 쓰는 unit 로컬 선언**. 후자를 빼면 조각의
정의부 링크가 갈 곳이 없어진다(FORM 머리에서 선언된 작업영역이 그렇다).
"""
rows = decls_mod.program_declarations(con, program, scope="global")
seen = {_decl_key(r) for r in rows}
rows += [d for k, d in needed.items() if k not in seen]
if not rows:
return []
body = [
f"## 정의부 ({len(rows)}건)", "",
"로직 조각을 다른 프로그램으로 옮겨 붙일 때 **함께 가야 하는 선언**이다. "
"조각별로 무엇이 필요한지는 위 조각 항목의 `정의부` 줄에 있고(이름을 누르면 여기로 온다), "
"붙여넣기용으로 의존까지 묶은 코드는 `GET /chunks/<chunk_id>` 의 `declaration_code` 로 받는다.", "",
]
by_include: dict[str, list[dict]] = {}
for r in rows:
by_include.setdefault(r["include"], []).append(r)
for include, items in by_include.items():
items.sort(key=lambda d: d["line_start"])
body += [f"### `{include}` ({len(items)}건)", ""]
for d in items:
code_lines = d["code"].split("\n")
shown = code_lines[:MAX_DECL_CODE_LINES]
scope_note = " · unit 로컬" if d["scope"] == "unit" else ""
# `{#id}` 는 제목에 앵커를 다는 표기다 — 뷰어가 이걸 id 로 바꿔 링크가 걸린다.
body += [f"#### {d['name']}{d['kind']}{scope_note} · "
f"L{d['line_start']}-L{d['line_end']} {{#{_decl_anchor(d)}}}", "",
"```abap", *shown]
if len(code_lines) > MAX_DECL_CODE_LINES:
body.append(f"* … ({len(code_lines) - MAX_DECL_CODE_LINES}줄 생략)")
body += ["```", ""]
return body
# ------------------------------------------------------------- program 문서
def _render_program(con: sqlite3.Connection, p: sqlite3.Row) -> tuple[str, str | None]:
program = p["name"]
summary = loads(p["summary_json"]) or {}
units = con.execute(
"SELECT * FROM unit WHERE program=? ORDER BY include, line_start", (program,)
).fetchall()
# call_type 을 걸러야 한다 — 걸르지 않으면 CL_GUI_ALV_GRID=>MC_FC_* 상수 읽기와
# CALL SCREEN 의 화면번호('100')가 외부 호출 목록을 다 차지하고 정작 FM 호출이 상한에 밀린다.
ph = ",".join("?" * len(EXTERNAL_CALL_KINDS))
ext_calls = [r["external_name"] for r in con.execute(
f"SELECT DISTINCT external_name FROM call_edge WHERE program=? AND external_name IS NOT NULL "
f"AND call_type IN ({ph}) ORDER BY external_name LIMIT 40", (program, *EXTERNAL_CALL_KINDS))]
t_read = [r["table_name"] for r in con.execute(
"SELECT DISTINCT table_name FROM table_ref WHERE program=? AND mode='read' ORDER BY table_name",
(program,))]
t_write = [r["table_name"] for r in con.execute(
"SELECT DISTINCT table_name FROM table_ref WHERE program=? AND mode='write' ORDER BY table_name",
(program,))]
chunk_count = con.execute(
"SELECT COUNT(*) AS c FROM logic_chunk WHERE program=?", (program,)).fetchone()["c"]
decl_count = con.execute(
"SELECT COUNT(*) AS c FROM declaration WHERE program=? AND scope='global'",
(program,)).fetchone()["c"]
chunk_keywords = []
for r in con.execute("SELECT keywords_ko FROM logic_chunk WHERE program=?", (program,)):
chunk_keywords += loads(r["keywords_ko"]) or []
tags = list(dict.fromkeys(
(summary.get("business_tags") or [])
+ ([summary.get("sap_module")] if summary.get("sap_module") else [])
+ chunk_keywords
))[:20]
purpose = summary.get("business_purpose_ko") or ""
generated_by = f"abap-indexing/{settings.llm_model}" if (summary or chunk_count) else "abap-indexing/parser"
title = "{}{}".format(program, p["title_ko"] or "").rstrip("")
fm = [
"type: abap-program",
f"title: {_q(title)}",
f"description: {_q(purpose or p['title_ko'] or program)}",
f"resource: {_q('abap://' + program)}",
f"tags: {_lst(tags)}",
f"generated: {{ by: {_q(generated_by)}, at: {_q(_now())} }}",
"status: stable" if summary else "status: draft",
f"x-package: {p['devclass'] or ''}",
f"x-changed-on: {p['changed_on'] or ''}",
f"x-tables-read: {_lst(t_read[:40])}",
f"x-tables-write: {_lst(t_write[:40])}",
f"x-calls: {_lst(ext_calls)}",
f"x-code-hash: {_q(p['source_hash'] or '')}",
f"x-unit-count: {len(units)}",
f"x-chunk-count: {chunk_count}",
f"x-declaration-count: {decl_count}",
]
body: list[str] = ["## 업무 목적", "", purpose or p["title_ko"] or "(LLM 요약 전)", ""]
flow = summary.get("main_flow") or [
f"{u['name']} ({u['include']} L{u['line_start']})"
for u in units if u["unit_type"] == "EVENT"
]
if flow:
body += ["## 주 흐름", ""] + [f"{i}. {s}" for i, s in enumerate(flow, 1)] + [""]
if summary.get("key_internal_tables"):
body += ["## 핵심 내부테이블", ""]
for t in summary["key_internal_tables"]:
body.append(f"- `{t.get('name')}` — {t.get('desc_ko','')} "
f"(채움: {', '.join(t.get('filled_by', [])) or '?'} / "
f"소비: {', '.join(t.get('consumed_by', [])) or '?'})")
body.append("")
body += ["## 테이블 / 외부 호출", "",
f"- read: {', '.join(t_read[:30]) or '-'}",
f"- write: {', '.join(t_write[:30]) or '-'}",
f"- 외부 호출: {', '.join(ext_calls[:30]) or '-'}", ""]
unit_by_id = {u["unit_id"]: u for u in units}
chunk_body, needed_decls = _render_chunks(con, program, unit_by_id)
body += chunk_body
body += _render_declarations(con, program, needed_decls)
body += ["## Unit 목록 (컨테이너)", "", "| unit | 유형 | 위치 | 조각 | 한 줄 요약 |", "|---|---|---|---|---|"]
for u in units:
if u["unit_type"] not in ("FORM", "METHOD", "FUNCTION", "MODULE", "EVENT"):
continue
us = loads(u["summary_json"]) or {}
body.append(f"| {u['name']} | {u['unit_type']} | {u['include']} "
f"L{u['line_start']}-{u['line_end']} | {u['chunk_count'] or 0} | {us.get('purpose_ko','')} |")
body.append("")
return _fm(fm) + "\n" + "\n".join(body), p["source_hash"]
# ------------------------------------------------------------- package 문서
def _render_package(con: sqlite3.Connection, devclass: str, text_ko: str) -> str:
progs = con.execute(
"SELECT name, title_ko, has_source FROM program WHERE devclass=? ORDER BY name", (devclass,)
).fetchall()
title = "{}{}".format(devclass, text_ko or "").rstrip("")
fm = [
"type: abap-package",
f"title: {_q(title)}",
f"description: {_q(text_ko or devclass)}",
f"resource: {_q('abap://package/' + devclass)}",
"tags: []",
f"generated: {{ by: {_q(PIPELINE)}, at: {_q(_now())} }}",
f"x-program-count: {len(progs)}",
]
body = [f"## 프로그램 목록 ({len(progs)}건)", "", "| 프로그램 | 타이틀 | 소스 |", "|---|---|---|"]
body += [f"| [{r['name']}](/programs/{r['name']}.md) | {r['title_ko'] or ''} "
f"| {'O' if r['has_source'] else '-'} |" for r in progs]
body.append("")
return _fm(fm) + "\n" + "\n".join(body)
# ---------------------------------------------------------------- 매니페스트
def write_manifest(wiki_dir: Path, con: sqlite3.Connection | None = None) -> None:
"""OKF v0.2 예약 파일 index.md — 번들 루트 매니페스트 (구 계획의 okf.yaml 대체).
단순 디렉토리 나열이 아니라 **계층 진입점**이다 (수정사항 8번): SAP 모듈 → 패키지 →
프로그램으로 내려갈 수 있어야 에이전트가 위키를 따라 탐색할 수 있다.
"""
counts = {}
for sub in ("programs", "packages", "concepts", "tables", "functions", "tcodes"):
d = wiki_dir / sub
counts[sub] = sum(1 for _ in d.rglob("*.md")) if d.exists() else 0
chunk_re = re.compile(r"^x-chunk-count: (\d+)$", re.M)
counts["chunks"] = 0
if (wiki_dir / "programs").exists():
for f in (wiki_dir / "programs").glob("*.md"):
m = chunk_re.search(f.read_text(encoding="utf-8")[:4000])
counts["chunks"] += int(m.group(1)) if m else 0
fm = [
"type: index",
'okf_version: "0.2"',
f"title: {_q('ABAP 소스 위키')}",
f"description: {_q('ABAP 프로그램·로직 조각·엔티티(테이블/FM)의 위키 — 모듈에서 프로그램으로 내려가는 진입점')}",
f"generated: {{ by: {_q(PIPELINE)}, at: {_q(_now())} }}",
]
body = [
"## 구성", "",
f"- [programs/](/programs/) — 프로그램 문서 {counts['programs']}건, 로직 조각 {counts['chunks']}건 포함 (커밋·교정 대상)",
f"- [packages/](/packages/) — 패키지 문서 {counts['packages']}건 (커밋·교정 대상)",
f"- [concepts/](/concepts/) — 도메인 개념 {counts['concepts']}건 (질의 확장 사전과 같은 원본)",
f"- [tables/](/tables/) — 테이블 엔티티 {counts['tables']}건 (이 테이블을 읽고 쓰는 프로그램 전부)",
f"- [functions/](/functions/) — 펑션모듈·BAPI·RFC {counts['functions']}건 (호출처 전부)",
f"- [tcodes/](/tcodes/) — T-Code {counts['tcodes']}",
"",
"프로그램 문서의 `## 로직 조각` 섹션이 검색의 1차 단위다 (LLM 이 코드에서 골라낸 업무 로직).",
"위키 본문은 LLM 이 쓴 **해석**이다. \"어디서 채워지나/누가 호출하나\" 같은 사실은",
"index-api 의 trace/graph/usage 도구로 확인한다.",
"",
]
if con is not None:
body += _hierarchy_section(con)
wiki_dir.mkdir(parents=True, exist_ok=True)
(wiki_dir / "index.md").write_text(_fm(fm) + "\n" + "\n".join(body), encoding="utf-8")
def _hierarchy_section(con: sqlite3.Connection) -> list[str]:
"""SAP 모듈 → 패키지 → 프로그램 계층. 모듈은 요약의 sap_module, 없으면 이름 접두로 추정."""
rows = con.execute(
"SELECT p.name, p.devclass, p.title_ko, p.summary_json, p.has_source, "
"COALESCE(k.text_ko,'') AS pkg_text FROM program p "
"LEFT JOIN package k ON k.devclass=p.devclass WHERE p.has_source=1 ORDER BY p.name"
).fetchall()
tree: dict[str, dict[str, list[sqlite3.Row]]] = {}
for r in rows:
module = ((loads(r["summary_json"]) or {}).get("sap_module") or "").strip().upper()
if not module:
# 요약 전에도 계층이 서야 한다 — Z<모듈><번호> 관행에서 2글자 모듈 코드를 추정
m = re.match(r"^[YZ]([A-Z]{2})", r["name"] or "")
module = m.group(1) if m else "기타"
tree.setdefault(module, {}).setdefault(r["devclass"] or "(패키지 미지정)", []).append(r)
out = ["## 계층 — 모듈 → 패키지 → 프로그램", ""]
for module in sorted(tree):
n = sum(len(v) for v in tree[module].values())
out.append(f"### {module} ({n}본)")
out.append("")
for devclass, progs in sorted(tree[module].items()):
link = f"[{devclass}](/packages/{devclass}.md)" if devclass != "(패키지 미지정)" else devclass
out.append(f"- {link}")
for p in progs:
title = p["title_ko"] or ""
out.append(f" - [{p['name']}](/programs/{p['name']}.md)"
+ (f"{title}" if title else ""))
out.append("")
return out
# ------------------------------------------------------------------ 진입점
def write_program_wiki(program: str, con: sqlite3.Connection | None = None,
wiki_dir: Path | None = None) -> dict:
"""프로그램 하나의 program(로직 조각 포함)/package 문서를 생성·병합한다. 조각 추출 후 호출."""
own = con is None
con = con or connect()
wiki_dir = wiki_dir or settings.wiki_dir
stats: dict[str, int] = {"chunks": 0, "programs": 0, "packages": 0}
try:
p = con.execute(
"SELECT p.*, COALESCE(k.text_ko,'') AS pkg_text FROM program p "
"LEFT JOIN package k ON k.devclass=p.devclass WHERE p.name=?",
(program.upper(),),
).fetchone()
if not p:
raise ValueError(f"프로그램 '{program}' 이(가) 인덱스에 없습니다")
program = p["name"]
stats["chunks"] = con.execute(
"SELECT COUNT(*) AS c FROM logic_chunk WHERE program=?", (program,)).fetchone()["c"]
# program 문서 — 사람 교정 보존 병합
text, source_hash = _render_program(con, p)
rel = f"programs/{program}.md"
merge_write(wiki_dir / rel, text, code_hash=source_hash, wiki_dir=wiki_dir, doc_rel=rel)
stats["programs"] += 1
# package 문서 — 사람 교정 보존 병합 (해시 개념 없음 → 본문 보존만)
if p["devclass"]:
rel = f"packages/{p['devclass']}.md"
merge_write(wiki_dir / rel, _render_package(con, p["devclass"], p["pkg_text"]),
code_hash=None, wiki_dir=wiki_dir, doc_rel=rel)
stats["packages"] += 1
write_manifest(wiki_dir, con)
return stats
finally:
if own:
con.close()
+96
View File
@@ -0,0 +1,96 @@
"""위키 생성 CLI (계획서 v4 §5.7, §11.6).
python -m wiki_out.run [--program X] [--all] [--limit N] [--dry-run]
- 요약(Stage 3) 이 없어도 파서 구조 사실만으로 문서를 만든다(조각이 생기면 재실행 시 채워짐).
- programs/packages/concepts 는 사람 교정 보존 병합(merge.py). 로직 조각은 프로그램 문서 안의 섹션.
"""
from __future__ import annotations
import argparse
import json
import re
from config.glossary import load_glossary
from config.settings import settings
from index.db import connect
from .entities import write_entity_wiki
from .merge import merge_write
from .okf_writer import PIPELINE, _fm, _lst, _now, _q, write_manifest, write_program_wiki
def write_concepts() -> int:
"""도메인 용어 사전을 concepts/ 문서로 출력.
사전 로딩은 config.glossary 로 옮겼다 — 질의 확장(query/expand.py)이 같은 사전을 써야 하고,
파싱 규칙이 두 곳에 있으면 갈라진다.
"""
glossary = load_glossary()
count = 0
for concept, related in glossary.items():
fm = [
"type: concept",
f"title: {_q(concept)}",
f"description: {_q(concept + ' — 도메인 개념 (질의 확장용)')}",
f"tags: {_lst(related[:10])}",
f"generated: {{ by: {_q(PIPELINE)}, at: {_q(_now())} }}",
]
body = ["## 관련 용어", "",
"이 목록은 검색 질의 확장에도 쓰인다 — `/search/logic?q=` 에 아래 어느 말로 물어도 걸린다.",
""] + [f"- {t}" for t in related] + [""]
rel = f"concepts/{re.sub(r'[^0-9A-Za-z가-힣_.-]', '_', concept)}.md"
merge_write(settings.wiki_dir / rel, _fm(fm) + "\n" + "\n".join(body),
code_hash=None, wiki_dir=settings.wiki_dir, doc_rel=rel)
count += 1
return count
def main() -> None:
ap = argparse.ArgumentParser(description="OKF 위키 생성")
ap.add_argument("--program", default=None, help="프로그램 하나만")
ap.add_argument("--all", action="store_true", help="소스 있는 전체 프로그램")
ap.add_argument("--limit", type=int, default=None)
ap.add_argument("--no-entities", action="store_true",
help="tables/ functions/ tcodes/ 엔티티 페이지를 만들지 않는다")
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
con = connect()
try:
if args.program:
names = [args.program.upper()]
elif args.all:
rows = con.execute(
"SELECT name FROM program WHERE has_source=1 ORDER BY name").fetchall()
names = [r["name"] for r in rows]
else:
ap.error("--program 또는 --all 필요")
if args.limit:
names = names[: args.limit]
if args.dry_run:
print(json.dumps({"would_process": len(names)}, ensure_ascii=False))
return
totals = {"programs": 0, "chunks": 0, "packages": 0, "failed": 0}
for name in names:
try:
s = write_program_wiki(name, con=con)
totals["programs"] += s["programs"]
totals["chunks"] += s["chunks"]
totals["packages"] += s["packages"]
except Exception as e: # noqa: BLE001
totals["failed"] += 1
print(f"[FAIL] {name}: {type(e).__name__}: {e}")
totals["concepts"] = write_concepts()
if not args.no_entities:
# tables/ functions/ tcodes/ — 파서 사실만으로 만들어진다 (수정사항 8번)
totals.update(write_entity_wiki(con, settings.wiki_dir))
write_manifest(settings.wiki_dir, con)
print(json.dumps(totals, ensure_ascii=False))
finally:
con.close()
if __name__ == "__main__":
main()
+54
View File
@@ -0,0 +1,54 @@
"""위키 frontmatter 검증 (계획서 v4 §5.7 validate_wiki — CI/훅용).
python -m wiki_out.validate [--dir wiki]
규칙 (OKF v0.2 conformance):
- 예약 파일(index.md, log.md)과 _review.md 를 제외한 모든 .md 는 frontmatter 를 갖는다.
- frontmatter 에 비어 있지 않은 type 필드가 있어야 한다.
실패 목록을 출력하고 exit 1.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from config.settings import settings
from .merge import get_scalar, split_frontmatter
RESERVED = {"index.md", "log.md", "_review.md"}
def validate(wiki_dir: Path) -> list[str]:
errors: list[str] = []
if not wiki_dir.exists():
return [f"위키 디렉토리 없음: {wiki_dir}"]
for path in sorted(wiki_dir.rglob("*.md")):
rel = path.relative_to(wiki_dir).as_posix()
if path.name in RESERVED:
continue
fm, _ = split_frontmatter(path.read_text(encoding="utf-8"))
if fm is None:
errors.append(f"{rel}: frontmatter 없음")
continue
if not (get_scalar(fm, "type") or "").strip():
errors.append(f"{rel}: type 필드 없음/비어 있음")
return errors
def main() -> None:
ap = argparse.ArgumentParser(description="OKF 위키 검증")
ap.add_argument("--dir", default=None)
args = ap.parse_args()
wiki_dir = Path(args.dir) if args.dir else settings.wiki_dir
errors = validate(wiki_dir)
for e in errors:
print(f"[INVALID] {e}")
total = sum(1 for _ in wiki_dir.rglob("*.md")) if wiki_dir.exists() else 0
print(f"검사 {total}건, 오류 {len(errors)}")
sys.exit(1 if errors else 0)
if __name__ == "__main__":
main()
+482
View File
@@ -0,0 +1,482 @@
<!-- ABAP LLM 위키 뷰어 템플릿. 플레이스홀더는 DATA_JSON / GENERATED_AT 를 밑줄 두 개로 감싼 토큰.
이 주석에는 그 토큰도, HTML 주석 닫기 기호(하이픈 두 개 + 꺾쇠)도 적지 말 것 — 치환된 데이터가
주석 안에 박히고 ABAP 주석 화살표가 이 주석을 조기에 닫아 화면이 깨진 적이 있다 (2026-09-17).
빌드: python -m wiki_out.viewer [--out path] / 라이브: GET /wiki-viewer -->
<title>ABAP LLM 위키</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+KR:wght@400;500;700&family=IBM+Plex+Mono:wght@400;500&display=swap">
<style>
:root{
--bg:#F6F6F2; --surface:#FFFFFF; --surface2:#EFEFE9; --ink:#232527; --muted:#6C7075;
--line:#E2E2DA; --accent:#0E7490; --accent-deep:#0B5D74; --accent-bg:#E3F0F4;
--track:#E6E6DF; --ok:#15803D; --ok-bg:#E5F3E9; --warn:#96560F; --warn-bg:#F7EDDD;
--code-bg:#F0F0EA; --shadow:0 1px 3px rgba(35,37,39,.07);
}
@media (prefers-color-scheme: dark){
:root:not([data-theme="light"]){
--bg:#17181A; --surface:#1F2124; --surface2:#26282C; --ink:#E8E8E4; --muted:#9DA1A6;
--line:#33363B; --accent:#4FB8D3; --accent-deep:#7FCBDE; --accent-bg:#1C2B31;
--track:#2C2F33; --ok:#6FBE86; --ok-bg:#1E2E23; --warn:#D9A05B; --warn-bg:#322718;
--code-bg:#26282C; --shadow:0 1px 3px rgba(0,0,0,.35);
}
}
:root[data-theme="dark"]{
--bg:#17181A; --surface:#1F2124; --surface2:#26282C; --ink:#E8E8E4; --muted:#9DA1A6;
--line:#33363B; --accent:#4FB8D3; --accent-deep:#7FCBDE; --accent-bg:#1C2B31;
--track:#2C2F33; --ok:#6FBE86; --ok-bg:#1E2E23; --warn:#D9A05B; --warn-bg:#322718;
--code-bg:#26282C; --shadow:0 1px 3px rgba(0,0,0,.35);
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);
font:400 14px/1.65 "IBM Plex Sans KR",'Apple SD Gothic Neo','Malgun Gothic',sans-serif;}
.mono{font-family:"IBM Plex Mono",Consolas,monospace}
a{color:var(--accent-deep)}
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer;padding:0;text-align:left}
button:focus-visible,a:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:3px}
header.app{display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;
padding:14px 20px 12px;border-bottom:1px solid var(--line);background:var(--surface)}
header.app h1{margin:0;font-size:17px;font-weight:700;letter-spacing:-.01em}
header.app h1 button{font:inherit;font-weight:700;letter-spacing:inherit;cursor:pointer}
header.app h1 button:hover .accent{text-decoration:underline}
header.app h1 .accent{color:var(--accent-deep)}
header.app .sub{color:var(--muted);font-size:12px}
header.app .home-btn{margin-left:auto;font-size:12px;color:var(--accent-deep);font-weight:500}
.shell{display:grid;grid-template-columns:290px minmax(0,1fr);min-height:calc(100vh - 50px)}
@media(max-width:820px){.shell{grid-template-columns:1fr}.side{border-right:0;border-bottom:1px solid var(--line);max-height:45vh}}
/* ---------- 사이드바 ---------- */
.side{border-right:1px solid var(--line);background:var(--surface);overflow-y:auto;padding:12px 0 32px}
.side .search{margin:0 14px 10px}
.side input{width:100%;padding:7px 10px;border:1px solid var(--line);border-radius:6px;
background:var(--bg);color:var(--ink);font:inherit;font-size:13px}
.side .sec{margin:14px 0 4px;padding:0 16px;font-size:11px;font-weight:700;letter-spacing:.08em;
text-transform:uppercase;color:var(--muted)}
.side .sec-toggle{display:flex;align-items:center;gap:7px;width:100%;text-align:left;cursor:pointer}
.side .sec-toggle:hover{color:var(--ink)}
.side .sec-toggle .cnt{margin-left:auto;text-transform:none;letter-spacing:0}
.nav-item{display:flex;align-items:center;gap:7px;width:100%;padding:4px 16px;font-size:13px;line-height:1.4}
.nav-item:hover{background:var(--surface2)}
.nav-item.active{background:var(--accent-bg);color:var(--accent-deep);font-weight:500}
.nav-item .cnt{margin-left:auto;color:var(--muted);font-size:11px;font-variant-numeric:tabular-nums}
/* 프로그램 행: ▶ 는 펼치기, 이름은 문서 열기 — 역할이 다르므로 버튼을 나눈다 */
.prog-nav{display:flex;align-items:center;gap:4px;padding:0 10px 0 12px}
.prog-nav:hover{background:var(--surface2)}
.prog-nav .tw-btn{flex:none;width:18px;height:26px;display:flex;align-items:center;justify-content:center;border-radius:4px}
.prog-nav .tw-btn:hover{background:var(--line)}
.prog-nav .nav-name{flex:1;min-width:0;padding:4px 6px;font-family:"IBM Plex Mono",monospace;font-size:12.5px;
border-radius:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.prog-nav .nav-name:hover{color:var(--accent-deep)}
.prog-nav .nav-name.active{background:var(--accent-bg);color:var(--accent-deep);font-weight:500}
.prog-nav .cnt{flex:none;color:var(--muted);font-size:11px;font-variant-numeric:tabular-nums;padding-right:4px}
.tw{display:inline-block;width:10px;color:var(--muted);font-size:10px;transition:transform .12s}
@media(prefers-reduced-motion:reduce){.tw{transition:none}}
.tw.open{transform:rotate(90deg)}
.units{margin:0;padding:0 0 4px}
.unit-item{display:flex;gap:7px;width:100%;padding:2px 16px 2px 33px;font-size:12px;color:var(--muted)}
.unit-item:hover{background:var(--surface2)}
.unit-item.active{background:var(--accent-bg);color:var(--accent-deep)}
.unit-item .nm{font-family:"IBM Plex Mono",monospace;font-size:11.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.utype{flex:none;width:44px;font-size:9.5px;font-weight:500;letter-spacing:.04em;color:var(--muted)}
.unit-item .cnt{margin-left:auto}
.unit-empty{padding:3px 16px 5px 33px;font-size:11px;color:var(--muted);opacity:.7}
/* 사이드바의 조각 항목 — kind 색으로 종류를 구분한다 */
.chunk-item{display:flex;gap:6px;align-items:baseline;width:100%;padding:2px 14px 2px 44px;
font-size:11.5px;color:var(--muted);text-align:left}
.chunk-item:hover{background:var(--surface2);color:var(--ink)}
.chunk-item .cp{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:1.45}
.chunk-item .kind{flex:none;font-size:9px;font-weight:600;letter-spacing:.03em;padding:1px 4px;
border-radius:3px;background:var(--surface2);border:1px solid var(--line);text-transform:uppercase}
.kind.k-sql_select{color:#0b6bb8;border-color:#9dc7e8}
.kind.k-db_write{color:#b0480b;border-color:#e8b79d}
.kind.k-fm_call{color:#6b3fb8;border-color:#c0a9e8}
.kind.k-validation{color:#0a7b52;border-color:#9de0c2}
.kind.k-interface{color:#b80b6b;border-color:#e89dc7}
.kind.k-aggregation,.kind.k-calculation{color:#8a6d0b;border-color:#e0d09d}
.kind.k-control_flow{color:#555;border-color:var(--line)}
h3.flash,h4.flash{animation:fl 1.4s ease-out}
@keyframes fl{0%{background:var(--accent);color:#fff}100%{background:transparent}}
.dot{flex:none;width:6px;height:6px;border-radius:50%;margin-top:6px;background:var(--track)}
.dot.done{background:var(--accent)}
.results .nav-item{align-items:flex-start}
.results .meta{font-size:11px;color:var(--muted)}
/* ---------- 본문 ---------- */
.main{padding:22px 28px 60px;min-width:0}
.wrap{max-width:860px}
/* 개요 */
.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(118px,1fr));gap:10px;margin:6px 0 24px}
.tile{background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:12px 14px;box-shadow:var(--shadow)}
.tile .v{font-size:24px;font-weight:700;letter-spacing:-.02em;font-variant-numeric:tabular-nums;line-height:1.15}
.tile .l{font-size:11.5px;color:var(--muted);margin-top:2px;white-space:nowrap}
.tile.hero .v{color:var(--accent-deep)}
h2.sec-h{font-size:14px;font-weight:700;margin:26px 0 10px;display:flex;align-items:baseline;gap:10px}
h2.sec-h .hint{font-weight:400;color:var(--muted);font-size:12px}
/* 프로그램 목록 — 한 줄에 하나, 타이틀은 한 줄로 자른다 */
.plist{background:var(--surface);border:1px solid var(--line);border-radius:8px;overflow:hidden;box-shadow:var(--shadow)}
.plist .ph,.plist .prow{display:grid;grid-template-columns:130px minmax(0,1fr) 150px 56px;gap:14px;align-items:center;padding:0 14px}
.plist .ph{font-size:11px;font-weight:500;color:var(--muted);letter-spacing:.03em;background:var(--surface2);
border-bottom:1px solid var(--line);height:32px}
.plist .prow{width:100%;height:40px;border-bottom:1px solid var(--line);text-align:left}
.plist .prow:last-child{border-bottom:0}
.plist .prow:hover{background:var(--accent-bg)}
.plist .pn{font-family:"IBM Plex Mono",monospace;font-size:13px;font-weight:500;color:var(--accent-deep)}
.plist .pt{font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.plist .pt .draft{font-size:10px;color:var(--warn);border:1px solid var(--warn);border-radius:99px;padding:0 6px;margin-left:6px;vertical-align:1px}
.plist .num{text-align:right;font-variant-numeric:tabular-nums;font-size:12.5px;color:var(--muted)}
.meter{display:flex;align-items:center;gap:8px}
.meter .bar{display:block;flex:1;height:6px;border-radius:3px;background:var(--track);overflow:hidden}
.meter .fill{display:block;height:100%;border-radius:3px;background:var(--accent)}
.meter .lab{flex:none;width:26px;text-align:right;font-size:12.5px;font-variant-numeric:tabular-nums;font-weight:500}
@media(max-width:700px){.plist .ph,.plist .prow{grid-template-columns:110px minmax(0,1fr) 90px}.plist .num,.plist .ph .num{display:none}}
.note{font-size:12px;color:var(--muted);margin:14px 0 0}
/* 문서 뷰 */
.doc-head{border-bottom:1px solid var(--line);padding-bottom:14px;margin-bottom:18px}
.crumb{font-size:11.5px;color:var(--muted);font-family:"IBM Plex Mono",monospace;margin-bottom:6px}
.doc-head h2{margin:0 0 8px;font-size:21px;font-weight:700;letter-spacing:-.015em;text-wrap:balance}
.chips{display:flex;flex-wrap:wrap;gap:6px;align-items:center}
.chip{font-size:11px;font-weight:500;padding:2px 8px;border-radius:99px;border:1px solid var(--line);color:var(--muted);background:var(--surface)}
.chip.type{color:var(--accent-deep);border-color:var(--accent);background:var(--accent-bg)}
.chip.ok{color:var(--ok);border-color:var(--ok);background:var(--ok-bg)}
.chip.warn{color:var(--warn);border-color:var(--warn);background:var(--warn-bg)}
.gen{font-size:11.5px;color:var(--muted);margin-top:8px}
.gen .mono{font-size:11px}
.md{max-width:760px}
.md h2{font-size:16px;font-weight:700;margin:24px 0 8px;padding-top:14px;border-top:1px solid var(--line)}
.md h2:first-child{border-top:0;padding-top:0;margin-top:8px}
.md h3{font-size:14px;font-weight:700;margin:18px 0 6px}
.md p{margin:8px 0;max-width:70ch}
.md ul,.md ol{margin:8px 0;padding-left:22px}
.md li{margin:3px 0}
.md code{font-family:"IBM Plex Mono",monospace;font-size:12.5px;background:var(--code-bg);
padding:1px 5px;border-radius:4px}
.md blockquote{margin:10px 0;padding:6px 12px;border-left:3px solid var(--warn);
background:var(--warn-bg);border-radius:0 6px 6px 0;color:var(--ink);font-size:13px}
.md .tbl{overflow-x:auto;margin:10px 0;border:1px solid var(--line);border-radius:8px}
.md table{border-collapse:collapse;width:100%;font-size:12.5px}
.md th{text-align:left;font-weight:500;color:var(--muted);font-size:11.5px;letter-spacing:.03em;
padding:7px 12px;border-bottom:1px solid var(--line);background:var(--surface2);white-space:nowrap}
.md td{padding:6px 12px;border-bottom:1px solid var(--line);vertical-align:top}
.md tr:last-child td{border-bottom:0}
.md td:first-child{font-family:"IBM Plex Mono",monospace;font-size:12px;white-space:nowrap}
.md a.doc-link{color:var(--accent-deep);text-decoration:none;border-bottom:1px dashed var(--accent)}
.md a.doc-link:hover{border-bottom-style:solid}
.md a.dead{color:var(--muted);border-bottom:1px dotted var(--line);text-decoration:none;cursor:default}
.md a.jump{color:var(--accent-deep);text-decoration:none;border-bottom:1px dashed var(--accent);cursor:pointer}
.md a.jump:hover{border-bottom-style:solid;background:var(--accent-bg,var(--surface2))}
.empty{color:var(--muted);padding:40px 0;text-align:center}
/* ---- 로직 조각: 소스 원문 + 설명 ---- */
.md{max-width:980px}
.md h4.chunk-h{font-size:13.5px;font-weight:600;margin:20px 0 8px;padding:8px 12px;
background:var(--accent-bg,var(--surface2));border-left:3px solid var(--accent);
border-radius:0 6px 6px 0;line-height:1.5}
.md h4.decl-h{font-size:13px;font-weight:600;margin:18px 0 6px;padding:5px 10px;
background:var(--surface2);border-left:3px solid var(--track,var(--line));border-radius:0 6px 6px 0;
font-family:"IBM Plex Mono",monospace;scroll-margin-top:12px}
.md pre.code{margin:0 0 10px;padding:10px 0;background:var(--code-bg);border:1px solid var(--line);
border-radius:8px;overflow-x:auto}
.md pre.code code{display:block;background:none;padding:0 14px;border-radius:0;
font-family:"IBM Plex Mono",monospace;font-size:12px;line-height:1.65;white-space:pre;tab-size:2}
.md pre.code .ln{display:inline-block;min-width:62px;color:var(--muted);opacity:.65;
user-select:none;text-align:right;padding-right:10px}
.md pre.code .k{color:var(--accent-deep);font-weight:600}
.md pre.code .s{color:#0a7b52}
.md pre.code .c{color:var(--muted);font-style:italic}
@media (prefers-color-scheme:dark){
.md pre.code .s{color:#5fd4a0}
}
</style>
<header class="app">
<h1><button id="homeTitle" title="개요로 돌아가기"><span class="accent">ABAP</span> LLM 위키</button></h1>
<span class="sub">스냅샷 __GENERATED_AT__</span>
<button class="home-btn" id="homeBtn">개요로</button>
</header>
<div class="shell">
<nav class="side" id="side" aria-label="위키 탐색">
<div class="search"><input id="q" type="search" placeholder="프로그램·unit·태그 검색" aria-label="검색"></div>
<div id="tree"></div>
</nav>
<main class="main"><div class="wrap" id="view"></div></main>
</div>
<script type="application/json" id="data">__DATA_JSON__</script>
<script>
const D = JSON.parse(document.getElementById('data').textContent);
const view = document.getElementById('view');
const tree = document.getElementById('tree');
const esc = s => String(s??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
/* ABAP 간이 하이라이트 — ' 123| CODE' 형태의 줄번호를 분리해 표시한다 */
const ABAP_KW = new RegExp('\\b('+[
'SELECT','FROM','INTO','TABLE','WHERE','AND','OR','NOT','APPENDING','FOR','ALL','ENTRIES',
'INSERT','UPDATE','MODIFY','DELETE','COMMIT','ROLLBACK','WORK','APPEND','COLLECT','READ',
'LOOP','ENDLOOP','AT','IF','ELSEIF','ELSE','ENDIF','CASE','WHEN','ENDCASE','DO','ENDDO',
'WHILE','ENDWHILE','CHECK','EXIT','CONTINUE','RETURN','LEAVE','FORM','ENDFORM','PERFORM',
'CALL','FUNCTION','METHOD','ENDMETHOD','MODULE','ENDMODULE','EXPORTING','IMPORTING','CHANGING',
'TABLES','EXCEPTIONS','USING','DATA','TYPES','CONSTANTS','CLEAR','REFRESH','FREE','SORT',
'MOVE-CORRESPONDING','CONCATENATE','DESCRIBE','LINES','AUTHORITY-CHECK','OBJECT','ID','FIELD',
'MESSAGE','WITH','DISPLAY','LIKE','TYPE','VALUE','SPACE','INITIAL','IS','EQ','NE','GT','LT',
'DESTINATION','TRANSPORTING','KEY','INDEX','BY','OF','TO','ENDSELECT','SY-SUBRC'
].join('|')+')\\b','g');
function hlAbap(raw){
const m = raw.match(/^(\s*\d+\|)(.*)$/);
const no = m ? m[1] : '';
let code = m ? m[2] : raw;
code = esc(code);
/* 주석을 먼저 떼낸다 — 주석 안의 ABAP 키워드는 칠하지 않는다.
전체주석(줄 첫 문자 '*')과 인라인주석('"' → esc 후 &quot;)을 나눠 처리한다. */
let comment = '';
if (/^\s*\*/.test(code)) { comment = code; code = ''; }
else {
const q = code.indexOf('&quot;');
if (q >= 0) { comment = code.slice(q); code = code.slice(0, q); }
}
code = code.replace(/(&#39;|')([^']*)(&#39;|')/g, '<span class="s">$1$2$3</span>')
.replace(ABAP_KW, '<span class="k">$1</span>');
return (no?`<span class="ln">${esc(no)}</span>`:'') + code +
(comment?`<span class="c">${comment}</span>`:'');
}
/* ---------- 미니 마크다운 렌더러 ---------- */
function inline(s){
s = esc(s);
s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (m, t, href) => {
if (href.startsWith('/')) {
const p = href.replace(/^\//,'').replace(/\/$/,'');
const key = D.docs[p] ? p : (D.docs[p + '.md'] ? p + '.md' : null);
return key ? `<a class="doc-link" href="#" data-doc="${esc(key)}">${t}</a>`
: `<a class="dead" title="문서 없음(스냅샷 미포함)">${t}</a>`;
}
/* 문서 안 앵커(#decl-...) — 조각의 정의부 이름에서 아래 선언 원문으로 뛴다 */
if (href.startsWith('#')) return `<a class="jump" href="${esc(href)}" data-jump-id="${esc(href.slice(1))}">${t}</a>`;
return `<a class="dead">${t}</a>`;
});
return s;
}
/* 제목 끝의 `{#id}` = 앵커 표기. 마크다운으로 읽을 때는 잡음이 적고, 여기서는 id 가 된다. */
const HEAD_ID=/\s*\{#([^}\s]+)\}\s*$/;
function headId(text){
const m=text.match(HEAD_ID);
return m ? [text.slice(0, m.index), m[1]] : [text, ''];
}
function renderMd(src){
const out=[]; const lines=src.split('\n'); let i=0, list=null;
const closeList=()=>{ if(list){out.push(list==='ul'?'</ul>':'</ol>'); list=null;} };
while(i<lines.length){
const ln=lines[i];
/* 코드펜스 — 로직 조각의 소스 원문. 이게 없으면 코드가 한 줄씩 <p> 로 흩어진다. */
if(/^```/.test(ln)){
closeList();
const lang=ln.slice(3).trim(); const buf=[]; i++;
while(i<lines.length && !/^```/.test(lines[i])){ buf.push(lines[i]); i++; }
i++; /* 닫는 펜스 */
out.push(`<pre class="code${lang?' lang-'+esc(lang):''}"><code>`+
buf.map(l=>hlAbap(l)).join('\n')+'</code></pre>');
continue;
}
if(/^\s*$/.test(ln)){ closeList(); i++; continue; }
if(/^####\s/.test(ln)){ closeList();
const [txt,id]=headId(ln.slice(5));
const cls=id.startsWith('decl-')?'decl-h':'chunk-h'; /* 정의부 항목은 조각 제목과 다른 모양 */
out.push(`<h4 class="${cls}"${id?` id="${esc(id)}"`:''}>`+inline(txt)+'</h4>'); i++; continue; }
if(/^###\s/.test(ln)){ closeList();
const [txt,id]=headId(ln.slice(4));
out.push(`<h3${id?` id="${esc(id)}"`:''}>`+inline(txt)+'</h3>'); i++; continue; }
if(/^##\s/.test(ln)){ closeList(); out.push('<h2>'+inline(ln.slice(3))+'</h2>'); i++; continue; }
if(/^>\s?/.test(ln)){ closeList(); out.push('<blockquote>'+inline(ln.replace(/^>\s?/,''))+'</blockquote>'); i++; continue; }
if(/^\|/.test(ln)){
closeList(); const rows=[];
while(i<lines.length && /^\|/.test(lines[i])){ rows.push(lines[i]); i++; }
const cells=r=>r.replace(/^\||\|$/g,'').split('|').map(c=>c.trim());
let html='<div class="tbl"><table>';
const head=cells(rows[0]); const hasSep=rows[1]&&/^[\s|:-]+$/.test(rows[1]);
html+='<thead><tr>'+head.map(c=>'<th>'+inline(c)+'</th>').join('')+'</tr></thead><tbody>';
for(let r=hasSep?2:1; r<rows.length; r++)
html+='<tr>'+cells(rows[r]).map(c=>'<td>'+inline(c)+'</td>').join('')+'</tr>';
out.push(html+'</tbody></table></div>'); continue;
}
if(/^[-*]\s/.test(ln)){ if(list!=='ul'){closeList(); out.push('<ul>'); list='ul';}
out.push('<li>'+inline(ln.replace(/^[-*]\s/,''))+'</li>'); i++; continue; }
if(/^\d+\.\s/.test(ln)){ if(list!=='ol'){closeList(); out.push('<ol>'); list='ol';}
out.push('<li>'+inline(ln.replace(/^\d+\.\s/,''))+'</li>'); i++; continue; }
closeList(); out.push('<p>'+inline(ln)+'</p>'); i++;
}
closeList(); return out.join('\n');
}
/* ---------- 화면: 개요 ---------- */
function showHome(){
setActive(null);
const s=D.stats;
const maxc = Math.max(1, ...D.programs.map(p=>p.chunks||0));
let h=`<div class="tiles">
<div class="tile"><div class="v">${s.programs}</div><div class="l">프로그램</div></div>
<div class="tile hero"><div class="v">${(s.chunks||0).toLocaleString()}</div><div class="l">로직 조각</div></div>
<div class="tile"><div class="v">${(s.units_total||0).toLocaleString()}</div><div class="l">조각 있는 unit</div></div>
<div class="tile"><div class="v">${(s.tables||0)}</div><div class="l">테이블</div></div>
<div class="tile"><div class="v">${(s.functions||0)}</div><div class="l">펑션</div></div>
<div class="tile"><div class="v">${s.human_verified}</div><div class="l">사람 검토</div></div>
</div>
<h2 class="sec-h">프로그램 <span class="hint">행을 누르면 인덱싱 결과(요약 · 로직 조각)를 봅니다</span></h2>
<div class="plist">
<div class="ph"><span>프로그램</span><span>타이틀</span><span>로직 조각</span><span class="num">unit</span></div>`;
for(const p of D.programs){
const c = p.chunks||0;
const w = Math.round(100*c/maxc);
const draft = p.status==='draft' ? '<span class="draft">초안</span>' : '';
h+=`<button class="prow" data-doc="${esc(p.path)}" title="${esc(p.desc||p.title)}">
<span class="pn">${esc(p.name)}</span>
<span class="pt">${esc(p.title)}${draft}</span>
<span class="meter"><span class="bar"><span class="fill" style="width:${w}%"></span></span><span class="lab">${c}</span></span>
<span class="num">${p.total}</span></button>`;
}
h+=`</div>
<p class="note">로직 조각 = LLM 이 코드에서 골라낸 업무 로직 단위 (소스 원문 + 설명). 테이블·호출은 파서가 뽑은 사실이며,
"어디서 채워지나 · 누가 호출하나"는 trace / call-graph / usage 도구로 확인합니다.</p>`;
view.innerHTML=h;
}
/* ---------- 화면: 문서 ---------- */
function showDoc(path){
const d=D.docs[path]; if(!d){ view.innerHTML='<div class="empty">문서를 찾을 수 없습니다</div>'; return; }
setActive(path);
const chips=[`<span class="chip type">${esc(d.t)}</span>`];
if(d.ver) chips.push('<span class="chip ok">사람 검토됨</span>');
if(d.st==='draft') chips.push('<span class="chip warn">초안 — 요약 전/재검토</span>');
for(const t of (d.tags||[])) chips.push(`<span class="chip">${esc(t)}</span>`);
let h=`<div class="doc-head"><div class="crumb">${esc(path)}</div>
<h2>${esc(d.title||path)}</h2><div class="chips">${chips.join('')}</div>
<div class="gen">생성: <span class="mono">${esc(d.gen||'?')}</span>${d.res?` · <span class="mono">${esc(d.res)}</span>`:''}</div></div>
<article class="md">${renderMd(d.body)}</article>`;
view.innerHTML=h; view.scrollIntoView({block:'start'});
}
/* ---------- 사이드바 ---------- */
function navBtn(cls,doc,inner){ return `<button class="${cls}" data-doc="${esc(doc)}">${inner}</button>`; }
function buildTree(){
let h='<div class="sec">프로그램</div>';
for(const p of D.programs){
/* ▶ 는 unit·조각 목록 펼치기, 이름은 프로그램 문서(인덱싱 결과) 열기 */
h+=`<div class="prog-nav">
<button class="tw-btn" data-toggle="${esc(p.name)}" aria-expanded="false" title="unit · 조각 펼치기"><span class="tw" id="tw-${esc(p.name)}">▶</span></button>
<button class="nav-name" data-doc="${esc(p.path)}" data-expand="${esc(p.name)}" title="${esc(p.title)}">${esc(p.name)}</button>
<span class="cnt" title="로직 조각 수">${p.chunks||0}</span></div>
<div class="units" id="u-${esc(p.name)}" hidden></div>`;
}
if(D.packages.length){ h+='<div class="sec">패키지</div>';
for(const k of D.packages) h+=navBtn('nav-item',k.path,`<span>${esc(k.name)}</span><span class="cnt">${k.count}</span>`); }
if(D.concepts.length){ h+='<div class="sec">개념</div>';
for(const c of D.concepts) h+=navBtn('nav-item',c.path,`<span>${esc(c.name)}</span>`); }
/* 테이블·펑션은 수백 건이라 접어 둔다 — 펼치면 프로그램 목록을 밀어내기 때문 */
h+=entitySection('tables','테이블',D.tables,12);
h+=entitySection('functions','펑션',D.functions,11.5);
tree.innerHTML=h;
}
function entitySection(key,label,items,fs){
if(!items||!items.length) return '';
return `<button class="sec sec-toggle" data-esec="${key}" aria-expanded="false">
<span class="tw" id="tw-e-${key}">▶</span>${label}<span class="cnt">${items.length}</span></button>
<div class="units" id="e-${key}" hidden data-fs="${fs}"></div>`;
}
function fillEntities(key){
const box=document.getElementById('e-'+key); if(box.dataset.filled) return;
const items=D[key]||[]; const fs=box.dataset.fs;
box.innerHTML=items.map(t=>
`<button class="unit-item" data-doc="${esc(t.path)}"><span class="nm" style="font-size:${fs}px">${esc(t.name)}</span></button>`
).join(''); box.dataset.filled='1';
}
/* 프로그램을 펼치면 그 프로그램의 unit·조각 목록을 보여준다.
조각은 별 문서가 아니라 프로그램 문서 안의 섹션이므로, 클릭 시 해당 섹션으로 스크롤한다. */
function fillUnits(name){
const box=document.getElementById('u-'+name); if(box.dataset.filled) return;
const p=D.programs.find(x=>x.name===name); let h='';
if(!p.units.length) h='<div class="unit-empty">조각 없음 — 추출 전</div>';
for(const u of p.units){
h+=`<button class="unit-item" data-doc="${esc(p.path)}" data-jump="${esc(u.name)}">
<span class="utype">${esc(u.type)}</span><span class="nm">${esc(u.name)}</span>
<span class="cnt">${u.chunks.length}</span></button>`;
for(const c of u.chunks)
h+=`<button class="chunk-item" data-doc="${esc(p.path)}" data-jump="${esc(c.purpose)}">
<span class="kind k-${esc(c.kind)}">${esc(c.kind)}</span>
<span class="cp">${esc(c.purpose)}</span></button>`;
}
box.innerHTML=h; box.dataset.filled='1';
}
function setActive(path){
for(const el of document.querySelectorAll('.nav-item.active,.unit-item.active,.nav-name.active')) el.classList.remove('active');
if(!path) return;
const btn=[...tree.querySelectorAll('[data-doc]')].find(b=>b.dataset.doc===path);
if(btn){
btn.classList.add('active');
const box=btn.closest('.units');
if(box){ box.hidden=false; const tw=document.getElementById('tw-'+box.id.slice(2)); if(tw) tw.classList.add('open'); }
}
}
/* ---------- 검색 ---------- */
const idx = Object.entries(D.docs).map(([p,d])=>({p, s:(d.title+' '+(d.d||'')+' '+(d.tags||[]).join(' ')).toLowerCase()}));
document.getElementById('q').addEventListener('input', e=>{
const q=e.target.value.trim().toLowerCase();
if(!q){ buildTree(); return; }
const hits=idx.filter(x=>x.s.includes(q)).slice(0,60);
let h=`<div class="sec">검색 결과 ${hits.length}${hits.length===60?'+':''}</div><div class="results">`;
for(const {p} of hits){ const d=D.docs[p];
h+=navBtn('nav-item',p,`<span><span style="display:block">${esc(d.title)}</span><span class="meta">${esc(d.t)} · ${esc(p)}</span></span>`); }
tree.innerHTML=h+'</div>';
});
/* ---------- 이벤트 ---------- */
/* 문서 안 앵커로 이동 — 뒤로 돌아올 수 있게 브라우저 히스토리에도 남긴다 */
function jumpTo(id){
const el=document.getElementById(id);
if(!el) return;
el.scrollIntoView({block:'start', behavior:'smooth'});
el.classList.add('flash'); setTimeout(()=>el.classList.remove('flash'), 1400);
history.replaceState(null, '', '#'+id);
}
document.addEventListener('click', e=>{
const j=e.target.closest('[data-jump-id]');
if(j){ e.preventDefault(); jumpTo(j.dataset.jumpId); return; }
const t=e.target.closest('[data-toggle]');
if(t){ const n=t.dataset.toggle; fillUnits(n);
const box=document.getElementById('u-'+n); box.hidden=!box.hidden;
t.setAttribute('aria-expanded', String(!box.hidden));
document.getElementById('tw-'+n).classList.toggle('open', !box.hidden); return; }
const es=e.target.closest('[data-esec]');
if(es){ const k=es.dataset.esec; fillEntities(k);
const box=document.getElementById('e-'+k); box.hidden=!box.hidden;
es.setAttribute('aria-expanded', String(!box.hidden));
document.getElementById('tw-e-'+k).classList.toggle('open', !box.hidden); return; }
const b=e.target.closest('[data-doc]');
if(b){
e.preventDefault();
showDoc(b.dataset.doc);
/* 사이드바에서 프로그램 이름을 눌렀으면 그 프로그램의 unit·조각 목록도 펼쳐 둔다 */
const ex=b.dataset.expand;
if(ex){ fillUnits(ex); const box=document.getElementById('u-'+ex);
if(box){ box.hidden=false; document.getElementById('tw-'+ex).classList.add('open');
const tb=tree.querySelector(`[data-toggle="${CSS.escape(ex)}"]`); if(tb) tb.setAttribute('aria-expanded','true'); } }
/* 조각은 별 문서가 아니라 프로그램 문서 안의 섹션이다 — 해당 제목으로 스크롤한다 */
const jump=b.dataset.jump;
if(jump){
const want=jump.trim().toLowerCase();
const hs=[...view.querySelectorAll('h3,h4')];
const hit=hs.find(h=>h.textContent.trim().toLowerCase().includes(want));
if(hit){ hit.scrollIntoView({block:'start'}); hit.classList.add('flash');
setTimeout(()=>hit.classList.remove('flash'),1400); }
}
}
});
document.getElementById('homeBtn').addEventListener('click', showHome);
document.getElementById('homeTitle').addEventListener('click', ()=>{ showHome(); window.scrollTo({top:0}); });
buildTree(); showHome();
</script>
+194
View File
@@ -0,0 +1,194 @@
"""위키 뷰어 빌더 — wiki/ 를 스캔해 단일 HTML(뷰어)로 만든다.
- 템플릿: wiki_out/viewer.html (플레이스홀더 __DATA_JSON__, __GENERATED_AT__)
- 빌드: python -m wiki_out.viewer [--out PATH] (기본 data/wiki-viewer.html)
- 라이브: query/api.py 의 GET /wiki-viewer 가 요청마다 build_html() 호출
- 공유용 스냅샷은 이 출력을 Claude 아티팩트로 게시 (index.db 관측소와 같은 패턴)
"""
from __future__ import annotations
import argparse
import json
import re
from datetime import datetime
from pathlib import Path
from config.settings import settings
from .merge import is_human_verified, split_frontmatter
TEMPLATE = Path(__file__).parent / "viewer.html"
def _fm_get(fm: str, key: str) -> str | None:
m = re.search(rf"^{re.escape(key)}: (.*)$", fm, re.M)
return m.group(1).strip() if m else None
def _unquote(v: str | None) -> str:
if not v:
return ""
if v.startswith('"'):
try:
return json.loads(v)
except ValueError:
return v.strip('"')
return v
def _list(v: str | None) -> list:
if not v or not v.startswith("["):
return []
try:
return json.loads(v)
except ValueError:
return []
# 프로그램 문서의 조각 섹션을 읽어 사이드바 내비게이션을 만든다.
# okf_writer 가 생성하는 모양에 의존한다:
# ### FORM SELECT_DATA — `ZFIR0030F01` L64-L80 (17줄)
# #### [sql_select] 미처리 건을 읽는다
_UNIT_H = re.compile(r"^### (\w+) (.+?) — `([^`]+)` L(\d+)-L(\d+)", re.M)
_CHUNK_H = re.compile(r"^#### \[(\w+)\] (.+)$", re.M)
def _chunk_nav(body: str) -> list[dict]:
"""프로그램 문서 본문 → [{unit, unit_type, include, kind, purpose}] (문서 순서 유지).
구조 변경(2026-09-16)으로 units/ 문서가 없어졌으므로, 내비게이션은 프로그램 문서 안의
조각 섹션에서 뽑는다. 예전 x-unit-* frontmatter 기반 코드는 항상 빈 목록을 돌려줬다.
"""
out: list[dict] = []
cur = {"unit": "", "unit_type": "", "include": ""}
for m in re.finditer(r"^(###|####) (.*)$", body, re.M):
level, text = m.group(1), m.group(2)
if level == "###":
um = _UNIT_H.match(m.group(0))
if um:
cur = {"unit": um.group(2), "unit_type": um.group(1), "include": um.group(3)}
else:
cur = {"unit": text, "unit_type": "", "include": ""}
else:
cm = _CHUNK_H.match(m.group(0))
if cm:
out.append({**cur, "kind": cm.group(1), "purpose": cm.group(2)})
return out
def build_data(wiki_dir: Path | None = None) -> dict:
wiki_dir = wiki_dir or settings.wiki_dir
docs: dict[str, dict] = {}
chunk_nav: dict[str, list[dict]] = {} # program → 조각 목록
for path in sorted(wiki_dir.rglob("*.md")):
rel = path.relative_to(wiki_dir).as_posix()
if path.name in ("index.md", "log.md", "_review.md"):
continue
fm, body = split_frontmatter(path.read_text(encoding="utf-8"))
if fm is None:
continue
gen = ""
m = re.search(r'generated:.*?by:\s*"([^"]+)".*?at:\s*"([^"]+)"', fm)
if m:
gen = f"{m.group(1)} · {m.group(2)}"
doc = {
"t": _fm_get(fm, "type") or "?",
"title": _unquote(_fm_get(fm, "title")) or rel,
"d": _unquote(_fm_get(fm, "description")),
"tags": _list(_fm_get(fm, "tags")),
"st": _fm_get(fm, "status") or "stable",
"gen": gen,
"res": _unquote(_fm_get(fm, "resource")),
"ver": is_human_verified(fm),
"body": body.strip(),
}
docs[rel] = doc
if doc["t"] == "abap-program":
name = rel.rsplit("/", 1)[-1][:-3]
chunk_nav[name] = _chunk_nav(doc["body"])
doc["chunk_count"] = len(chunk_nav[name])
programs = []
for rel, d in docs.items():
if d["t"] != "abap-program":
continue
name = rel.rsplit("/", 1)[-1][:-3]
chunks = chunk_nav.get(name, [])
# unit 단위로 묶어 사이드바에 접히는 목록으로 준다
by_unit: list[dict] = []
for c in chunks:
if not by_unit or by_unit[-1]["name"] != c["unit"]:
by_unit.append({"name": c["unit"], "type": c["unit_type"],
"include": c["include"], "chunks": []})
by_unit[-1]["chunks"].append({"kind": c["kind"], "purpose": c["purpose"]})
# 개요 표에는 짧은 타이틀(frontmatter title 의 "NAME — " 뒤)만 싣고, 긴 LLM 설명은 desc 로 따로 준다
short = re.sub(rf"^{re.escape(name)}\s*[—-]\s*", "", d["title"]).strip() or name
programs.append({
"name": name, "path": rel, "title": short, "desc": d["d"] or "",
"status": d["st"], "verified": d["ver"],
"units": by_unit,
"chunks": len(chunks),
"done": len(by_unit), "total": len(by_unit),
})
programs.sort(key=lambda p: p["name"])
packages = sorted(
({"name": rel.rsplit("/", 1)[-1][:-3], "path": rel,
"count": len([1 for ln in d["body"].splitlines() if ln.startswith("| [")])}
for rel, d in docs.items() if d["t"] == "abap-package"),
key=lambda p: p["name"])
concepts = sorted(
({"name": d["title"], "path": rel} for rel, d in docs.items() if d["t"] == "concept"),
key=lambda c: c["name"])
review_path = wiki_dir / "_review.md"
review = (sum(1 for ln in review_path.read_text(encoding="utf-8").splitlines()
if ln.startswith("- [ ]")) if review_path.exists() else 0)
tables = sorted(
({"name": rel.rsplit("/", 1)[-1][:-3], "path": rel}
for rel, d in docs.items() if d["t"] == "abap-table"),
key=lambda c: c["name"])
functions = sorted(
({"name": rel.rsplit("/", 1)[-1][:-3], "path": rel}
for rel, d in docs.items() if d["t"] == "abap-function"),
key=lambda c: c["name"])
n_chunks = sum(p["chunks"] for p in programs)
units_total = sum(p["total"] for p in programs)
return {
"stats": {
"programs": len(programs),
"chunks": n_chunks,
"units_total": units_total, "units_done": units_total,
"tables": len(tables), "functions": len(functions),
"human_verified": sum(1 for d in docs.values() if d["ver"]),
"review": review,
},
"docs": docs, "programs": programs, "packages": packages, "concepts": concepts,
"tables": tables, "functions": functions,
}
def build_html(wiki_dir: Path | None = None) -> str:
data = build_data(wiki_dir)
payload = json.dumps(data, ensure_ascii=False, separators=(",", ":")).replace("</", "<\\/")
return (TEMPLATE.read_text(encoding="utf-8")
.replace("__DATA_JSON__", payload)
.replace("__GENERATED_AT__", datetime.now().strftime("%Y-%m-%d %H:%M")))
def main() -> None:
ap = argparse.ArgumentParser(description="위키 뷰어 HTML 빌드")
ap.add_argument("--out", default=None)
args = ap.parse_args()
out = Path(args.out) if args.out else settings.wiki_dir.parent / "data" / "wiki-viewer.html"
out.parent.mkdir(parents=True, exist_ok=True)
html = build_html()
out.write_text(html, encoding="utf-8")
print(f"{out} ({len(html) / 1024:.0f} KB)")
if __name__ == "__main__":
main()