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
+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()