브레인 일감(2026-09-18 둘째 바퀴). 잣대는 원문 ↔ 마스터 어긋남 건수.
- **머리 두 단을 이어 본다**(`judged_const_form` ⑯) — ⚠ 평탄화가 아니라 **위 단을 그대로 두고
아래 단을 머리에 더해** 한 번만 다시 봄. 21 표가 갈림(직종 18 · 공식 기호 3)
- 못 가린 175 는 갈래를 다시 나눔 — 머리 두 단·값 있음 81 · 값은 있는데 표지 없음 82 ·
머리가 구간 6 · **원문이 품을 안 적음 6(할 일 아님)**
- 밑수 미확보 사유를 셋으로 가름(`_basis_missing_why`)
· **원문 부재**(채울 것 아님) 364 · **거절 자리** 72 · **미판독**(진짜 할 일) 2
· ⚠ 거절 자리 = 원문이 적은 것이 값의 단위인 자리 — 「회당」(2026-09-17 판정) ·
「(인/일)」·「(㎥/hr)」·「(ℓ/hr)」는 「하루에 몇 인」이라는 뜻이지 표의 밑수가 아님 ·
밑수가 둘인 자리(「1ha당, 100본당」)도 여기
· 산림도 같은 눈으로 — 미판독 10 → 1(F0237 「1대 1조, 시간당」만 사람 판정)
- 밑수 단위에 JOINT·호 더함 · 작업조 표는 「밑수가 표 밖(시공량)」으로 따로 적음
- 셈 — 건설 미판정 196 → 175 · 건설 밑수 확보 711 → 713 · 산림 미판정 0 · 산림 밑수 미확보 79 그대로
(속가름: 원문 부재 70 → 78 · 미판독 8 → 1)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
230 lines
14 KiB
Python
230 lines
14 KiB
Python
"""공종 마스터 — **건설 품셈 표 모양 판정**(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", "다짐 두께·횟수 기준표 — 시방 기준"
|
||
|
||
# ⑯ 머리가 두 단인 표 — **위 단을 버리지 않고 아래 단을 머리에 더해** 한 번 더 본다.
|
||
# ⚠ 평탄화가 아니다(위 단에 밑수·갈래가 든다) · 한 번만 되본다(두 단이 셋이 되진 않는다).
|
||
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"사용량|소요량|사용수량")
|
||
#: 다짐 기준표 머리 — 두께·횟수·규격을 정한 시방 기준.
|
||
_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 "값은 있는데 표지가 없음 — 사람이 봐야 함"
|