Files
byeongwook.choiandClaude Fable 5.1 edb0b111a8 normalize: 문자열 아닌 필드(숫자·null)에 죽지 않고, 파일 하나 실패가 전체를 멈추지 않게
고객사 실측: 패키지 목록의 CHANGED_ON 에서 TypeError 가 나며 정규화가 중단돼 206본 중 49본만 처리됐다.
clean_text_field 가 값을 문자열로 바꿔 처리하고, 파일별 처리 전체를 try 로 감싸 [FAIL] 기록 후 계속한다.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 16:13:46 +09:00

193 lines
8.0 KiB
Python

"""Stage 1 — 정규화.
수집 API 결과 JSON(.txt)의 줄바꿈 아티팩트를 제거하고 프로그램/인클루드 단위 파일로 저장한다.
입력 파일 두 종류 (계획서 §1):
- 패키지-프로그램 목록: 최상위가 배열이고 DEVCLASS 키 → packages.jsonl 로 병합
- 프로그램 소스: 최상위가 객체이고 MAIN_PROGRAM 키 → <PROGRAM>/<INCLUDE>.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: object) -> str:
"""DESCRIPTION/TEXT 등 텍스트 필드의 잔여 아티팩트(연속 공백) 정리.
JSON 규약(2026-09-14)의 SAP 응답은 숫자·null 이 섞여 온다(예: TOTAL_ROWS, 빈 날짜).
문자열이 아니어도 죽지 않고 문자열로 바꿔 정리한다 (고객사 실측: CHANGED_ON 에서 TypeError).
"""
if value is None or value is False:
return ""
if not isinstance(value, str):
value = str(value)
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/<PROGRAM>/ 에 인클루드별 .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"))
_handle_file(path, data, out_dir, dry_run, stats, package_rows, tcode_rows, error_lines)
except Exception as e: # noqa: BLE001 — 실패 기록 후 계속 (계획서 §3)
stats["errors"] += 1
error_lines.append(f"{path}\t{type(e).__name__}: {e}")
print(f"[FAIL] {path.name}: {type(e).__name__}: {e}", file=sys.stderr)
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 _handle_file(path: Path, data, out_dir: Path, dry_run: bool, stats: dict,
package_rows: list, tcode_rows: list, error_lines: list) -> None:
"""파일 한 건 분류·처리. 예외는 호출자가 기록하고 다음 파일로 넘어간다."""
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")
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()