"""공종 마스터 — **건설 품셈 표 모양 판정**(2026-09-18 브레인 일감 · 랩탑 메인). 왜 — 표 모양 판정(`_Table.detect_form`)이 산림 표에 맞춰져 있어 건설 표를 못 가렸음 (건설 미판정 891 · 산림 0). 건설은 같은 성격이라도 꼴이 다름 — 직종이 「특급·고급·중급·초급 기술자」로 적히고, 기계손료표가 「분류번호·상각 비율·시 간 당(10-7)」이며, 가설재가 「손율(%)·요율」로 적힘. ⚠ **산림 판정을 흔들지 않는다** — 여기 규칙은 `detect_form` 이 **미판정으로 떨어질 때만** 본다. 산림은 미판정이 0 이므로 이 층이 산림 결과를 바꿀 수 없다. ⚠ **못 가리면 미판정으로 남긴다** — 지어내 채우지 않는다(틀린 판정보다 미판정이 낫다). ⚠ **md 모양에 안 기댄다** — 줄 자리·표 차례가 아니라 **머리글·칸 글자**로만 가른다(원문 md 가 계속 고쳐짐). """ from __future__ import annotations import re from typing import Any #: 건설 직종 — 산림 직종(`_Table.OCCUPATION_RE`)에 없는 이름. 측량·설계·기계 쪽이 여기 걸림. CONST_WORKER_RE = re.compile( r"기술자|기능사|기능공|기능인|기\s*사(?!용)|조종원|운전원|비계공|형틀목공|도장공|보통인부|특별인부|" r"작업반장|배관공|전기공|통신공|측량사|제도사" ) #: 값이 계산에 쓰이는 비율·계수 — 품이 아니라 **계수표**. RATE_RE = re.compile( r"손\s*율|손\s*률|요\s*율|부자재율|잔존율|가산율|감가율|환산계수|보정계수|계\s*수" ) #: 기계 손료표 머리(건설 8-3) — 「분류 번호 … 상각 비율 … 시 간 당(10-7)」. LOSS_HEAD = ("분류번호", "상각") #: 셀 안에 적힌 계산식 — `A=0.4×√N(㎡)` 처럼 기호와 `=` 가 든 긴 글. FORMULA_RE = re.compile(r"[A-Za-z가-힣]\s*=\s*[\d.A-Za-z(√]") #: 자원 수량이 붙는 단위 칸(칸 전체가 단위). 「인」은 품, 나머지는 자재·장비. RESOURCE_UNITS = { "인", "인·일", "조", "대", "시간", "hr", "h", "매", "개", "본", "주", "식", "kg", "㎏", "톤", "ton", "㎥", "㎡", "m", "㎝", "ℓ", "L", "회", "개소", } # fmt: skip _NUMBER_RE = re.compile(r"^\d[\d,]*(\.\d+)?$") #: 한 칸에 값이 여럿 뭉친 것도 값으로 셈(`0.099 0.071`) — 뭉쳤다고 「값 없는 표」로 보면 안 됨. #: ⚠ 꼬리 단위는 **품·자재 단위만** — `24hr`·`30톤`·`0.166일` 은 규격·기간 표기라 그 표의 값이 아님 #: (그런 표까지 값이 있다고 보면 기준·규격표가 미판정으로 남는다). _NUMBERS_RE = re.compile(r"^[\d,.\s]*\d[\d,.\s]*(?:인|명|매|본|개|대|조|㎡|㎥|㎜|kg|㎏|ℓ|%)?$") #: 값이 아니라 근거·풀이를 적는 칸 — 여기 든 산출식·수는 그 표의 값이 아님. _ASIDE_HEAD_RE = re.compile(r"비고|적용기준|산출근거|산출식|참고") #: 비목 이름 — 첫 칸이 이것뿐인 표는 「비목 구성표」라 성격을 사람이 가름. COST_ITEMS = {"재료비", "설치비", "노무비", "경비", "제작비", "운송비", "자재비", "주재료비"} #: 공식 기호 한 글자(아래첨자·숫자 꼬리 포함) — `β` · `E` · `t 1` · `q₀`. _SYMBOL_RE = re.compile(r"[A-Za-zα-ωΑ-Ω][₀-₉0-9]?") #: 할증·보정 낱말 — 값이 품이 아니라 **다른 값에 곱하는 율**임을 원문이 이름으로 말한 자리. _RATE_WORD_RE = re.compile(r"할증|할인|가산|감액|경비의|노무비의|재료비의|기계경비의|효\s*율") #: 능력(작업량·시공량·소요시간) 낱말 — 「한 시간·하루에 얼마」라 품 표가 아니라 능력 표다. _CAPACITY_RE = re.compile( r"작업량|시공량|시공능력|작업능력|처리량|산출량|소요시간|작업시간|" r"㎥/hr|㎡/hr|본/hr|톤/hr|ton/hr|hr/ton|min/|/hr|/일\)" ) def _squeeze(text: Any) -> str: return re.sub(r"\s+", "", str(text or "")) def _cells(table: dict[str, Any]) -> list[str]: rows = [table.get("headers") or [], *(table.get("rows") or [])] return [str(c) for row in rows for c in row] def _value_cells(table: dict[str, Any]) -> list[str]: """비고·적용기준 칸을 뺀 칸들 — 그 표가 **값으로 내놓는 것**만.""" headers = [_squeeze(h) for h in table.get("headers") or []] aside = {i for i, h in enumerate(headers) if _ASIDE_HEAD_RE.search(h)} out = [] for row in [table.get("headers") or [], *(table.get("rows") or [])]: out += [str(c) for i, c in enumerate(row) if i not in aside] return out def _has_number(cells: list[str]) -> bool: return any(_NUMBERS_RE.match(_squeeze(c)) for c in cells if _squeeze(c)) def _sub_header_row(table: dict[str, Any]) -> list[str] | None: """머리가 두 단인 표의 **아래 단**. 아니면 `None`. 아래 단은 이름줄이라 **수가 없다**(`구분 | 점질토 | 사질토`). 첫 줄에 값이 있으면 자료 줄이다. """ rows = table.get("rows") or [] if not rows: return None first = [_squeeze(c) for c in rows[0]] if sum(1 for c in first if c) < 2: return None if any(_NUMBERS_RE.match(c) for c in first if c): return None return [c for c in first if c] def judged_const_form(table: dict[str, Any], _twice: bool = False) -> tuple[str, str] | None: """건설 꼴로 본 `(형태, 까닭)`. 못 가리면 `None` — 미판정으로 남김. 차례가 뜻을 가진다 — 좁은 표지부터. 계수·기준표를 먼저 걸러야 직종 낱말이 계수표를 품으로 오인하지 않는다. """ headers = [_squeeze(h) for h in table.get("headers") or []] first_col = [_squeeze(row[0]) for row in table.get("rows") or [] if row] # ① 기계 손료표 — 머리에 분류번호와 상각 비율이 함께 있음(건설 8-3, 122표) if any(LOSS_HEAD[0] in h for h in headers) and any(LOSS_HEAD[1] in h for h in headers): return "coefficient", "머리 '분류번호'+'상각 비율' — 기계 손료 계수표" # ② 율·계수 표 — 손율·요율·부자재율·보정계수가 머리나 첫 칸에 있음(가설재·측량 보정) if found := next((x for x in headers + first_col if RATE_RE.search(x)), None): return "coefficient", f"'{found}' — 비율·계수표" # ③ 값이 한 칸도 없는 표 — 자원이 없으니 품이 아님(설명·서식·이름 목록) # ⚠ 첫 칸이 모두 **비목**인 표(`재료비 | JOINT FILLER`)는 빼둔다 — 참조냐 품이냐가 설계 결정이라 # **사람이 봐야 하는 자리**다(2026-09-17 비목 구성표 · `test_b08_work_item_master` 약속). if not _has_number(_value_cells(table)): if first_col and all(x in COST_ITEMS for x in first_col): return None return "reference", "값(숫자) 칸이 없음 — 설명·서식 표" # ④ 건설 직종이 머리나 줄 이름에 있고 값이 있음 — 인력품 표(측량·설계 「특급 기술자」 …) # ⚠ 계산식보다 **먼저** 본다 — 인력품 표의 비고에 산출식이 적힌 자리가 많다(9-5-4 수치지도). if found := next((x for x in headers + first_col if CONST_WORKER_RE.search(x)), None): return "requirement", f"건설 직종 '{found}'" # ⑤ 단위 칸이 줄에 있고 같은 줄에 값이 있음 — 자원 소요량 표(`비계공 | 인 | 1.40`) for row in table.get("rows") or []: unit = next((c for c in row if _squeeze(c) in RESOURCE_UNITS), None) if unit and any(_NUMBER_RE.match(_squeeze(c)) for c in row): return "requirement", f"줄에 단위 칸 '{_squeeze(unit)}' + 값" if any(_squeeze(h) in RESOURCE_UNITS for h in headers): return "requirement", "머리에 단위 칸 + 값" # ⑥ 값 자리에 계산식이 든 표 — 값이 아니라 식(현장사무소 규모 `A=0.4×√N` · 준설선 환산능력) values = [c for c in _value_cells(table) if len(_squeeze(c)) > 6] if found := next((c for c in values if FORMULA_RE.search(c)), None): return "coefficient", f"값 자리에 계산식 '{_squeeze(found)[:20]}'" # ⑦ 줄 이름이 공식 기호뿐 — `β`·`E`·`t₁` 처럼 한 글자 기호는 품 이름이 아님(시공능력 공식 밑값) named = [x for x in first_col if x] if named and all(_SYMBOL_RE.fullmatch(x) for x in named): return "coefficient", f"줄 이름이 공식 기호뿐 {sorted(set(named))[:4]}" # ⑧ 값이 모두 백분율 — 품·자재 수량이 아니라 요율(층별 할증 `5% 8% 12%`) # ⚠ 「0」은 백분율 줄의 첫 칸으로 자주 온다(지하층 0 · 4층 5% …) — 셈에서 빼고 본다. percents = [_squeeze(c) for c in _value_cells(table) if _squeeze(c)] figures = [x for x in percents if _NUMBERS_RE.match(x) or x.endswith("%")] marks = [x for x in figures if x not in ("0", "0.0")] if marks and all(x.endswith("%") for x in marks) and len(marks) >= 3: return "coefficient", "값이 모두 백분율 — 요율표" # ⑨ 할증·보정 낱말이 줄 이름·머리에 있고 값이 있음 — 율표(수직고별 할증률 · 기계경비의 %) if found := next((x for x in headers + first_col if _RATE_WORD_RE.search(x)), None): return "coefficient", f"'{found}' — 할증·보정 율표" # ⑩ 작업량·시공능력·소요시간 표 — 품이 아니라 **능력**이다(한 시간·하루에 얼마) if found := next((x for x in headers + first_col if _CAPACITY_RE.search(x)), None): return "productivity", f"'{found}' — 작업량·시공능력 표" # ⑪ 기계 기초표 — 가격·손료는 **기계 벌(mach_base_2026.json)이 정본**이라 공종 품이 아님 if any("가격" in h for h in headers) and any("분류번호" in h for h in headers): return "reference", "기계 가격표 — 기계 벌이 정본" if any("시간당" in h for h in headers) and any("분류번호" in h for h in headers): return "reference", "기계 손료표 — 기계 벌이 정본" # ⑫ 머리 칸이 시공능력 공식 기호 — `K`·`E`·`f`·`t(분)` (건설 8-2 기계 시공능력 밑값) if found := next((h for h in headers if _FORMULA_HEAD_RE.fullmatch(h)), None): return "coefficient", f"머리가 공식 기호 '{found}' — 시공능력 밑값" # ⑬ 줄 이름이 시간 — 「시간(분)」·「사이클타임」은 품이 아니라 **걸리는 시간**이다 if found := next((x for x in first_col if _TIME_ROW_RE.search(x)), None): return "productivity", f"줄 이름 '{found}' — 걸리는 시간" # ⑭ 줄 이름이 사용량·소요량 — 자재 소요량 표(`사용량(kg) | 6.5 | 8.0`) if found := next((x for x in first_col if _AMOUNT_ROW_RE.search(x)), None): return "requirement", f"줄 이름 '{found}' — 자재 소요량" # ⑮ 다짐 기준표 — 두께·횟수·규격을 정한 **시방 기준**이지 품이 아님(건설 8-2-9 롤러) if sum(1 for h in headers if _COMPACT_RE.search(h)) >= 2: return "reference", "다짐 두께·횟수 기준표 — 시방 기준" # ⑯ 조건별 계수 — 「현장조건·토질명」으로 갈리고 값이 모두 1 이하면 **효율·계수**다(8-2-1 도자 E) if any(_CONDITION_RE.search(x) for x in headers + first_col): nums = [ float(_squeeze(c).replace(",", "")) for c in _value_cells(table) if _NUMBER_RE.match(_squeeze(c)) ] if nums and all(0 < n <= 1 for n in nums): return "coefficient", "현장조건별 값이 모두 1 이하 — 작업효율·계수표" # ⑰ 품 단위가 칸에 적힌 표 — `인/ton`·`인/개소` 는 **그 단위당 사람 몫**이라 소요량 표다 if found := next((c for c in _cells(table) if _PERSON_PER_RE.search(_squeeze(c))), None): return "requirement", f"'{_squeeze(found)[:12]}' — 단위당 사람 몫" # ⑱ 머리에 이름 칸과 단위 칸이 함께 — 자재 소요량 표(`품명 | 규격 | 단위 | 수량`) if any(h in ("품명", "명칭", "자재명") for h in headers) and any("단위" in h for h in headers): return "requirement", "머리에 '품명'+'단위' — 자재 소요량 표" # ⑲ 머리에 「사용시간」 — 장비를 몇 시간 쓰는지 적은 **소요량** 표(13-9-1 수중펌프 설치) if any("사용시간" in h for h in headers): return "requirement", "머리에 '사용시간' — 장비 소요시간" # ⑳ 머리가 두 단인 표 — **위 단을 버리지 않고 아래 단을 머리에 더해** 한 번 더 본다. # ⚠ 평탄화가 아니다(위 단에 밑수·갈래가 든다) · 한 번만 되본다(두 단이 셋이 되진 않는다). if not _twice and (sub := _sub_header_row(table)): merged = { **table, "headers": [*(table.get("headers") or []), *sub], "rows": (table.get("rows") or [])[1:], } if hit := judged_const_form(merged, True): return hit[0], f"머리 두 단을 이어 보니 — {hit[1]}" return None #: 머리 한 칸이 공식 기호뿐 — 건설 8-2 시공능력 식의 밑값(`K`·`E`·`f`·`t(분)`·`C(분)`). _FORMULA_HEAD_RE = re.compile(r"[A-Za-zα-ωΑ-Ω][₀-₉0-9]?(?:\([^)]{1,4}\))?") #: 줄 이름이 「걸리는 시간」 — 품(사람·날)이 아니라 능력 쪽 값. _TIME_ROW_RE = re.compile(r"시간\(|\(분\)|사이클|주기\(") #: 줄 이름이 자재 쓰는 양. _AMOUNT_ROW_RE = re.compile(r"사용량|소요량|사용수량") #: 조건별 계수표 머리 — 「현장조건」·「작업조건」·「토질명」으로 갈리고 값이 1 이하인 표(작업효율 E). _CONDITION_RE = re.compile(r"현장조건|작업조건|토질명|토질별작업|시공조건") #: 「인/ton」·「인/개소」 꼴 — 그 단위당 사람 몫이라 품 표다. _PERSON_PER_RE = re.compile(r"인/(?:ton|톤|개소|개|매|본|㎡|㎥|m|km|대|조|식)") #: 다짐 기준표 머리 — 두께·횟수·규격을 정한 시방 기준. _COMPACT_RE = re.compile(r"다짐두께|다짐횟수|다짐기계|다짐") #: 머리에 구간이 적힌 표 — 「0∼10m」·「10m미만」처럼 **조건으로 갈리는** 표. _RANGE_RE = re.compile(r"[∼~~]|미만|이상|이하|초과") #: 규격·치수 단위 — 값이 이것뿐이면 품이 아니라 **규격 배정·기준**이다. _SPEC_UNIT_RE = re.compile(r"^[\d,.]+\s*(?:㎜|mm|㎝|m|㎡|㎥|inch|인치|kg|㎏|톤|ton|개|주|본)$") def const_undetermined_kind(table: dict[str, Any]) -> str: """못 가른 까닭을 **갈래로** 적는다(2026-09-18 브레인 — 남은 것을 눈으로 가르기 위해). ⚠ 갈래는 「어디를 더 봐야 하나」를 가리킬 뿐, 형태를 짐작해 채우지 않는다. """ headers = [_squeeze(h) for h in table.get("headers") or []] rows = table.get("rows") or [] first_row = [_squeeze(c) for c in (rows[0] if rows else [])] values = [_squeeze(c) for c in _value_cells(table) if _squeeze(c)] if not _has_number(values): return "원문에 값이 없음 — 설명·서식·이름 목록(할 일 아님)" figures = [c for c in values if _NUMBERS_RE.match(c) or _SPEC_UNIT_RE.match(c)] if figures and all(_SPEC_UNIT_RE.match(c) for c in figures): return "값이 규격·치수뿐 — 원문이 품을 안 적음(할 일 아님)" if first_row and not any(_NUMBER_RE.match(c) for c in first_row) and sum(1 for c in first_row if c) >= 2: # fmt: skip return "머리 두 단 · 값 있음 — 아래 단을 이어 봐도 표지가 없음" if any(_RANGE_RE.search(h) for h in headers): return "머리가 구간 · 값 있음 — 조건으로 갈리는 표" return "값은 있는데 표지가 없음 — 사람이 봐야 함"