Files
Aislo/B08_Quantity/B08_Quantity_Build_WorkItemMaster_FormsConst.py
T
eomsangdonandClaude Opus 5 7786316ef5 feat(b08): 조건별 계수표 잣대 — 건설 형태 미판정 175 → 168
- 「현장조건·토질명」으로 갈리고 값이 모두 1 이하면 **작업효율·계수표**로 봄(8-2-1 도자 E 꼴)
- ⚠ 산림 미판정 0 그대로 · 밑수 셈 변화 없음(확보 713 · 미확보 438)
- 전체 시험 2,442 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-18 14:09:48 +09:00

242 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""공종 마스터 — **건설 품셈 표 모양 판정**(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 이하 — 작업효율·계수표"
# ⑰ 머리가 두 단인 표 — **위 단을 버리지 않고 아래 단을 머리에 더해** 한 번 더 본다.
# ⚠ 평탄화가 아니다(위 단에 밑수·갈래가 든다) · 한 번만 되본다(두 단이 셋이 되진 않는다).
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"현장조건|작업조건|토질명|토질별작업|시공조건")
#: 다짐 기준표 머리 — 두께·횟수·규격을 정한 시방 기준.
_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 "값은 있는데 표지가 없음 — 사람이 봐야 함"