브레인 일감(막힌 자리). 코덱스가 md 에 줄을 보태자 원천 벌의 옛 줄 번호가 밀려 산림 밑수가 238 → 105 로
떨어졌고, 머리글로 다시 맞추니 12장 같은 머리글 이웃 표를 집어 직접공사비가 움직였음. 추출기가 저장소에 없었음.
- `resources/data_cost_input_value/_build_pum.py` — md `|` 표를 칸 글자 그대로(2단 머리 안 펼침)
· 표 번호·절은 옛 벌에서 이어받음 — ① 글자 같은 표를 차례 지키며 맞댐(이웃으로 못 건너뜀)
② 맞은 표 사이 수가 같으면 같은 차례끼리(글자만 고쳐진 표) ③ 다르면 그 안 머리 같은 표끼리
· 새 표는 가장 큰 번호 다음 · 사라진 표는 번호 버리고 알림 · 줄 번호·sha256 은 새 md 에서
· 옛 md(177d8844)에 돌리면 산림 476 · 건설 2192 표가 원천 벌과 글자까지 같음(표 번호·절·줄·칸)
- 다시 뽑음 — 산림: 줄만 밀린 351 · 글자 바뀐 표 0 / 건설: 줄만 밀린 199 · 새 표 10(C2193~C2202 ·
코덱스가 뭉친 글을 표로 되살린 제8장 손료표 · 뒤 번호 안 밀림)
- ⭐ 금액 불변 — 다시 뽑고 공종 마스터를 다시 지어 원가계산서를 잼: 직접공사비 5,983,724 → 5,983,724 ·
총원가 9,203,637 · 합계 10,124,000 그대로(원가계산서 열세 칸 전부 같음 · `test_work_item_key_gate`) · 열쇠 새 발급 0
· 이 커밋만으로는 원문 맞대기 둘이 빨강(마스터 줄 번호가 옛 md 기준 — 받기 전부터 빨강) · 마스터 다시 지으면 초록
· ⚠ 마스터 다시 짓기는 데스크탑 서브 몫이라 커밋에 안 넣음 — 지으면 밑수 238 → 242 · 미확보 92 → 88
(코덱스가 되살린 밑수 줄: 5-19-1 ㎡ · 5-27 10㎡ · 5-28-1 100㎡ · 9-21 100㎡) · 지문 시험은 그때 갱신
- `test_build_pum.py` — 원천 벌이 md 와 같음 · 줄 37 보태도 번호·절 그대로 · 표가 끼어도 뒤 번호 안 밀림 ·
12-17-1 같은 머리글 표 가운데를 고쳐도 제 번호 · 빠진 표는 알림 · 2단 머리 안 펼침
- 원문 맞대기 알려진 목록을 마스터 다시 지은 뒤 모습에 맞춤 —
⚠ 5-26-1·2·3 코덱스 복원 「(인/992㎡당)」 ↔ 마스터 사람 판정 100㎡ 어긋남(사람 판단 필요) ·
5-28-2 「(일 당)」 은 작업조 표라 마스터가 일부러 비움
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
199 lines
9.0 KiB
Python
199 lines
9.0 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""원천 벌 뽑기 — 품셈 md → `pum_forest_2026.json` · `pum_const_2026.json` (2026-09-18 브레인).
|
|
|
|
왜 — 원문 md 가 고쳐지면(줄 보탬 · 표 머리 복원) 원천 벌의 **줄 번호가 밀려** 공종 마스터가 남의 줄을
|
|
읽었음(산림 밑수 238 → 105). 머리글로 다시 맞추니 12장에 같은 머리글이 많아 **이웃 표를 집었고**
|
|
직접공사비가 조용히 움직였음. 원천 벌을 뽑던 추출기가 저장소에 없었음.
|
|
|
|
무엇을 하나
|
|
- 표 = md 의 `|` 표(머리 줄 + `|---|` 줄 + 몸 줄). 칸 글자를 **그대로** 옮김 —
|
|
⚠ 2단 머리를 펼치지 않음(윗머리 「시 간 당(10-7)」 에 밑수·절 번호가 들어 있음).
|
|
- ⚠ **줄 번호로 옛 표를 찾지 않음.** 표 번호(`F0001`·`C0001`)와 절(`section`)은 옛 벌에서 이어받되,
|
|
짝은 이렇게 지음 — ① 글자(머리·몸)가 같은 표를 **차례를 지키며** 맞댐(앞뒤가 뒤바뀌지 않으니
|
|
머리글이 같은 이웃 표로 건너뛸 수 없음) ② 맞은 표 사이 빈 자리의 표 수가 같으면 **같은 차례끼리**
|
|
(글자만 고쳐진 표) ③ 수가 다르면 그 안에서 머리가 같은 표끼리 차례대로.
|
|
- 새 표 = 가장 큰 번호 다음 번호 · 절은 표 위로 올라가며 처음 만나는 `###` 제목이나
|
|
「숫자-숫자 글」 줄(옛 벌의 절 잡는 법 — 산림 476표가 이 규칙과 같음). ⚠ 알림에 뜸 — 사람이 볼 것.
|
|
- 사라진 표 = 번호를 버리고 알림에 적음(다른 표에 번호를 물려주지 않음).
|
|
- 줄 번호·sha256 은 새 md 에서. 표가 한 칸도 안 바뀌면 파일을 그대로 둠(`generated_at` 도).
|
|
|
|
돌리기: `./venv/Scripts/python.exe resources/data_cost_input_value/_build_pum.py [--check]`
|
|
`--check` — 쓰지 않고 달라질 것만 알림 · 달라지면 끝 코드 1.
|
|
금액 불변은 `resources/tester/test_work_item_key_gate.py` 원가계산서 시험(직접공사비 5,983,724)이 잼 —
|
|
다시 뽑은 뒤 공종 마스터를 다시 짓고 그 시험을 돌릴 것.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import difflib
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import sys
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from typing import Callable
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
CATALOGS = {
|
|
"pum_forest": ROOT / "resources/data_cost_input_value/pum_forest_2026.json",
|
|
"pum_const": ROOT / "resources/data_cost_input_value/pum_const_2026.json",
|
|
}
|
|
PREFIX = {"pum_forest": "F", "pum_const": "C"}
|
|
|
|
_SEP = re.compile(r"\s*\|(\s*:?-+:?\s*\|)+\s*")
|
|
#: 옛 벌의 절 줄 — `12-2 표면 마무리를 따른다.` 처럼 본문 인용도 절로 잡혀 있음(마스터 빌더가 바로잡음).
|
|
_SECTION_LINE = re.compile(r"(\d+-\d+(?:-\d+)*\.?\s+\S.*)$")
|
|
|
|
|
|
def _cells(line: str) -> list[str]:
|
|
s = line.strip()
|
|
s = s[1:-1] if s.endswith("|") else s[1:]
|
|
return [c.strip() for c in s.split("|")]
|
|
|
|
|
|
def md_tables(lines: list[str]) -> list[dict]:
|
|
"""md `|` 표 목록 — `line` 은 머리 줄 번호(1부터)."""
|
|
tables, i = [], 0
|
|
while i < len(lines):
|
|
if (
|
|
lines[i].lstrip().startswith("|")
|
|
and i + 1 < len(lines)
|
|
and _SEP.fullmatch(lines[i + 1])
|
|
):
|
|
j = i + 2
|
|
while j < len(lines) and lines[j].lstrip().startswith("|"):
|
|
j += 1
|
|
tables.append(
|
|
{
|
|
"line": i + 1,
|
|
"headers": _cells(lines[i]),
|
|
"rows": [_cells(x) for x in lines[i + 2 : j]],
|
|
}
|
|
)
|
|
i = j
|
|
else:
|
|
i += 1
|
|
return tables
|
|
|
|
|
|
def section_above(lines: list[str], line: int) -> str | None:
|
|
"""새 표의 절 — 위로 올라가며 처음 만나는 `###` 제목 또는 「숫자-숫자 글」 줄(표 줄은 건너뜀)."""
|
|
for k in range(line - 2, -1, -1):
|
|
s = lines[k]
|
|
if s.startswith("### "):
|
|
return s[4:].strip()
|
|
if s.lstrip().startswith("|"):
|
|
continue
|
|
if m := _SECTION_LINE.search(s):
|
|
return m.group(1).strip()
|
|
return None
|
|
|
|
|
|
def _sig(table: dict) -> str:
|
|
return json.dumps([table["headers"], table["rows"]], ensure_ascii=False)
|
|
|
|
|
|
def align(old: list[dict], new: list[dict]) -> list[int | None]:
|
|
"""`new[j]` 의 짝 `old` 차례(없으면 None). 차례를 지킴 — 짝끼리 앞뒤가 뒤바뀌지 않음."""
|
|
pair: list[int | None] = [None] * len(new)
|
|
|
|
def pair_blocks(a: list[str], b: list[str], i0: int, j0: int, inner: Callable | None) -> None:
|
|
matcher = difflib.SequenceMatcher(None, a, b, autojunk=False)
|
|
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
|
if tag == "equal" or (tag == "replace" and i2 - i1 == j2 - j1):
|
|
for k in range(j2 - j1):
|
|
pair[j0 + j1 + k] = i0 + i1 + k
|
|
elif tag == "replace" and inner:
|
|
inner(i0 + i1, i0 + i2, j0 + j1, j0 + j2)
|
|
|
|
def by_headers(i1: int, i2: int, j1: int, j2: int) -> None:
|
|
heads = lambda ts: [json.dumps(t["headers"], ensure_ascii=False) for t in ts] # noqa: E731
|
|
matcher = difflib.SequenceMatcher(
|
|
None, heads(old[i1:i2]), heads(new[j1:j2]), autojunk=False
|
|
)
|
|
for tag, a1, a2, b1, b2 in matcher.get_opcodes():
|
|
if tag == "equal":
|
|
for k in range(b2 - b1):
|
|
pair[j1 + b1 + k] = i1 + a1 + k
|
|
|
|
pair_blocks([_sig(t) for t in old], [_sig(t) for t in new], 0, 0, by_headers)
|
|
return pair
|
|
|
|
|
|
def extract(old_doc: dict, read: Callable[[str], bytes], dataset: str) -> tuple[dict, dict]:
|
|
"""옛 벌 + 원문 → (새 벌, 알림). `read(path)` 는 원문 파일 바이트."""
|
|
old_tables = old_doc["variables"]["pum"]["tables"]
|
|
per_file = "source_file" in old_tables[0]
|
|
sources = [s for s in old_doc["sources"] if s.get("role") == "primary"]
|
|
numbers = [int(t["table_id"][1:]) for t in old_tables]
|
|
next_number = max(numbers) + 1
|
|
report = {"same": 0, "moved": 0, "changed": [], "new": [], "removed": []}
|
|
tables, new_sources = [], []
|
|
for src in old_doc["sources"]:
|
|
data = read(src["path"])
|
|
new_sources.append({**src, "sha256": hashlib.sha256(data).hexdigest()})
|
|
for src in sources:
|
|
lines = read(src["path"]).decode("utf-8").splitlines()
|
|
old = [t for t in old_tables if not per_file or t["source_file"] == src["path"]]
|
|
new = md_tables(lines)
|
|
for j, k in enumerate(align(old, new)):
|
|
t = new[j]
|
|
if k is None:
|
|
table_id = f"{PREFIX[dataset]}{next_number:04d}"
|
|
next_number += 1
|
|
section = section_above(lines, t["line"])
|
|
report["new"].append({"table_id": table_id, "line": t["line"], "section": section})
|
|
else:
|
|
table_id, section = old[k]["table_id"], old[k]["section"]
|
|
if _sig(old[k]) != _sig(t):
|
|
report["changed"].append({"table_id": table_id, "line": t["line"]})
|
|
elif old[k]["line"] != t["line"]:
|
|
report["moved"] += 1
|
|
else:
|
|
report["same"] += 1
|
|
entry = {"table_id": table_id, "section": section, "line": t["line"], "headers": t["headers"], "rows": t["rows"]} # fmt: skip
|
|
if per_file:
|
|
entry["source_file"] = src["path"]
|
|
tables.append(entry)
|
|
kept = {old[k]["table_id"] for k in align(old, new) if k is not None}
|
|
report["removed"] += [{"table_id": t["table_id"], "section": t["section"]} for t in old if t["table_id"] not in kept] # fmt: skip
|
|
doc = {**old_doc, "sources": new_sources}
|
|
doc["variables"] = {
|
|
**old_doc["variables"],
|
|
"pum": {**old_doc["variables"]["pum"], "tables": tables},
|
|
}
|
|
if doc != old_doc:
|
|
doc["generated_at"] = datetime.now(timezone(timedelta(hours=9))).isoformat(
|
|
timespec="seconds"
|
|
)
|
|
return doc, report
|
|
|
|
|
|
def _read(path: str) -> bytes:
|
|
return (ROOT / path).read_bytes()
|
|
|
|
|
|
def main(check: bool) -> int:
|
|
dirty = False
|
|
for dataset, path in CATALOGS.items():
|
|
old_doc = json.loads(path.read_text(encoding="utf-8"))
|
|
doc, report = extract(old_doc, _read, dataset)
|
|
changed = doc != old_doc
|
|
dirty |= changed
|
|
print(
|
|
f"── {dataset}: 표 {len(doc['variables']['pum']['tables'])} · 그대로 {report['same']} · "
|
|
f"줄만 밀림 {report['moved']} · 글자 바뀜 {len(report['changed'])} · 새 표 {len(report['new'])} · "
|
|
f"사라진 표 {len(report['removed'])}" + ("" if changed else " — 바뀐 것 없음")
|
|
)
|
|
for key in ("changed", "new", "removed"):
|
|
for item in report[key]:
|
|
print(f" {key}: {json.dumps(item, ensure_ascii=False)}")
|
|
if changed and not check:
|
|
path.write_text(json.dumps(doc, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
return 1 if (check and dirty) else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main("--check" in sys.argv))
|