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:
@@ -0,0 +1 @@
|
||||
"""Stage 3 → OKF v0.2 위키 출력 (계획서 v4 §5.7)."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
|
||||
/* 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 후 ")을 나눠 처리한다. */
|
||||
let comment = '';
|
||||
if (/^\s*\*/.test(code)) { comment = code; code = ''; }
|
||||
else {
|
||||
const q = code.indexOf('"');
|
||||
if (q >= 0) { comment = code.slice(q); code = code.slice(0, q); }
|
||||
}
|
||||
code = code.replace(/('|')([^']*)('|')/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>
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user