"""공종 마스터 — **표 한 장 읽기**: 형태·밑수·위험 깃발 (2026-09-17 뽑는 스크립트가 700줄을 넘어 떼어냄). 뽑는 스크립트(`B08_Quantity_Build_WorkItemMaster.py`)는 목차·절 붙이기·열쇠를 맡고, 표 한 장이 어떤 표인지(`pum_form`)·밑수·깃발은 **여기 한 곳**에서 가름 — 산림·건설 뽑기가 같은 판정을 씀(두 벌 금지). 옮기기만 했고 판정은 한 글자도 안 바꿈 — 옛 이름은 뽑는 스크립트가 그대로 다시 내보냄. """ from __future__ import annotations import re from typing import Any from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Forms import judged_basis, judged_form # ── 형태 판정 ──────────────────────────────────────────────────────────── # 헤더·비고 문자열에서 찾는 표지. 앞의 것이 먼저 걸린다(생산량형이 더 좁은 표현이라 우선). PRODUCTIVITY_MARKS = ( "㎥/hr", "m3/hr", "㎡/hr", "본/hr", "/1인/1일", "/인/1일", "인/1일", "작업능력", "ha당 평균 작업량", "ha당 집재재적", ) REQUIREMENT_MARKS = ( "소요인력", "소요량", "당 주입량", "단위수량", "수 량", "수량", "인/km", "인/ha", "㏊당", "ha당", "당 소요", ) # 값이 공종 품이 아니라 계산식 파라미터인 표. 공종으로 세우지 않는다. COEFFICIENT_MARKS = ("손료계수", "시간당손료계수", "기계손료") # 기계 시공능력 공식(Q = 3600·qo·K·f·E / Cm)의 파라미터 기호. 이 기호만 있는 표는 계수표다. COEFFICIENT_ROW_KEYS = {"K", "f", "E", "Cm", "㎝(sec)", "q", "qo", "Q", "V", "L"} # 품셈 1장(적용기준)·할증·공제·단위 표준 — 공종이 아니라 **기준표**다. # ⚠ **「단 위」를 뺐다** (2026-09-07). 원래 겨냥은 품셈 1-2-2 「단위 표준」 표였는데, # **1장은 chapter 규칙이 이미 참조로 거르므로** 이 표지는 넓기만 했다. 그 탓에 # **41건이 「헤더 '단 위'」 하나로 참조가 되어 버려지고 있었고**, 그 안에 # **돌쌓기(장비) 13-4-5·13-4-2**(우리 매핑이 실제로 쓰는 코드) · 12-2 표면 마무리 · # 9-19-1 면고르기 · 9-20 뿌리다듬기 · 13-8 막돌쌓기 같은 **명백한 공종**이 섞여 있었다. # 「단위 열이 있다」는 소요량표의 흔한 모양이지 참조표의 표지가 아니다. REFERENCE_MARKS = ("할증률", "할인", "공제율", "적재량", "지위") # 값 대신 「어디를 따르라」고만 적은 표. 품이 아니므로 공종으로 세우지 않는다. REFERENCE_CELL_MARKS = ("별도계상", "구역화물", "적용한다", "따른다", "준용") # 품셈이 실어 둔 **빈 단가산출서 서식** — 값이 없는 예시 양식이다(2026-09-08 ㉙, 서브가 찾음). # 첫 줄이 「위치 및 임·소반」·「위치 및 면적」이고 다음 줄이 「구분 | 작업량 | 단위품 | # 소요품 | 단가」인 모양. 채워 넣으라고 둔 칸이라 **자원도 값도 없다.** # ⚠ 이것을 공종으로 세우면 「값이 있는데 안 서는 자리」로 오해된다 — 애초에 값이 없다. # 서브가 그 표들 탓에 못 맞춘 자원이 882 → 497 로 부풀어 있었다. # ⚠ **두 조건을 다 만족할 때만** 본다 — 「구분」·「단가」는 정상 표에도 흔한 낱말이라 # 하나만 보면 정상 표를 지운다(오늘 그 병을 열한 번 겪었다). #: 표지는 **머리글에 그대로 적혀 있다** — 「ha당 참나무시들음병방제 **단가산출서(예시)**」. #: 낱말 하나로 충분히 좁다(정상 표 머리글에 「단가산출서」가 올 일이 없다). BLANK_FORM_MARK = "단가산출서" #: 머리글이 비어 있는 판을 대비한 보조 표지 — **두 조건을 다 만족할 때만** 본다. BLANK_FORM_HEAD_MARKS = ("위치및임소반", "위치및면적") BLANK_FORM_BODY_MARKS = ("단위품", "소요품") # 직종이 값의 주인인 표 = 소요량형. `보통인부(인)`·`콘크리트공(인)` 처럼 `(인)` 이 붙는다. # ⚠ 원문에 `인 부` 처럼 낱말 사이 공백이 있어, 비교 전에 공백을 모두 지운다(`squeeze`). OCCUPATION_RE = re.compile( r"\((?:인|조|인/일|인·일)\)|인부|기능공|운전사|특별인부|보통인부|콘크리트공|철근공|石工|석공|목공|용접공" ) # 재료 소요량표의 값 단위. 직종이 없어도 이 단위가 값 열에 오면 소요량형이다. MATERIAL_UNIT_MARKS = ("(kg)", "(㎏)", "(개)", "(매)", "(본)", "(ℓ)", "(L)", "(㎥)", "(㎡)", "(m)") # ⚠ **첫 칸이 분류 딱지이고 이름이 둘째 칸인 표** (2026-09-07 서브 창이 자기 파싱에서 잡은 모양). # `['자재', 'PVC 지수판(200×5)', 'm', '1.04']` 처럼 첫 칸이 값의 이름이 아니라 갈래다. # 그런 표는 직종이 넷째·다섯째 행에 있어 **첫 세 행만 보는 판정에 안 걸렸고**, 12장 임도 # 구조물 표 9건이 통째로 `undetermined` 로 빠져 있었다. 딱지를 알아보고 다시 본다. CLASSIFICATION_TAGS = frozenset( { "자재", "재료", "재료비", "자재비", "잡재료", "장비", "기계", "인력", "노무", "노무비", "인건비", "설치비", "경비", "공구손료", } ) # 딱지 가운데 **품이 붙는 쪽**. 이 딱지가 있으면 그 표는 소요량형이다. RESOURCE_TAGS = frozenset({"자재", "재료", "잡재료", "장비", "기계", "인력", "노무", "공구손료"}) # 값이 아니라 **다른 값의 몇 %** 라고만 적은 표. 품이 아니므로 참조로 둔다. RATIO_DIRECTIVE_MARKS = ("재료비의", "주재료비의", "회기준") def norm(text: Any) -> str: """공백을 하나로 줄인 문자열. `None` 은 빈 문자열.""" return " ".join(str(text).split()) if text is not None else "" def detect_form(table: dict[str, Any], chapter: str | None) -> tuple[str, str]: """`(pum_form, 근거 문구)`. 판정 못 하면 `("undetermined", 이유)`. 순서가 뜻을 가진다 — 좁은 표지부터 본다. 계수·기준표를 먼저 걸러 내야 「작업능력」 같은 흔한 낱말이 기준표를 공종으로 오인하지 않는다. """ hay = " ".join(norm(h) for h in table.get("headers", [])) keys = [norm(r[0]) for r in table.get("rows", []) if r] head_rows = [norm(c) for r in table.get("rows", [])[:3] for c in r] first_rows = " ".join(head_rows) both = f"{hay} || {first_rows}" squeezed = both.replace(" ", "") # `인 부` → `인부`. 원문 자간 공백을 지운 뒤 비교한다. for mark in COEFFICIENT_MARKS: if mark in hay: return "coefficient", f"헤더 '{mark}'" if keys and set(keys) <= COEFFICIENT_ROW_KEYS: return "coefficient", f"행 키가 시공능력 공식 기호뿐 {sorted(set(keys))}" if keys and all(re.fullmatch(r"[a-zA-Zqf][₀-₉0-9]?", k) for k in keys): return "coefficient", f"행 키가 기호뿐 {sorted(set(keys))}" # 1장은 적용기준 장 자체다 — 공종이 아니라 기준표로 둔다(PLAN 8-14 「목록은 규정에서」). if chapter == "1": return "reference", "품셈 제1장(적용기준)" for mark in REFERENCE_MARKS: if mark in hay: return "reference", f"헤더 '{mark}'" for mark in REFERENCE_CELL_MARKS: if mark in squeezed: return "reference", f"'{mark}' — 값이 아니라 참조 지시" # 빈 단가산출서 서식 — 머리글 표지가 정본, 머리 두 줄은 보조. if BLANK_FORM_MARK in hay.replace(" ", ""): return "reference", f"'{BLANK_FORM_MARK}' — 채워 넣으라고 둔 빈 서식" head2 = "".join( norm(str(c)) for row in (table.get("rows") or table.get("raw_row") or [])[:2] for c in row ) for mark in ("․", "·", "ㆍ", " "): head2 = head2.replace(mark, "") if any(m in head2 for m in BLANK_FORM_HEAD_MARKS) and any( m in head2 for m in BLANK_FORM_BODY_MARKS ): return "reference", "품셈이 실어 둔 빈 단가산출서 서식 — 채워 넣으라고 둔 칸" # ⚠ 생산량형을 **직종보다 먼저** 본다. 「작업능력(㎥/hr)」 표의 비고란에 흔히 # 「보통인부 1인/일」이 붙어 있어, 직종을 먼저 보면 생산량형이 소요량형으로 뒤집힌다. # 그 뒤집힘이 곧 PLAN 8-6 이 경고한 「값이 조용히 반대로 들어가는」 사고다. for mark in PRODUCTIVITY_MARKS: if mark in hay: return "productivity", f"헤더 '{mark}'" # 직종이 값의 주인이면 소요량형이다 — 「보통인부(인) 0.16」 은 ㎥당 품이다. if OCCUPATION_RE.search(squeezed): return "requirement", "직종 표기((인)·인부·공)" for mark in MATERIAL_UNIT_MARKS: if mark in hay: return "requirement", f"값 단위 '{mark}'" for mark in PRODUCTIVITY_MARKS: if mark in both: return "productivity", f"본문 '{mark}'" for mark in REQUIREMENT_MARKS: if mark in both: return "requirement", f"'{mark}'" # ⚠ 첫 칸이 분류 딱지인 표 — **맨 마지막에 본다.** 앞의 판정을 흔들지 않으려는 자리다. tags = [norm(row[0]).replace(" ", "") for row in table.get("rows", []) if row] if any(tag in CLASSIFICATION_TAGS for tag in tags): # ⚠ **소요량을 먼저 본다.** 비율 지시를 먼저 보면 `잡재료비(재료비의) 5 %` 한 줄 때문에 # 강관동바리(내관 0.38본·형틀목공 0.07인) 같은 **멀쩡한 소요량표가 참조로 넘어간다** # — 만들다 실제로 걸린 자리다. # 값이 실제로 있는 표만 공종으로 세운다. 이름만 있고 수치가 없으면 판정하지 않는다. has_number = any( re.fullmatch(r"[\d,]+(?:\.\d+)?", norm(c)) for r in table.get("rows", []) for c in r ) if has_number and any(tag in RESOURCE_TAGS for tag in tags): return "requirement", f"분류 딱지 {sorted({t for t in tags if t in RESOURCE_TAGS})}" body = " ".join(norm(c) for r in table.get("rows", []) for c in r).replace(" ", "") for mark in RATIO_DIRECTIVE_MARKS: if mark in body: return "reference", f"분류 딱지 표의 '{mark}' — 값이 아니라 비율 지시" return "undetermined", "헤더·첫 행에 단위·밑수·직종 표지 없음" BASIS_RE = re.compile(r"(\d[\d,.]*)\s*(㎥|m3|㎡|m2|㏊|ha|km|㎞|m|인|본|개|kg|㎏|톤|ton)\s*당") # ⚠ **밑수는 표 안이 아니라 표 바로 위 본문에 있다** (2026-09-07 서브 창 제보로 파고 확인). # `### 12-2. 표면 마무리` 다음 줄에 `(단위: ㎡당)` 이 오는 식이다. 표만 보면 못 찾고, # 못 찾은 채로 두면 「10㎡당」 표를 1㎡당으로 알아 **곱셈이 10배 틀린다**. # 앞서 확인한 「밑수가 **밀렸나**」와는 다른 물음이다 — 이번은 「**아예 안 적혔나**」다. # ⚠ **「당」 또는 「단위:」 가 있어야 밑수다.** 둘 다 없으면 규격일 뿐이다 — # 만들다 실제로 걸렸다: `(무한궤도,0.7㎥)` 를 「0.7㎥당」으로 읽어 5건이 잘못 잡혔다. #: ⚠ `(단위: 인/㎡당)` 꼴 — **분모가 밑수**다. 값의 단위(인)를 밑수로 읽으면 뜻이 뒤집힌다. #: ⚠ 「단위:」 없이 `(인/100m당)` 으로만 적은 표가 22 곳이다(조림 5장·단끊기 5-16-1 등, 2026-09-13). SOURCE_BASIS_RATIO_RE = re.compile( r"[((]\s*(?:단위\s*[::]\s*)?[^))/::]+/\s*([\d,]*\.?\d*)\s*" r"(㎥|m3|㎡|m2|㏊|ha|㎞|km|m|매|본|개소|개|인|공|kg|㎏|톤|ton|주|식|대)\s*당\s*[))]" ) SOURCE_BASIS_RE = re.compile( r"[((]\s*(?:" r"단위\s*[::]\s*([\d,]*\.?\d*)\s*(?P㎥|m3|㎡|m2|㏊|ha|㎞|km|m|매|본|개소|개|인|공|kg|㎏|톤|ton|주|식|대)\s*당?" r"|([\d,]*\.?\d*)\s*(?P㎥|m3|㎡|m2|㏊|ha|㎞|km|m|매|본|개소|개|인|공|kg|㎏|톤|ton|주|식|대)\s*당" r")\s*[))]" ) #: 표 위로 몇 줄까지 거슬러 볼 것인가. 더 올라가면 **앞 표의 밑수**를 잘못 물어 온다. SOURCE_LOOKBACK = 6 #: 표 **아래 [주]** 에만 밑수가 적힌 자리 — 「목재의 손율은 **1개소 사용당** 50%로 한다」. #: ⚠ **아주 좁게 잡는다.** 「N개소 **사용** 당」이라는 표기는 품셈 전문에 **딱 두 곳**뿐이고 #: (11-2 비탈 규준틀 · 11-3 수평 규준틀) 둘 다 개소로 세는 표다. 「개소당 평균면적」 같은 #: 흔한 말은 「사용」이 없어 안 걸린다 — 넓히면 조림 2장이 통째로 걸린다(실측 19건). #: ⚠ 뜻으로도 개소가 맞다 — [주]② 가 「본 품은 …한 **비탈규준틀**의 제작·도색·가설·철거를 #: 포함한 것」이라 **규준틀 하나**를 말하고, [주]① 이 「20m마다 **설치**」라 센다. SOURCE_BASIS_NOTE_RE = re.compile(r"([\d,]*\.?\d*)\s*개소\s*사용\s*당") #: 표 아래로 몇 줄까지 볼 것인가. [주] 는 표 바로 밑에 붙는다. SOURCE_NOTE_LOOKAHEAD = 8 #: ⚠⚠ **「인」은 품의 단위이지 공종 밑수가 아니다**(2026-09-09 원문 대조로 넷이 오독으로 드러남). #: 8-6-2 드론방제·8-6-3 지상방제 「(단위 : 인)」 — 그 「인」은 **소요인력**이고 공종 밑수는 #: ha 다. 13-2-4 야면석 채집 「(단위: 인 당)」 — 표 안이 **㎡당·㎥당** 두 줄이다. #: ⇒ **원문에 「<숫자>인당」이 그대로 있을 때만** 밑수로 인정한다. #: ⚠ **넓게 「인」을 통째로 버리면 2-2-5 천공기가 사라진다** — 그 표는 셀 안에 「천공인부 #: **1인당** 1대」로 숫자가 붙어 있다. 그래서 「숫자가 붙었나」로 좁게 가른다. PERSON_UNITS = {"인", "공"} def person_basis_ok(raw_quantity: str | None, unit: str | None) -> bool: """「인」 계열 밑수를 인정할 것인가 — **숫자가 붙어 있을 때만** 참.""" if unit not in PERSON_UNITS: return True return bool((raw_quantity or "").strip()) def basis_from_source(lines: list[str], line_no: int) -> tuple[float | None, str | None]: """표 바로 위 본문에서 밑수를 읽는다. 못 찾으면 `(None, None)` — 1 로 단정하지 않는다. ⚠ 위로 거슬러 보되 **다른 표를 만나면 멈춘다**. 앞 표의 밑수를 물어 오면 조용히 틀린다. """ start = max(0, line_no - 1 - SOURCE_LOOKBACK) for index in range(line_no - 2, start - 1, -1): if index < 0 or index >= len(lines): continue text = lines[index].strip() if text.startswith("|"): break # 앞 표에 닿았다 — 그 위는 남의 밑수다 if m := SOURCE_BASIS_RATIO_RE.search(text): raw = (m.group(1) or "").replace(",", "") if not person_basis_ok(raw, m.group(2)): continue # 「인」에 숫자가 안 붙었다 — 품의 단위이지 밑수가 아니다 try: quantity = float(raw) if raw else 1.0 except ValueError: quantity = 1.0 return quantity, m.group(2) if m := SOURCE_BASIS_RE.search(text): unit = m.group("u1") or m.group("u2") raw = (m.group(1) if m.group("u1") else m.group(3)) or "" raw = raw.replace(",", "") if not person_basis_ok(raw, unit): continue # 위와 같음 — 「(단위 : 인)」·「(단위: 인 당)」은 밑수가 아니다 try: quantity = float(raw) if raw else 1.0 except ValueError: quantity = 1.0 return quantity, unit # 표 **아래 [주]** 도 본다 — 위에 아무것도 없을 때만. 규준틀 둘이 그 자리다. # ⚠ 다음 표(`|`)나 다음 절(`###`)을 만나면 멈춘다 — 남의 [주]를 물어 오면 조용히 틀린다. for index in range(line_no, min(len(lines), line_no + SOURCE_NOTE_LOOKAHEAD)): text = lines[index].strip() if text.startswith("###"): break if m := SOURCE_BASIS_NOTE_RE.search(text): raw = (m.group(1) or "").replace(",", "") try: quantity = float(raw) if raw else 1.0 except ValueError: quantity = 1.0 return quantity, "개소" return None, None #: 표 아래 `[주]` 덩어리를 어디까지 볼 것인가. #: ⚠ **줄 수로 끊지 않는다** — 긴 표(경급 20줄 · 기계 40줄)는 [주]가 저 아래에 붙어서 #: 좁게 잡으면 **긴 표의 [주]가 통째로 안 읽힌다**(2026-09-18 랩탑 메인이 짚은 세 꼴 중 셋째). #: 끊는 것은 **다음 절(`#`)** 과 **새 표(`|`)** 라는 구조뿐이고, 이 수는 안전망일 뿐이다. NOTES_LOOKAHEAD = 400 _NOTE_START = ("[주]", "[ 주 ]", "[주") #: 이어짐 글머리 — `*` 도 담는다(「* (전목) 나무베기, …」 처럼 [주] 를 풀어 적은 줄). _NOTE_CONT = ("-", "·", "※", "*") #: 가나다…하 열넷만. ⚠ 넓히면 「등)」·「(인)」 같은 글이 제목으로 잡힌다. _ORDER = "가나다라마바사아자차카타파하" #: ⚠⚠ **원문이 절 제목을 목록 줄로 적는다** — 「- 3. 노상 및 노반재료」·「- 라. 체적환산계수(f)표」. #: [주] 이어짐 「- ②」와 같은 「-」로 시작해서, 멈춤이 「#」과 표뿐이면 제목도 남의 [주]도 끌어온다. #: ⚠⚠ 그런데 **같은 꼴로 진짜 규칙이 이어지는 자리가 있다** — 9-8 구조물 헐기가 #: 「- 2. 철근절단 : …」·「- 4. 고철공제 : A=1㎥×0.008×…」처럼 **단계마다 번호를 달고** 적는다. #: 꼴만 보고 자르면 **고철 공제 50kg/㎥ 가 통째로 사라진다**(2026-09-18 2바퀴 검사 34건). #: ⇒ **알맹이 없는 제목일 때만** 자른다 — 콜론도 숫자도 없고 짧은 줄. _LIST_HEAD_RE = re.compile( rf"^-\s*(?:\d+\.|[{_ORDER}]\.|\(\d+\)|\([{_ORDER}]\)|[{_ORDER}]\))\s*(?P.*)$" ) #: 제목으로 볼 최대 길이. 규칙 글은 이보다 길다. _TITLE_MAX = 30 def is_bare_title(text: str) -> bool: """목록 줄이 **알맹이 없는 절 제목**인가 — 콜론·등호·숫자가 없고 짧으면 제목이다. ⚠ 잣대는 「지우는 것보다 남기는 것이 안전」이다(2026-09-18 브레인). 조금이라도 규칙 같으면 남긴다. """ found = _LIST_HEAD_RE.match(text) if found is None: return False rest = found.group("rest").strip() if not rest: return True if any(ch in rest for ch in "::=%"): return False # 콜론·등호·백분율이 있으면 규칙이다 — 「- 4. 고철공제 : A=1㎥×…」 # ⚠ 「숫자가 있으면 제목이 아니다」로는 못 가른다 — 「- 3. 뿌리제거(100본당)」이 제목이다. # 남는 잣대는 **길이**뿐이다. 규칙 글은 이보다 길다(2026-09-18 2·3바퀴에서 두 번 겪음). return len(rest) <= _TITLE_MAX #: ⚠ **쪽이 넘어가며 쪽번호와 장 제목이 [주] 줄 끝에 눌어붙은 자리** — 「…따른다.11제1장 적용기준」. #: 「숫자 + 제N장」이 띄어쓰기 없이 붙는 꼴은 본문에 없으므로 이 자리를 잘라 낸다(건설 131줄). PAGE_BREAK_RE = re.compile(r"\d{1,4}제\d+장.*$") def trim_page_break(text: str) -> str: """줄 끝에 눌어붙은 쪽번호·장 제목을 떼어 낸다. 없으면 그대로.""" return PAGE_BREAK_RE.sub("", text).rstrip() def notes_from_source(lines: list[str], line_no: int) -> list[str]: """표 바로 아래 `[주]` 글줄 목록. 없으면 빈 목록. 왜 싣나 — `[주]` 에 **금액에 바로 닿는 조건**이 적혀 있다(발파 가산 20% · 고철 공제 50kg/㎥ · 공구손료 3% · 위험목 인력 100% 가산 …). **읽기만 싣는다 — 여기서 셈하지 않는다.** 멈추는 자리 둘뿐 — **다음 절(`#`)** 과 **새 표(`|`)**. ⚠ 그 사이에 끼는 것은 **끊지 않고 건너뛴다** — 「> 【산출 예시】」 인용과 「* (전목) …」 줄이 [주] 중간에 들어오는데, 거기서 끊으면 **뒤에 오는 ⑤⑥ 가산·할증이 사라진다** (2026-09-18: 벌채 30%·조재 20%, 위험목 100%, 집재 주행 10% 가 그렇게 사라졌음). ⚠ **[주] 를 만나기 전에는** 알맹이 없는 제목에서 멈춘다 — 제 [주] 가 없는 표가 제목을 건너 남의 [주] 를 빌려오던 것을 막는다(F0009 가 강재류 [주]를 달고 있었음). ⚠ 한 표에 `[주]` 덩어리가 **여럿**일 수 있다(9-8 구조물 헐기는 단계마다 [주]를 단다) — 안 끊는다. """ out: list[str] = [] started = False body_done = False for index in range(line_no, min(len(lines), line_no + NOTES_LOOKAHEAD)): text = lines[index].strip() if text.startswith("#"): break if text.startswith("|"): if started or body_done: break # 새 표가 왔다 — 이 표의 [주] 는 여기까지 continue # 아직 이 표 본문 안이다 if not text: continue if not started and is_bare_title(text): break # 제목을 건너 남의 [주] 를 빌려오지 않는다 if text.startswith(_NOTE_START): started = True out.append(trim_page_break(text)) continue body_done = True # 표 본문은 끝났다(글줄이 나왔다) if not started: continue if is_bare_title(text): break # 알맹이 없는 제목 — 여기서부터는 남의 글이다 if text.startswith(_NOTE_CONT): out.append(trim_page_break(text)) # 그 밖(「> 【산출 예시】」·「* (전목) …」)은 **끊지 않고 건너뛴다** return out #: ⚠ **밑수가 절 이름에만 있는 표** — 「4-2-2. 1,000㎡당」·「4-2-1. 100본당」(2026-09-13 판정). #: 본문·표 안 어디에도 「(단위: …)」가 없어 밑수가 비어 있었다(B09 가 막아 금액은 안 섰음). #: ⚠ **이름 전체가 「수 + 단위 + 당」일 때만** 뽑는다 — 「나무주사(100본당)」처럼 섞인 것은 #: 본문이 이미 주고, 「자재의 1회당 표준 운반량」 같은 설명문은 밑수가 아니다. 짐작하지 않는다. NAME_BASIS_RE = re.compile( r"^([\d,]+(?:\.\d+)?)\s*(㎥|㎡|㏊|ha|㎞|km|m|매|본|개소|개|주|kg|㎏|톤|ton)\s*당$" ) def basis_from_name(name: str) -> tuple[float | None, str | None]: """절 이름이 곧 밑수인 경우만 `(수, 단위)`. 아니면 `(None, None)`.""" found = NAME_BASIS_RE.match(norm(name)) if found is None: return None, None try: return float(found.group(1).replace(",", "")), found.group(2) except ValueError: return None, None #: 밑수가 될 수 있는 **세는 단위**(분모 자리). _DENOM = "㎥|m3|㎡|m2|㏊|ha|㎞|km|m|매|본|개소|개|인|공|kg|㎏|톤|ton|주|식|대|일|시간|hr" #: 값이 붙는 **재는 단위**(분자 자리). 이쪽이 단위꼴이어야 「몫」이지 규격이 아니다. _NUMER = "인|공|ℓ|L|l|㎏|kg|톤|ton|m|㎝|cm|매|본|개|주|대|시간|hr|㎥|m3|㎡|m2" #: ⚠ **표머리에 「당」 없이 몫으로만 적힌 밑수** — `인/ha` · `ℓ/일,대` · `m/본` (명세 꼴 B). #: 본문 쪽은 `SOURCE_BASIS_RATIO_RE` 가 이미 보는데 **표머리는 안 보고 있었다.** #: ⚠ 좁게 잡는다 — **양쪽이 다 단위꼴일 때만**. 안 그러면 `(무한궤도,0.7㎥)` 같은 #: 규격 칸이 밑수로 둔갑한다(옛날에 실제로 다섯 건 걸렸다). HEADER_RATIO_RE = re.compile( rf"(?:^|[((\s])(?:단위\s*[::]\s*)?(?:{_NUMER})\s*/\s*([\d,]*\.?\d*)\s*({_DENOM})\s*당?(?:$|[,))\s])" ) #: `재료비(1km 소요량기준)` 꼴 — 「당」 대신 「소요량기준」으로 적은 표머리. HEADER_BASIS_NOTE_RE = re.compile(rf"([\d,]*\.?\d*)\s*({_DENOM})\s*소요량\s*기준") #: 분모가 둘인 몫 — `ℓ/일,대` · `인/㎥·일`. 밑수가 하나로 안 정해진다. COMPOUND_DENOM_RE = re.compile(rf"/\s*[\d,]*\.?\d*\s*(?:{_DENOM})\s*[,,·]\s*(?:{_DENOM})") def basis_from_header(cell: str) -> tuple[float | None, str | None]: """표머리 한 칸에서 몫꼴 밑수를 읽는다. 못 읽으면 `(None, None)`. ⚠ 분모가 밑수다 — `인/ha` 는 「1ha당 몇 인」이라 밑수는 `ha` 다. 분자(인)를 밑수로 읽으면 뜻이 뒤집힌다. """ # ⚠ **분모가 둘인 몫은 거절한다** — `ℓ/일,대` 는 「하루에 한 대당」이라 밑수가 둘이다. # 한쪽(일)만 적으면 대수를 안 곱해 조용히 적게 선다. 반쪽 진실보다 「미확보」가 정직하다. if COMPOUND_DENOM_RE.search(cell): return None, None for pattern in (HEADER_RATIO_RE, HEADER_BASIS_NOTE_RE): if m := pattern.search(cell): raw = (m.group(1) or "").replace(",", "") unit = m.group(2) if not person_basis_ok(raw, unit): continue try: return (float(raw) if raw else 1.0), unit except ValueError: return 1.0, unit return None, None def detect_basis(table: dict[str, Any]) -> tuple[float | None, str | None]: """「100㎥당」 같은 밑수. 없으면 `(None, None)` — 단위당 1 로 단정하지 않는다. ⚠⚠ **칸 하나 안에서만 본다**(2026-09-09). 여러 칸을 이어 붙여 보면 **앞 칸의 값**과 **뒤 칸의 단위**가 붙어 없는 밑수가 생긴다 — 13-2-4 야면석 채집이 그랬다: 행 ① 인 부 | ㎡당 | 0.11 | 0.17 | 0.22 | 0.28 | **0.36** 행 ② **㎥당** | 0.60 | … 이어 붙이면 「0.36 ㎥당」이 되어 **밑수 0.36㎥** 라는 값이 선다(원문에 없는 값). ⚠ 그 표는 **㎡당·㎥당 두 줄**이라 애초에 하나로 못 정한다 — 「미확보」가 정직하다. ⚠ **「인」은 여기서도 숫자가 붙어야 인정한다** — `BASIS_RE` 가 숫자를 요구하므로 2-2-5 「천공인부 **1인당** 1대」는 그대로 서고 「(단위 : 인)」은 안 선다. """ cells = [norm(h) for h in table.get("headers", []) or []] cells += [norm(c) for r in (table.get("rows", []) or [])[:2] for c in r] for cell in cells: if not cell: continue if m := BASIS_RE.search(cell): if not person_basis_ok(m.group(1), m.group(2)): continue try: return float(m.group(1).replace(",", "")), m.group(2) except ValueError: return None, m.group(2) # 「당」이 없는 몫꼴은 **표머리에서만** 본다(줄 값에는 규격이 섞여 있다). # ⚠ 표머리가 **서로 다른 분모**를 말하면 거절한다 — 건설 8장 기계경비표가 한 표 안에서 # `주연료 (ℓ/hr)` 와 `조종원 (인/일)` 을 함께 적는다. 먼저 걸린 것을 고르면 반은 틀린다. found = { basis_from_header(cell) for cell in (norm(h) for h in table.get("headers", []) or []) if cell } found = {hit for hit in found if hit[1]} if len(found) == 1: return found.pop() return None, None # ⚠ **딱지가 비율을 달고 오는 표** — `인력(10%)` · `장비(90%)` (2026-09-07 서브 창 제보로 확인). # 그 표는 소요량형이면서 **장비 몫이 시공능력 공식**(Q = 3600·q·K·f·E ÷ Cm)이라 # **인력 10 % 만 값으로 서 있다.** 형태 한 낱말(`requirement`)로만 적으면 받는 쪽이 # 그 단가를 전량에 곱해 **내역서가 9할 싸게** 선다. 그래서 **몫과 미완 여부를 따로 싣는다.** SHARE_TAG_RE = re.compile( r"^(자재|재료|재료비|자재비|잡재료|장비|기계|인력|노무|노무비|인건비|경비|공구손료)" r"\s*[((]\s*(\d+(?:\.\d+)?)\s*[%%]\s*[))]$" ) #: 시공능력 공식 파라미터. 이 기호가 있으면 그 몫은 **아직 조립이 안 된 것**이다. CAPACITY_SYMBOLS = {"K", "k", "f", "E", "Cm", "㎝(sec)", "q", "qo", "Q"} # ⚠ **값 자리에 숫자가 아니라 식이 적힌 칸** (2026-09-07 서브 창 제보로 확인). # `0.2 × 30%`(기초잡석 소할) · `(0.2+0.26)/2` · `1/1.3` 처럼 계산이 그대로 적혀 있다. # 형태 판정은 통과하는데 **값이 숫자로 안 읽혀 그 성분이 조용히 빠진다** — # `partial_ratio` 와 같은 병인데 배분율 딱지가 없어 아무 검사에도 안 걸렸다. # ⚠ 여기서 **식을 계산하지 않는다.** 뜻을 잘못 읽으면 조용히 틀리므로 **드러내기만** 한다. _PLAIN_NUMBER_RE = re.compile(r"^[\d,]+(?:\.\d+)?$") _CALC_CELL_RE = re.compile(r"^[\d,.\s()×xX*/÷+\-%]+$") _CALC_MAX_LEN = 22 def expression_cells(table: dict[str, Any]) -> list[str]: """값 자리에 식이 적힌 칸 목록. 없으면 빈 목록.""" found: list[str] = [] for row in table.get("rows", []): for cell in row: text = norm(cell) if not text or len(text) > _CALC_MAX_LEN or _PLAIN_NUMBER_RE.match(text): continue if ( _CALC_CELL_RE.match(text) and re.search(r"\d", text) and re.search(r"[×xX*/÷+]", text) ): found.append(text) # 순서를 지키되 중복은 지운다 — 같은 식이 여러 줄에 반복된다. seen: list[str] = [] for item in found: if item not in seen: seen.append(item) return seen # ⚠ **작업조 표** — 「형틀목공 4인 + 보통인부 1인 / 시공량 25·35·40㎡」처럼 # **인원과 시공량**으로 적힌 표다. 그 「4」는 소요량이 아니라 **작업조 인원**이라 # 행-자원으로 그냥 읽으면 **35배 부푼다**(유로폼 12-38-3 이 그 표다). # ⚠ 값을 바꾸지 않고 **표시만** 한다 — 나누는 것은 받는 쪽 몫이다. CREW_QUANTITY_MARKS = ("시공량", "작업량", "1일작업량") # ⚠ **이름에 자간 공백이 든 기종·직종** — 같은 표 묶음 안에서도 표기가 갈린다 # (`13-6-1` 은 「굴 삭 기 (무한궤도)」, 바로 옆 `13-6-2` 는 「굴착기 (무한궤도)」). # 받는 쪽이 이름으로 자원을 찾으므로 **표기가 갈리면 그 줄이 통째로 빠진다.** # ⚠ **여기서 이름을 고치지 않는다** — 정규화는 값을 살리지만 **잘못된 줄도 함께 살린다** # (서브 창 실례: 이름 정규화 직후 버킷계수 `K` 를 소요량으로 읽어 시간당 사용료가 이중). # 깃발만 실어 받는 쪽이 대조하게 한다. _SPACED_NAME_RE = re.compile(r"^(?=.*\S\s\S)[가-힣](?:\s+[가-힣])+") #: 시공능력 공식 파라미터가 값 자리에 온 줄 — **소요량이 아니다.** 그냥 읽으면 이중계상. _FORMULA_KEYS = frozenset({"K", "k", "f", "E", "Cm", "q", "qo", "Q", "㎝(sec)"}) #: 자원 줄임을 알리는 단위 칸. 이것과 수치가 함께 있어야 자원으로 본다. _RESOURCE_UNITS = frozenset( {"인", "h", "hr", "시간", "대", "매", "본", "개", "kg", "㎏", "㎥", "㎡", "m", "ℓ", "톤", "ton"} ) def spaced_names(table: dict[str, Any]) -> list[str]: """자간 공백이 든 **자원 이름**. 표기가 갈리는 자리를 드러낸다. ⚠ **좁게 잡는다.** 「단 위」·「모 래」 같은 머리글·재료명까지 걸면 101건이 되어 목록이 잡음이 되고, 잡음이 되면 아무도 안 본다(오늘 아홉 번 겪은 병). **그 줄에 단위 칸과 수치가 함께 있는 것**만 자원 줄로 본다. """ found: list[str] = [] for row in table.get("rows", []): if not row: continue name = norm(row[0]) if not name or not _SPACED_NAME_RE.match(name): continue rest = [norm(cell) for cell in row[1:]] has_unit = any(cell in _RESOURCE_UNITS for cell in rest) has_number = any(_PLAIN_NUMBER_RE.match(cell) for cell in rest if cell) if has_unit and has_number and name not in found: found.append(name) return found def formula_rows(table: dict[str, Any]) -> list[str]: """값 자리에 시공능력 공식 기호가 온 줄. 자원으로 세면 이중계상이다.""" found: list[str] = [] for row in table.get("rows", []): for cell in row: text = norm(cell) if text in _FORMULA_KEYS and text not in found: found.append(text) return found # ⚠ **규격 표기에 쓰이는 특수문자** — 원문이 한 종류로 안 쓴다(2026-09-07 실측). # 물결표만 셋이다: `∼`(U+223C) 662회 · `~`(U+FF5E) 459회 · `~`(U+007E) 2회. # 곱셈표도 `×`(U+00D7) 97회 · `x`(U+0078) 4회로 갈린다. # **두 창이 각자 갈래 키를 조립하면 글자 하나로 영영 안 맞는다** — 그래서 우리는 # 키를 조립하지 않고 **저장 원본값만** 보낸다(인계 계약). 여기서는 **표시만** 한다. # ⚠ 값을 고치지 않는다 — 원문 표기를 흡수하는 것은 **원문을 읽는 쪽**의 몫이다. SPECIAL_GLYPHS = { "∼": "∼(U+223C)", "~": "~(U+FF5E)", "~": "~(U+007E)", "×": "×(U+00D7)", "x": "x(U+0078)", "–": "–(U+2013)", "‐": "‐(U+2010)", } def special_glyphs(table: dict[str, Any]) -> list[str]: """규격 표기에 쓰인 특수문자 종류. 갈래 키를 맞출 때 대조할 자리.""" text = " ".join(norm(cell) for row in table.get("rows", []) for cell in row) return sorted({SPECIAL_GLYPHS[ch] for ch in text if ch in SPECIAL_GLYPHS}) def crew_table(table: dict[str, Any]) -> bool: """작업조 + 시공량으로 적힌 표인가.""" hay = " ".join(norm(h) for h in table.get("headers", [])) body = " ".join(norm(c) for row in table.get("rows", []) for c in row) squeezed = (hay + " " + body).replace(" ", "") if not any(mark in squeezed for mark in CREW_QUANTITY_MARKS): return False return bool(OCCUPATION_RE.search(squeezed)) def resource_shares(table: dict[str, Any]) -> dict[str, float]: """`{인력: 10.0, 장비: 90.0}` — 딱지에 붙은 몫. 없으면 빈 칸.""" shares: dict[str, float] = {} for row in table.get("rows", []): if not row: continue if m := SHARE_TAG_RE.match(norm(row[0])): shares[m.group(1)] = float(m.group(2)) return shares def capacity_formula_pending(table: dict[str, Any]) -> bool: """장비 몫이 **시공능력 공식**으로만 적혀 있어 아직 값이 안 된 상태인가.""" keys = {norm(row[0]) for row in table.get("rows", []) if row} cells = {norm(c) for row in table.get("rows", []) for c in row} return bool((keys | cells) & CAPACITY_SYMBOLS) def basis_quantity_is_grouped(quantity: float | None) -> bool: """「10㎡당」처럼 **묶음 기준**인가. 1 이 아니면 곱셈이 그만큼 갈린다.""" return quantity is not None and abs(quantity - 1.0) > 1e-9 def new_report() -> dict[str, Any]: """표 읽기 셈 — 밑수 확보·묶음 기준 수 · 형태 못 정한 표 · 밑수 못 찾은 표. ⚠ 밑수를 못 찾은 표 목록은 「곱하면 안 되는 줄」을 받는 쪽이 가릴 수 있게 낸다 — 빈칸으로 두면 「1단위당」으로 오해되어 곱셈이 10배·100배 틀린다. """ return {"basis_found": 0, "basis_grouped": 0, "basis_missing": [], "undetermined": []} def table_entry( table: dict[str, Any], section: str, number: str | None, chapter: str | None, source_lines: list[str], node_name: str | None, report: dict[str, Any], ) -> dict[str, Any]: """표 한 장 → 공종 마스터 표 칸(형태·밑수·깃발). 산림·건설 한 벌 — 셈은 `report` 에 쌓음. `chapter` 는 형태 판정의 「1장 = 적용기준」 가름 · `node_name` 은 절 이름이 곧 밑수인 표(4-2-2 「1,000㎡당」). """ form, why = detect_form(table, chapter) # 사람이 가른 형태가 자동 판정을 이긴다 — 표마다 까닭 한 줄(미판정 표 판정, 2026-09-13). form, why = judged_form(table["table_id"], number) or (form, why) # ⚠ **본문이 정본이다.** 표 안을 긁는 쪽은 보조 — 비고에 적힌 다른 기준 # (「10㎡당」 같은 참고 문구)을 그 표의 밑수로 잘못 물어 온다. # 실제로 13-3-1 이 본문 `(단위: ㎥당)` 인데 표 안 긁기가 `10㎡` 를 물어 왔다. basis_qty = basis_unit = basis_source = None # 사람이 원문에서 확인한 밑수가 가장 앞이다 — **머리가 소실돼 자동으로는 못 읽는 표**만 해당. if judged := judged_basis(table["table_id"], number): basis_qty, basis_unit, basis_source = judged[0], judged[1], judged[2] if basis_unit is None and source_lines: basis_qty, basis_unit = basis_from_source(source_lines, int(table.get("line") or 0)) elif basis_unit is None: basis_source = None if basis_qty is None and basis_unit is None: basis_qty, basis_unit = detect_basis(table) basis_source = "표 안" if basis_unit else None elif basis_source is None: basis_source = "본문" # 본문·표 안에 없을 때만 **절 이름**을 본다(4-2-2 「1,000㎡당」) — 이름 전체가 밑수일 때만. if basis_unit is None and node_name is not None: name_qty, name_unit = basis_from_name(node_name) if name_unit: basis_qty, basis_unit, basis_source = name_qty, name_unit, "절 이름" if basis_unit: report["basis_found"] += 1 if basis_quantity_is_grouped(basis_qty): report["basis_grouped"] += 1 elif form in ("requirement", "productivity") and not crew_table(table): # 참조·계수표는 곱할 값이 아니므로 목록에 넣지 않는다 — 잡음이 되면 안 본다. # 작업조 표도 뺌 — 밑수가 시공량 열(1단위당 = 인원 ÷ 시공량 · B09 CrewOutput)이라 # 「N㎡당」 문구가 없어도 곱셈이 안 틀림(12-38-3 설치·해체가 여기 걸려 막혀 있었음 · 2026-09-14 301). report["basis_missing"].append( { "pum_table_id": table["table_id"], "section": norm(table.get("section")), "pum_form": form, "line": table.get("line"), } ) shares = resource_shares(table) # ⚠ 「값이 일부만 선 표」를 정상으로 흘려보내지 않는다. **몫이 적혀 있으면 부분값**으로 # 본다 — 관측한 25건 모두 장비 몫이 시공능력 공식으로만 적혀 있어 값이 아니었고, # 공식 기호가 첫 표에만 있고 이어지는 표는 그것을 물려받는 모양이라 # 「기호가 있는 표만」으로 세면 절반을 놓친다(9-13-2 가 그 경우). # ⚠ 이 깃발은 **표시일 뿐 값을 지우지 않는다** — 넓게 잡아도 정상 값이 안 사라진다. partial = bool(shares) entry = { "pum_table_id": table["table_id"], "section": section, "source_line": table.get("line"), "pum_form": form, "form_basis": why, "basis_quantity": basis_qty, "basis_unit": basis_unit, # 밑수를 어디서 읽었나 — 본문(정본) / 표 안(보조). 없으면 None. "basis_source": basis_source, "resource_shares": shares, "partial_ratio": partial, # ⚠ 값 자리에 식이 적힌 칸 — 그 성분은 숫자로 안 읽히므로 **금액을 만들면 안 된다**. "expression_cells": expression_cells(table), # ⚠ 작업조 표 — 「4」가 소요량이 아니라 인원이다. 그냥 읽으면 35배 부푼다. "crew_table": crew_table(table), # ⚠ 이름 표기가 갈리는 줄 — 받는 쪽이 이름으로 찾으면 통째로 빠진다. "spaced_names": spaced_names(table), # ⚠ 공식 기호 줄 — 소요량이 아니다. 자원으로 세면 이중계상. "formula_rows": formula_rows(table), # ⚠ 규격 표기 특수문자 — 갈래 키를 맞출 때 대조할 자리(값은 안 고침). "special_glyphs": special_glyphs(table), # 공식 기호가 이 표에 직접 있는가 — 없으면 앞 표에서 물려받는 모양이다. "capacity_formula_here": capacity_formula_pending(table), # 갈래 키 — 부모 모양을 단 뒤 표 읽기가 가른 대로 채움(`_Variants`). 못 가르면 빈칸. "variant_key": [], "notes": notes_from_source(source_lines, int(table.get("line") or 0)), "condition_note": [norm(h) for h in table.get("headers", []) if norm(h)], "raw_row": table.get("rows", []), # 원문 셀 — B09 자원 축이 읽는다. } if form == "undetermined": report["undetermined"].append( { "pum_table_id": table["table_id"], "section": section, "headers": entry["condition_note"], "first_rows": table.get("rows", [])[:2], "reason": why, } ) return entry