normalize: 문자열 아닌 필드(숫자·null)에 죽지 않고, 파일 하나 실패가 전체를 멈추지 않게

고객사 실측: 패키지 목록의 CHANGED_ON 에서 TypeError 가 나며 정규화가 중단돼 206본 중 49본만 처리됐다.
clean_text_field 가 값을 문자열로 바꿔 처리하고, 파일별 처리 전체를 try 로 감싸 [FAIL] 기록 후 계속한다.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
byeongwook.choi
2026-09-21 16:13:46 +09:00
co-authored by Claude Fable 5.1
parent db50ec9d46
commit edb0b111a8
2 changed files with 69 additions and 35 deletions
+46 -35
View File
@@ -33,10 +33,16 @@ def parse_collected_file(raw: str) -> dict | list:
return json.loads(normalize_raw_text(raw))
def clean_text_field(value: str | None) -> str:
"""DESCRIPTION/TEXT 등 텍스트 필드의 잔여 아티팩트(연속 공백) 정리."""
if not value:
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()
@@ -106,41 +112,11 @@ def run(raw_dir: Path, out_dir: Path, limit: int | None = None, dry_run: bool =
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}")
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")
print(f"[FAIL] {path.name}: {type(e).__name__}: {e}", file=sys.stderr)
if not dry_run:
if package_rows:
@@ -157,6 +133,41 @@ def run(raw_dir: Path, out_dir: Path, limit: int | None = None, dry_run: bool =
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)")