"""Stage 1 — 정규화. 수집 API 결과 JSON(.txt)의 줄바꿈 아티팩트를 제거하고 프로그램/인클루드 단위 파일로 저장한다. 입력 파일 두 종류 (계획서 §1): - 패키지-프로그램 목록: 최상위가 배열이고 DEVCLASS 키 → packages.jsonl 로 병합 - 프로그램 소스: 최상위가 객체이고 MAIN_PROGRAM 키 → /.abap + meta.json 핵심: 파일이 strict JSON이 아니다. 문자열 내부에 raw 줄바꿈(0x0A)+공백 패딩이 word-wrap 아티팩트로 들어 있고, 실제 코드 줄바꿈은 이스케이프된 \\n 두 글자다. 따라서 JSON 파싱 전에 raw 텍스트 단계에서 정규화한다. """ from __future__ import annotations import argparse import hashlib import json import re import sys from pathlib import Path # 검증된 정규화 규칙: raw 줄바꿈 + 양쪽 패딩 → 공백 1개 (계획서 §1.2) _WRAP_ARTIFACT = re.compile(r"[ \t]*\n[ \t]*") def normalize_raw_text(raw: str) -> str: """word-wrap 아티팩트(raw 0x0A + 패딩)를 공백 1개로 치환해 strict JSON으로 만든다.""" return _WRAP_ARTIFACT.sub(" ", raw.replace("\r\n", "\n").replace("\r", "\n")) def parse_collected_file(raw: str) -> dict | list: """정규화 후 strict JSON 파싱.""" return json.loads(normalize_raw_text(raw)) def clean_text_field(value: str | None) -> str: """DESCRIPTION/TEXT 등 텍스트 필드의 잔여 아티팩트(연속 공백) 정리.""" if not value: return "" return re.sub(r"\s+", " ", value).strip() def sha256(text: str) -> str: return hashlib.sha256(text.encode("utf-8")).hexdigest() def classify(data: object) -> str: """파일 종류 판별: package_list | program_source | unknown""" if isinstance(data, list) and data and isinstance(data[0], dict) and "DEVCLASS" in data[0]: return "package_list" if isinstance(data, dict) and "MAIN_PROGRAM" in data: return "program_source" return "unknown" def write_program(data: dict, out_dir: Path) -> dict: """프로그램 소스 → data/normalized// 에 인클루드별 .abap + meta.json 저장.""" program = clean_text_field(data["MAIN_PROGRAM"]).upper() prog_dir = out_dir / program prog_dir.mkdir(parents=True, exist_ok=True) includes_meta = [] total_lines = 0 for inc in data.get("INCLUDE_PROGRAM", []): name = clean_text_field(inc.get("INCLUDE", "")).upper() if not name: continue # 이 시점에서 SOURCE_CODE 안의 "\n"(이스케이프)은 json.loads 가 진짜 줄바꿈으로 복원한 상태 code = inc.get("SOURCE_CODE", "") (prog_dir / f"{name}.abap").write_text(code, encoding="utf-8", newline="\n") lines = code.count("\n") + 1 if code else 0 total_lines += lines includes_meta.append({"include": name, "sha256": sha256(code), "line_count": lines}) meta = { "program": program, "description": clean_text_field(data.get("DESCRIPTION")), "includes": includes_meta, "text_symbols": [ {"symbol": clean_text_field(t.get("SYMBOL")), "text": clean_text_field(t.get("TEXT"))} for t in data.get("TEXT_SYMBOL", []) or [] ], "total_lines": total_lines, } (prog_dir / f"{program}.meta.json").write_text( json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8" ) return meta def run(raw_dir: Path, out_dir: Path, limit: int | None = None, dry_run: bool = False) -> dict: out_dir.mkdir(parents=True, exist_ok=True) errors_log = out_dir / "_errors.log" packages_path = out_dir / "packages.jsonl" stats = {"files": 0, "programs": 0, "includes": 0, "package_rows": 0, "total_lines": 0, "errors": 0} package_rows: list[dict] = [] tcode_rows: list[dict] = [] # ingest/from_sap.py 가 실어 보내는 TCODE_LIST → tcodes.jsonl error_lines: list[str] = [] files = sorted(p for p in raw_dir.rglob("*") if p.is_file() and p.suffix.lower() in {".txt", ".json"}) if limit: files = files[:limit] for path in files: stats["files"] += 1 try: data = parse_collected_file(path.read_text(encoding="utf-8")) except Exception as e: # noqa: BLE001 — 실패 기록 후 계속 (계획서 §3) stats["errors"] += 1 error_lines.append(f"{path}\t{type(e).__name__}: {e}") continue kind = classify(data) if kind == "package_list": for row in data: package_rows.append( { "devclass": clean_text_field(row.get("DEVCLASS")).upper(), "obj_name": clean_text_field(row.get("OBJ_NAME")).upper(), "text": clean_text_field(row.get("TEXT")), "created_on": clean_text_field(row.get("CREATED_ON")), "changed_on": clean_text_field(row.get("CHANGED_ON")), } ) stats["package_rows"] += len(data) elif kind == "program_source": if dry_run: stats["programs"] += 1 stats["includes"] += len(data.get("INCLUDE_PROGRAM", [])) else: meta = write_program(data, out_dir) stats["programs"] += 1 stats["includes"] += len(meta["includes"]) stats["total_lines"] += meta["total_lines"] for t in data.get("TCODE_LIST") or []: tcode = clean_text_field(t.get("TCODE")).upper() if tcode: tcode_rows.append({"tcode": tcode, "program": meta["program"], "text_ko": clean_text_field(t.get("TTEXT")) or meta["description"]}) else: stats["errors"] += 1 error_lines.append(f"{path}\tUNKNOWN_FORMAT") if not dry_run: if package_rows: with packages_path.open("w", encoding="utf-8") as f: for row in package_rows: f.write(json.dumps(row, ensure_ascii=False) + "\n") if tcode_rows: # from_dir.py 와 같은 파일·모양. 로더(index/loader.py load_tcodes)가 tcode 테이블에 넣는다 (out_dir / "tcodes.jsonl").write_text( "".join(json.dumps(r, ensure_ascii=False) + "\n" for r in tcode_rows), encoding="utf-8") if error_lines: errors_log.write_text("\n".join(error_lines) + "\n", encoding="utf-8") return stats def main() -> None: ap = argparse.ArgumentParser(description="Stage 1 — 수집 JSON 정규화") ap.add_argument("raw_dir", nargs="?", default=None, help="수집 원본 디렉토리 (기본 data/raw)") ap.add_argument("--out", default=None, help="출력 디렉토리 (기본 data/normalized)") ap.add_argument("--limit", type=int, default=None) ap.add_argument("--dry-run", action="store_true") args = ap.parse_args() from config.settings import settings raw_dir = Path(args.raw_dir) if args.raw_dir else settings.data_raw out_dir = Path(args.out) if args.out else settings.data_normalized if not raw_dir.exists(): print(f"입력 디렉토리 없음: {raw_dir}", file=sys.stderr) sys.exit(1) stats = run(raw_dir, out_dir, limit=args.limit, dry_run=args.dry_run) print(json.dumps(stats, ensure_ascii=False)) if __name__ == "__main__": main()