Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
310 lines
14 KiB
Python
310 lines
14 KiB
Python
"""건설공사 표준품셈 2,192표 → **건설 공종 축**(`CW-00001`) (2026-09-17 브레인 8-2).
|
|
|
|
산림(`B08_Quantity_Build_WorkItemMaster.py`)과 같은 칸·같은 판정을 쓰고, 다른 셋만 여기 둠.
|
|
|
|
① 목차표가 없음 — 합본 원문(`2026년_건설공사_표준품셈.md`) 머리 「목 차」 글줄에서 계층을 읽음.
|
|
한 장이 한 줄로 이어져 있고 **쪽 번호가 다음 번호에 붙어 있음**(「31-1-1 목적」 = 3쪽 + 1-1-1)
|
|
⇒ 장 번호로 시작하는 자리까지 앞 숫자를 한 자씩 떼며 가름.
|
|
② 부문 다섯이 장 번호를 **각자 1부터** 셈 — 공통 1장 적용기준 ≠ 토목 1장 도로포장.
|
|
코드에 부문을 넣음(`CP-02-01-03` = 토목 1-3) · 뿌리 마디가 부문.
|
|
③ 표를 절에 붙이는 번호는 **본문 절 제목**에서 읽음(목차 번호를 믿지 않음 · 브레인 2026-09-17).
|
|
원천 `section` 칸은 다른 절 인용(「8-2-3 굴착기’를 적용하여」)·쪽 번호(「85-3 말뚝」)가 섞여
|
|
못 씀 → 표가 놓인 원문 줄에서 위로 거슬러 **번호 뒤 글이 목차 이름과 같은** 제목을 찾음.
|
|
장 파일 경계가 쪽 기준이라(원문 머리 경고) 앞뒤 장 제목도 받고, 파일 첫머리 표는 앞 장 파일 꼬리까지 봄.
|
|
⚠ 못 붙인 표는 조용히 버리지 않고 `orphan_tables` 목록.
|
|
⚠ 총칙·묶는 마디 덜어내기 안 함(가름만) — 총칙 뿌리는 `axis_policy.json` 건설 축(데스크탑 서브 잣대) · 미확정 둘은 안 넣음.
|
|
⚠ 갈래 키 안 붙임 — B09 표 읽기가 산림 표로만 검증됨(빈칸 = 못 가름).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import bisect
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
SOURCE = ROOT / "resources" / "data_cost_input_value" / "pum_const_2026.json"
|
|
#: 건설 열쇠 장부 — 산림 장부(`work_item_keys.json`)와 일련번호를 안 섞음
|
|
CONST_KEY_REGISTRY = ROOT / "resources" / "data_work_item_master" / "work_item_keys_const.json"
|
|
KEY_PREFIX = "CW" # Construction Work item
|
|
BOOK_NAME = "2026년_건설공사_표준품셈.md"
|
|
CODE_PREFIX = "CP" # Construction Pumsem — 목차 코드
|
|
DATASET_ID = "work_item_master_const"
|
|
FILE_TAG = "const" # 산출 파일 이름 **앞**에 붙임 — ⚠ 뒤에 붙이면 `work_item_master_*` 로 산림을 고르는 쪽(B08 밑수 대조 · B09 근거표)이 건설을 집음
|
|
DIVISIONS = ("공통부문", "토목부문", "건축부문", "기계설비부문", "유지관리부문")
|
|
|
|
_LEADER = re.compile(r"·{2,}")
|
|
_TOC_PAGE_HEAD = re.compile(r"\d*목차") # 목차 쪽 머리(「19목차」)가 줄 가운데 끼어 있음
|
|
_NUMBER = re.compile(r"\d+(?:-\d+)+")
|
|
#: 이름 바로 뒤에 이 글자가 오면 **인용**이지 제목이 아님 — 「3-2-4 터파기(기계)’를 참고하여」 ·
|
|
#: 「6-2-2 현장가공, 6-2-3 현장조립’을」 · 「1-4-6 작업제한/작업시간제한’」 · 「[주] ④’」.
|
|
#: ⚠ 제목 뒤에 본문이 바로 붙는 꼴(「8-1-4 시공능력 산정 기본식Q=…」)이 많아 허용 목록으로는 못 가름.
|
|
_QUOTED_NEXT = "’‘”“'\"/,[]~」』"
|
|
|
|
|
|
def _squeeze(text: Any) -> str:
|
|
return "".join(str(text or "").split())
|
|
|
|
|
|
def toc_text(lines: list[str]) -> dict[str, str]:
|
|
"""부문 → 그 부문 목차 글(장 줄을 이어 붙임). 목차 뒤 첫 딴 줄에서 멈춤."""
|
|
out: dict[str, str] = {}
|
|
division = None
|
|
started = False
|
|
for line in lines:
|
|
text = line.strip()
|
|
if not started:
|
|
started = _squeeze(text) == "목차"
|
|
continue
|
|
if not text:
|
|
continue
|
|
if text in DIVISIONS:
|
|
division = text
|
|
out[division] = ""
|
|
elif division and re.match(r"제\d+장", text):
|
|
out[division] += text
|
|
elif out:
|
|
break
|
|
return out
|
|
|
|
|
|
def _number_at(text: str, chapter: str, *, after_name: bool) -> int | None:
|
|
"""`text` 안에서 장 번호로 시작하는 절 번호 자리. 앞은 쪽 번호(숫자)뿐이어야 함.
|
|
|
|
`after_name` — 장 머리(「적용기준3」 + 1-1)처럼 앞에 이름 + 쪽 번호가 올 때.
|
|
"""
|
|
for index in range(len(text)):
|
|
head = text[:index]
|
|
if after_name:
|
|
if not re.fullmatch(r".*\D\d+", head):
|
|
continue
|
|
elif head and not head.isdigit():
|
|
break
|
|
if re.match(rf"{chapter}(?:-\d+)+", text[index:]):
|
|
return index
|
|
return None
|
|
|
|
|
|
def toc_entries(text: str) -> list[tuple[str, str, str]]:
|
|
"""부문 목차 글 → `(장, 번호, 이름)` 차례. 장 줄은 번호 자리에 장 번호."""
|
|
out: list[tuple[str, str, str]] = []
|
|
chapter = None
|
|
for segment in _LEADER.split(_TOC_PAGE_HEAD.sub("", text)):
|
|
segment = segment.strip()
|
|
head = re.search(r"제(\d+)장", segment)
|
|
if head:
|
|
chapter = head.group(1)
|
|
rest = segment[head.end() :]
|
|
at = _number_at(rest, chapter, after_name=True)
|
|
if at is None:
|
|
raise ValueError(f"목차 장 머리를 못 가름: {segment[:40]}")
|
|
out.append((chapter, chapter, re.sub(r"\d+$", "", rest[:at]).strip()))
|
|
segment = rest[at:]
|
|
if chapter is None or not segment or segment.isdigit():
|
|
continue
|
|
at = _number_at(segment, chapter, after_name=False)
|
|
if at is None:
|
|
raise ValueError(f"목차 줄을 못 가름: {segment[:40]}")
|
|
number = re.match(rf"{chapter}(?:-\d+)+", segment[at:]).group(0)
|
|
out.append((chapter, number, segment[at + len(number) :].strip()))
|
|
return out
|
|
|
|
|
|
def _code(root: str, number: str) -> str:
|
|
return f"{root}-" + "-".join(part.zfill(2) for part in number.split("-"))
|
|
|
|
|
|
def _node(
|
|
code: str, number: str, name: str, level: int, parent: str | None, order: int, division: str
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"work_item_code": code,
|
|
"number": number,
|
|
"name": name,
|
|
"level": level,
|
|
"parent_code": parent,
|
|
"sort_order": order,
|
|
"tables": [],
|
|
"division": division,
|
|
}
|
|
|
|
|
|
def toc_nodes(lines: list[str]) -> list[dict[str, Any]]:
|
|
"""목차 → 산림과 같은 꼴 마디(부문 뿌리 + 장 + 절 + 항). ⚠ 번호가 겹치거나 윗줄이 없으면 멈춤."""
|
|
nodes: list[dict[str, Any]] = []
|
|
order = 0
|
|
for division, text in toc_text(lines).items():
|
|
root = f"{CODE_PREFIX}-{DIVISIONS.index(division) + 1:02d}"
|
|
order += 256
|
|
nodes.append(_node(root, division, division, 1, None, order, division))
|
|
for _, number, name in toc_entries(text):
|
|
parts = number.split("-")
|
|
parent = _code(root, "-".join(parts[:-1])) if len(parts) > 1 else root
|
|
order += 256
|
|
nodes.append(
|
|
_node(_code(root, number), number, name, len(parts) + 1, parent, order, division)
|
|
)
|
|
codes = [n["work_item_code"] for n in nodes]
|
|
if len(set(codes)) != len(codes):
|
|
raise ValueError("건설 목차 번호가 겹침 — 본문 대조 필요")
|
|
missing = {n["parent_code"] for n in nodes if n["parent_code"]} - set(codes)
|
|
if missing:
|
|
raise ValueError(f"건설 목차 윗줄이 없음: {sorted(missing)[:5]}")
|
|
return nodes
|
|
|
|
|
|
def file_place(source_file: str) -> tuple[str, int]:
|
|
"""`…/02_토목부문/제5장_강구조공사.md` → `("토목부문", 5)`."""
|
|
parts = Path(source_file).parts
|
|
return parts[-2].split("_", 1)[1], int(re.match(r"제(\d+)장", parts[-1]).group(1))
|
|
|
|
|
|
def titles_in(text: str, division: str, chapters: set[str], names: dict) -> list[str]:
|
|
"""한 줄 안의 **본문 절 제목** 번호들(차례대로). 번호 뒤 글이 목차 이름과 같아야 제목."""
|
|
found = []
|
|
for match in _NUMBER.finditer(text):
|
|
raw = match.group(0)
|
|
for cut in range(len(raw.split("-")[0])): # 붙은 쪽 번호를 한 자씩 뗌(「85-3」 → 5-3)
|
|
number = raw[cut:]
|
|
name = names.get((division, number))
|
|
if number.split("-")[0] not in chapters or name is None:
|
|
continue
|
|
tail, key = _squeeze(text[match.end() :]), _squeeze(name)
|
|
after = tail[len(key) : len(key) + 1]
|
|
if tail.startswith(key) and not (after and after in _QUOTED_NEXT):
|
|
found.append(number)
|
|
break
|
|
return found
|
|
|
|
|
|
class SectionFinder:
|
|
"""표 → 본문 절 번호. 파일마다 「제목이 있는 줄 · 그 번호」 를 한 번만 셈."""
|
|
|
|
def __init__(self, nodes: list[dict[str, Any]], source_files: list[str]):
|
|
self.names = {(n["division"], n["number"]): n["name"] for n in nodes if n["level"] > 1}
|
|
self.marks: dict[str, tuple[list[int], list[str]]] = {}
|
|
ordered = sorted(set(source_files), key=file_place)
|
|
self.previous = {
|
|
after: before
|
|
for before, after in zip(ordered, ordered[1:])
|
|
if file_place(before)[0] == file_place(after)[0]
|
|
}
|
|
|
|
def _marks(self, source_file: str) -> tuple[list[int], list[str]]:
|
|
if source_file not in self.marks:
|
|
division, chapter = file_place(source_file)
|
|
chapters = {str(chapter - 1), str(chapter), str(chapter + 1)}
|
|
lines = (ROOT / source_file).read_text(encoding="utf-8").splitlines()
|
|
at, numbers = [], []
|
|
for index, line in enumerate(lines):
|
|
found = titles_in(line, division, chapters, self.names)
|
|
if found:
|
|
at.append(index)
|
|
numbers.append(found[-1])
|
|
self.marks[source_file] = (at, numbers)
|
|
return self.marks[source_file]
|
|
|
|
def number(self, table: dict[str, Any]) -> str | None:
|
|
at, numbers = self._marks(table["source_file"])
|
|
index = bisect.bisect_left(at, int(table.get("line") or 0) - 1) # 표 줄보다 위
|
|
if index:
|
|
return numbers[index - 1]
|
|
previous = self.previous.get(table["source_file"])
|
|
if previous: # 파일 첫머리 표 — 제목은 앞 장 파일 꼬리에 있음
|
|
_, numbers = self._marks(previous)
|
|
return numbers[-1] if numbers else None
|
|
return None
|
|
|
|
|
|
def general_roots() -> tuple[str, ...]:
|
|
"""총칙 뿌리 — `axis_policy.json` 건설 축의 장 파일 이름을 목차 코드로(「01_공통부문/제1장_적용기준.md」 → `CP-01-01`).
|
|
|
|
⚠ 미확정(`general_provision_pending` · 제8장 건설기계 · 유지관리 제1장)은 안 넣음 — 판정이 오면 자료만 고치면 됨.
|
|
"""
|
|
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Keys import general_provision_roots
|
|
|
|
roots = []
|
|
for chapter_file in general_provision_roots("const"):
|
|
division, chapter = file_place(chapter_file)
|
|
roots.append(_code(f"{CODE_PREFIX}-{DIVISIONS.index(division) + 1:02d}", str(chapter)))
|
|
return tuple(roots)
|
|
|
|
|
|
def book_lines(data: dict[str, Any]) -> list[str]:
|
|
folder = (ROOT / str(data["sources"][0]["path"])).parents[1]
|
|
return (folder / BOOK_NAME).read_text(encoding="utf-8").splitlines()
|
|
|
|
|
|
def build_const() -> tuple:
|
|
"""건설 공종 축 한 벌 — 산림 `build()` 와 같은 꼴(마스터 · 미판정 · 밑수 미확보 · (장부, 새 열쇠))."""
|
|
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import finish
|
|
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Parents import apply_parent_modes
|
|
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Table import new_report, norm, table_entry
|
|
|
|
data = json.loads(SOURCE.read_text(encoding="utf-8"))
|
|
tables = data["variables"]["pum"]["tables"]
|
|
nodes = toc_nodes(book_lines(data))
|
|
by_place = {(n["division"], n["number"]): n for n in nodes if n["level"] > 1}
|
|
finder = SectionFinder(nodes, [t["source_file"] for t in tables])
|
|
report = new_report()
|
|
attached = 0
|
|
orphans: list[dict[str, Any]] = []
|
|
lines_of: dict[str, list[str]] = {}
|
|
for table in tables:
|
|
division, _ = file_place(table["source_file"])
|
|
number = finder.number(table)
|
|
node = by_place.get((division, number))
|
|
if table["source_file"] not in lines_of:
|
|
lines_of[table["source_file"]] = (
|
|
(ROOT / table["source_file"]).read_text(encoding="utf-8").splitlines()
|
|
)
|
|
# 형태 판정의 「1장 = 적용기준」 은 공통부문 1장만 — 다른 부문 1장은 도로포장·철골·배관 따위
|
|
chapter = "1" if division == DIVISIONS[0] and number and number.startswith("1-") else None
|
|
section = f"{number} {node['name']}" if node else norm(table.get("section"))
|
|
entry = table_entry(
|
|
table,
|
|
section,
|
|
number,
|
|
chapter,
|
|
lines_of[table["source_file"]],
|
|
node and node["name"],
|
|
report,
|
|
)
|
|
if node is None:
|
|
orphans.append(
|
|
{
|
|
"pum_table_id": table["table_id"],
|
|
"section": norm(table.get("section")),
|
|
"source_file": table["source_file"],
|
|
}
|
|
)
|
|
continue
|
|
node["tables"].append(entry)
|
|
attached += 1
|
|
apply_parent_modes(nodes, {}) # 합산형은 사람이 적은 것만 — 건설은 아직 없음(전부 choose_one)
|
|
for node in nodes:
|
|
node["variant_keys"] = [] # 못 가름 — B09 표 읽기가 산림 표로만 검증됨
|
|
return finish(
|
|
data,
|
|
source=SOURCE,
|
|
dataset_id=DATASET_ID,
|
|
nodes=nodes,
|
|
tables_total=len(tables),
|
|
attached=attached,
|
|
orphans=orphans,
|
|
report=report,
|
|
registry_path=CONST_KEY_REGISTRY,
|
|
toc_corrections=[],
|
|
key_prefix=KEY_PREFIX,
|
|
general_roots=general_roots(),
|
|
policy={
|
|
"divisions": list(DIVISIONS),
|
|
"toc_source": BOOK_NAME + " 머리 「목 차」",
|
|
"section_from_body": True,
|
|
"variant_keys_attached": False,
|
|
"general_provision_roots": list(general_roots()),
|
|
},
|
|
)
|