test(b08): 원문 맞대기 표 성격 사각 좁힘 — 품 표 276 → 305 알아봄 · 못 보는 12 와 비목 구성표 7 을 목록으로

브레인 일감 ①②. 시험이 못 보는 자리를 좁히고, 못 좁히는 자리는 까닭과 함께 남김.

- ① 표 성격 — 품 표 317 중 못 알아보던 41 의 원인을 가름
  · 넓힘: 단위 붙은 수(`10ℓ`·`5인/km`) · 범위 값(`3.3∼5.9`) · 「인력구분」「인원(명)」「시멘트(kg)」 표지 ·
    단위만 늘어선 줄(`| | ㎥ | ㎥ |`) → 305 알아봄(성격이 뒤집히면 빨강 — 10가지 뒤집어 확인)
  · 못 봄 12 → 목록 `form_blind` 에 까닭: 머리 소실 4 · 값 칸 비어 있음 4 · 뭉친 칸·식 글 2 ·
    연료표 1(F0046 — 같은 모양 셋은 참조인데 이것만 품) · 증가율표 1(13-4-1)
  · ⚠ 12-1-3 한중콘크리트 표(F0333)는 머리가 「온도 품종 0℃때…」인데 몸 줄이 앞 시멘트 표와 똑같음 — 원문 md 의심
  · 표지를 넓혀 새로 걸린 참조표 둘(8-6-1 F0227 · 13-4-4 F0410)은 사람 판정 참조라 까닭 달아 목록에
- ② 「재료비의 5%」 꼴 — 원문에 표 전체가 비목 구성(`재료비 | 설치비 재료비의 5%`)인 것은 7표(12장)
  · 모양은 시험으로 잡힘(첫 칸이 모두 비목 이름) · 참조냐 품이냐는 원문으로 못 가림 → `cost_rule` 「사람이 봐야 함」
  · 같은 꼴인데 12-29 만 requirement, 나머지 6 은 reference — 목록에 드러남
  · 「…비의 N%」 가 든 나머지 15표는 품 표 안의 한 칸(잡품 「주재료비의 5%」 · 감률 「굴취품의 20%」)이라 따로 안 셈
- 목록 칸 `why`(사람이 적은 까닭)는 맞대기에서 뺌 · --prune 도 까닭을 지킴

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
This commit is contained in:
2026-09-18 00:41:50 +09:00
co-authored by Claude Opus 5
parent bb091dc029
commit 92fc6d3159
2 changed files with 261 additions and 23 deletions
@@ -7,7 +7,10 @@
"basis: md=원문에서 읽은 밑수 [수, 단위] · master=마스터 밑수 · 둘 중 하나가 null 이면 한쪽만 있음.",
"notes: md_only=원문 [주]에 있는데 마스터에 없는 줄 · master_only=마스터에만 있는 줄.",
"form: 원문 표에 품 표 표지(직종 이름·소요인력·(인)·수량 단위 칸 + 숫자)가 있는데 마스터가 reference·coefficient 로 둔 표 · md_mark=그 표지 칸.",
"basis 의 12-7-1·12-7-2·12-8·12-38-3·13-16-2(F0339·F0340·F0341·F0395·F0449)는 일부러 다름 — 작업조 표라 밑수가 시공량 열(c3da333e)."
"basis 의 12-7-1·12-7-2·12-8·12-38-3·13-16-2(F0339·F0340·F0341·F0395·F0449)는 일부러 다름 — 작업조 표라 밑수가 시공량 열(c3da333e).",
"form_blind: 마스터는 품 표(requirement·productivity)인데 원문에서 품 표지를 못 찾은 표 — 시험이 성격 뒤집힘을 못 잡는 자리.",
"cost_rule: 첫 칸이 모두 비목(재료비·설치비 …)인 비목 구성표 — 모양은 잡히나 참조냐 품이냐는 원문으로 못 가림 · 사람이 봐야 함.",
"why: 사람이 적은 까닭 — 시험은 맞대기에서 뺌."
],
"place": {
"F0452": {
@@ -539,6 +542,12 @@
"master": "reference",
"md_mark": "적용시공량"
},
"F0227": {
"section": "8-6-1",
"master": "reference",
"md_mark": "유효 살포량/1회",
"why": "사람 판정 reference(Forms.py) — 기종별 희석배수별 1회 살포량 계산표"
},
"F0257": {
"section": "9-11-1",
"master": "coefficient",
@@ -574,10 +583,184 @@
"master": "reference",
"md_mark": "매"
},
"F0410": {
"section": "13-4-4",
"master": "reference",
"md_mark": "야면석(㎥) 호박돌(㎥)",
"why": "사람 판정 reference(Forms.py) — 채움 콘크리트 소요량 자재 원단위표, B08 채움표가 씀(품이면 두 번 셈) · 안티그래비티 2바퀴는 requirement 주장"
},
"F0454": {
"section": "14-2",
"master": "reference",
"md_mark": "작업량"
}
},
"form_blind": {
"F0046": {
"section": "2-1-1",
"master": "requirement",
"why": "기계별 연료표(천공기 주연료 3ℓ·잡품 95%) — 품 낱말 없음. 같은 모양 F0044·F0045·F0047 은 사람 판정 reference 인데 이것만 requirement"
},
"F0062": {
"section": "2-1-12",
"master": "requirement",
"why": "값 칸이 「층적부피에 따라 별도계산」·「〃」 — 숫자 없음"
},
"F0140": {
"section": "5-26-1",
"master": "requirement",
"why": "머리 소실 — 토성×깊이 숫자만 남음(사람 판정: 100㎡당 보통인부)"
},
"F0141": {
"section": "5-26-2",
"master": "requirement",
"why": "머리 소실 — 위와 같음"
},
"F0142": {
"section": "5-26-3",
"master": "requirement",
"why": "머리 소실 — 위와 같음"
},
"F0195": {
"section": "7-14",
"master": "productivity",
"why": "칸이 뭉침(「㎥ 0.060.07 4.86.0」) — 원문 md 가 병합 칸을 못 풂"
},
"F0237": {
"section": "8-11",
"master": "productivity",
"why": "작업량이 식 글(「Q = 3.5 ㎥/hr」)"
},
"F0333": {
"section": "12-1-3",
"master": "requirement",
"why": "⚠ 원문 md 의심 — 머리는 한중콘크리트 「온도 품종 | 0℃때 …」인데 몸 줄이 앞 표 F0332(시멘트·모래·자갈 kg)와 똑같음 · PDF 대조 필요"
},
"F0342": {
"section": "12-9-1",
"master": "requirement",
"why": "수량 칸이 비어 있음 — 원문에 값 없음(「반중력식 옹벽 참조」)"
},
"F0343": {
"section": "12-9-2",
"master": "requirement",
"why": "수량 칸이 비어 있음 — 원문에 값 없음"
},
"F0344": {
"section": "12-9-3",
"master": "requirement",
"why": "수량 칸이 비어 있음 — 원문에 값 없음"
},
"F0405": {
"section": "13-4-1",
"master": "requirement",
"why": "증가율(%) 표(높이별 30·40·60%) — 품이 아니라 할증률 모양 · requirement 가 맞는지 사람 확인"
}
},
"cost_rule": {
"F0369": {
"section": "12-22",
"master": "reference",
"md_rows": [
[
"재료비"
],
[
"설치비",
"재료비의 5%"
]
],
"why": "설치비 = 재료비의 5% — 참조(비율 지시)로 둘지 비율 자원 줄로 세울지는 설계 방식 결정"
},
"F0379": {
"section": "12-29",
"master": "requirement",
"md_rows": [
[
"재료비",
"",
"필요수량"
],
[
"설치비",
"재료비의 5%"
]
],
"why": "⚠ 같은 꼴 6표는 reference 인데 이것만 requirement — 설치비 = 재료비의 5%"
},
"F0382": {
"section": "12-31",
"master": "reference",
"md_rows": [
[
"재료비"
],
[
"설치비",
"주재료비의 5%"
]
],
"why": "설치비 = 주재료비의 5% — F0369 와 같은 물음"
},
"F0383": {
"section": "12-32",
"master": "reference",
"md_rows": [
[
"재료비",
"P.V.C ∅54m/m"
],
[
"설치비",
"주재료비의 10%"
]
],
"why": "설치비 = 주재료비의 10% — F0369 와 같은 물음"
},
"F0386": {
"section": "12-34-2",
"master": "reference",
"md_rows": [
[
"재료비",
"1회 기준 46.1%"
],
[
"노무비",
"1회 기준 47.1%"
]
],
"why": "재료비·노무비 「1회 기준 46.1%·47.1%」 — 거푸집 사용횟수 비율"
},
"F0388": {
"section": "12-34-4",
"master": "reference",
"md_rows": [
[
"재료비",
"JOINT FILLER"
]
],
"why": "재료비 칸에 자재 이름(JOINT FILLER)만 — 값 없음"
},
"F0390": {
"section": "12-36",
"master": "reference",
"md_rows": [
[
"제작비",
"견적처리"
],
[
"운송비",
"견적처리"
],
[
"설치비",
"견적처리"
]
],
"why": "제작비·운송비·설치비 모두 「견적처리」 — 값 없음"
}
}
}
@@ -37,6 +37,15 @@ KNOWN = Path(__file__).resolve().parent / "fixtures/work_item_master_source_know
TOC_TABLE = "F0001"
#: 이 줄 뒤는 부록(할인·할증 공종표 · 적용 공종 · 단가산출서 예시) — 공종 품이 아니라 `orphan_tables` 몫.
APPENDIX = "〔부록 1"
#: 알려진 목록 갈래. `form_blind`·`cost_rule` 은 어긋남이 아니라 **시험이 못 가리는 자리**를 적어 둔 것.
KIND_NAMES = {
"place": "표 자리",
"basis": "밑수",
"notes": "[주]",
"form": "표 성격",
"form_blind": "표 성격 — 품 표지를 못 알아봄",
"cost_rule": "비목 구성표 — 사람이 봐야 함",
}
# ── 원문 읽기 ────────────────────────────────────────────────────────────
_SEP = re.compile(r"\s*\|(\s*:?-+:?\s*\|)+\s*")
@@ -168,35 +177,68 @@ _WORKER = re.compile(
r"\s*부|기능공|운전|목\s*공|석\s*공|철근공|콘크리트공|기\s*사|벌목부|작업반장|기술자|"
r"용접공|미장공|방수공|배관공|비계공"
)
#: 품 낱말 — 공백을 지운 칸에서 찾음(`주 재 료` → `주재료`).
_WORK_MARK = re.compile(
r"\(인\)|소요인력|소요량|주입량|작업능력|작업량|수집량|집재량|공정량|시공량|인/|/인|\(시간\)|\(hr\)"
r"\(인\)|소요인력|소요량|주입량|작업능력|작업량|수집량|집재량|공정량|시공량|살포량|처리량|"
r"인력구분|인력구성|적용인부|인원\(명\)|^주재료$|인/|/인|\(시간\)|\(hr\)|[가-힣]\((kg|㎏|g|㎥|)\)"
)
_QTY_UNIT = {"", "", "", "", "kg", "", "", "", "m", "", "", "", "hr", "시간"}
#: 숫자 칸 — `0.16` · `1.5인` · `0.5` · 한 칸에 여러 값 `0.04 0.06 0.10`.
_NUMBER = re.compile(r"^\d[\d,]*(\.\d+)?(\s*(인||시간|㎥|개|본|매|kg|m|%))?(\s+[-\d,.]+)*$")
#: 숫자 칸 — `0.16` · 범위 `3.35.9` · 단위 붙음 `1.5인`·`10`·`5인/km`·`2.18Roll` ·
#: 한 칸에 여러 값 `0.04 0.06 0.10`.
_NUMBER = re.compile(
r"^\d[\d,]*(\.\d+)?(\s*[~∼~-]\s*\d[\d,]*(\.\d+)?)?"
r"(\s*(인||시간|㎥|㎡|개|본|매|kg|m|%|Roll)(/[a-z㎞㏊]+)?)?(\s+[-\d,.]+)*$"
)
def md_work_mark(table: dict) -> str | None:
"""품 표 표지 칸 — 있으면 그 칸 글, 없으면 `None`.
"""품 표 표지 칸 — 있으면 그 칸 글, 없으면 `None`. 표에 숫자 칸이 있을 때만 봄.
표 어딘가에 **짧은 칸(20자 이하)** 으로 직종 이름·품 낱말(`소요인력`·`(인)`·`작업능력` …)이 있고
숫자 칸도 있음 · ② 한 줄에 수량 단위 칸(`인`·`매`·`㎥` …)과 숫자 칸이 함께 있음.
① **짧은 칸(20자 이하)** 직종 이름·품 낱말(`소요인력`·`인력구분`·`(인)`·`시멘트(kg)` …)
② 한 줄에 수량 단위 칸(`인`·`매`·`㎥` …)과 숫자 칸이 함께 있음
③ 단위만 늘어선 줄(`| | ㎥ | ㎥ | ㎥ |`) — 값은 아래 줄에 있음(7-1-1 수확)
긴 설명 글 속 「보통인부」는 이름이 아님(손료계수표 적용기준 칸).
ponytail: 품 표 317 중 276 만 알아봄 — 값이 범위(`3.3∼5.9`)거나 머리가 여러 줄로 갈린 표는 못 봄.
⚠ 못 알아보는 품 표는 알려진 목록 `form_blind` 에 까닭과 함께 둠 — 그 표는 성격이 뒤집혀도 못 잡음.
"""
rows = [table["headers"], *table["rows"]]
cells = [c for row in rows for c in row]
if any(_NUMBER.match(c) for c in cells):
for c in cells:
if len(c) <= 20 and (_WORKER.search(c) or _WORK_MARK.search(c)):
return c
if not any(_NUMBER.match(c) for c in cells):
return None
for c in cells:
if len(c) <= 20 and (_WORKER.search(c) or _WORK_MARK.search(c.replace(" ", ""))):
return c
for row in rows:
unit = next((c for c in row if c in _QTY_UNIT), None)
if unit and any(_NUMBER.match(c) for c in row):
return unit
filled = {c for c in row[1:] if c}
if len(filled) == 1 and filled <= _QTY_UNIT:
return next(iter(filled))
return None
#: 비목 이름 — 이것만으로 첫 칸이 채워진 표가 비목 구성표(`재료비 | 설치비 재료비의 5%`).
_COST_ITEMS = {"재료비", "설치비", "노무비", "경비", "제작비", "운송비", "자재비", "주재료비"}
def md_cost_rows(table: dict) -> list[list[str]] | None:
"""비목 구성표면 그 줄들(뒤 빈 칸 뗌), 아니면 `None`.
⚠ 이 꼴은 **모양은 잡히나 성격은 원문으로 못 가림** — 「설치비 = 재료비의 5%」 가 품(자원 줄)인지
참조(비율 지시)인지는 설계 방식의 결정이라 사람이 봐야 함(알려진 목록 `cost_rule`).
"""
rows = [r for r in table["rows"] if r and r[0].strip()]
if not rows or any(r[0].replace(" ", "") not in _COST_ITEMS for r in rows):
return None
out = []
for r in rows:
cut = list(r)
while cut and not cut[-1]:
cut.pop()
out.append(cut)
return out
# ── 맞대기 ──────────────────────────────────────────────────────────────
def _norm(text: str) -> str:
return " ".join(str(text).split())
@@ -225,7 +267,7 @@ def load_sources() -> dict:
def current_mismatches(src: dict) -> dict[str, dict]:
"""표 자리·밑수·[주]·표 성격 어긋남 — 알려진 목록과 같은 꼴. 밑수·[주]·성격은 공종에 붙은 표만 봄."""
lines = src["lines"]
out: dict[str, dict] = {"place": {}, "basis": {}, "notes": {}, "form": {}}
out: dict[str, dict] = {kind: {} for kind in KIND_NAMES}
appendix = next(i for i, x in enumerate(lines, 1) if x.strip().startswith(APPENDIX))
orphans = {o["pum_table_id"] for o in src["master"]["orphan_tables"]}
for table in src["tables"]:
@@ -267,12 +309,19 @@ def current_mismatches(src: dict) -> dict[str, dict]:
"master_only": [x for x in entry["notes"] if x not in md_note],
}
if entry["pum_form"] in ("reference", "coefficient") and (mark := md_work_mark(table)):
out["form"][table["id"]] = {
form = entry["pum_form"]
if cost_rows := md_cost_rows(table):
out["cost_rule"][table["id"]] = {
"section": section,
"master": entry["pum_form"],
"md_mark": mark,
"master": form,
"md_rows": cost_rows,
}
continue # 성격은 사람 몫 — 품 표지로 재지 않음
mark = md_work_mark(table)
if form in ("reference", "coefficient") and mark:
out["form"][table["id"]] = {"section": section, "master": form, "md_mark": mark}
elif form in ("requirement", "productivity") and not mark:
out["form_blind"][table["id"]] = {"section": section, "master": form}
return out
@@ -280,6 +329,10 @@ def load_known() -> dict[str, dict]:
return json.loads(KNOWN.read_text(encoding="utf-8"))
def _without_why(entry: dict) -> dict:
return {k: v for k, v in entry.items() if k != "why"}
# ── 시험 ────────────────────────────────────────────────────────────────
def test_표_수와_자리(src: dict) -> None:
"""원문 표가 원본(`pum_forest`)에 같은 차례·같은 줄로 있고, 마스터에 빠짐없이 한 번씩 있음."""
@@ -340,14 +393,14 @@ def test_셀_값이_글자까지_같음(src: dict) -> None:
assert not diffs, f"셀 어긋남 {len(diffs)}건 — 앞 30:\n" + "\n".join(diffs[:30])
KIND_NAMES = {"place": "표 자리", "basis": "밑수", "notes": "[주]", "form": "표 성격"}
@pytest.mark.parametrize("kind", list(KIND_NAMES))
def test_알려진_어긋남_밖은_없음(src: dict, kind: str) -> None:
"""표 자리·밑수·[주]·표 성격 — 알려진 목록과 똑같아야 함. 새로 어긋남 · 모습 바뀜 · 고쳐짐 셋 다 빨강."""
"""알려진 목록과 똑같아야 함 새로 어긋남 · 모습 바뀜 · 고쳐짐 셋 다 빨강.
목록 칸 `why` 는 사람이 적은 까닭 — 맞대기에서 뺌.
"""
now = current_mismatches(src)[kind]
known = load_known()[kind]
known = {k: _without_why(v) for k, v in load_known()[kind].items()}
lines: list[str] = []
for tid in sorted(set(now) - set(known)):
lines.append(f" 새로 어긋남 {tid}: {json.dumps(now[tid], ensure_ascii=False)}")
@@ -368,6 +421,8 @@ if __name__ == "__main__" and "--prune" in sys.argv:
data = load_known()
for kind in KIND_NAMES:
before = len(data[kind])
data[kind] = {k: v for k, v in data[kind].items() if current[kind].get(k) == v}
data[kind] = {
k: v for k, v in data[kind].items() if current[kind].get(k) == _without_why(v)
}
print(f"{KIND_NAMES[kind]}: {before}{len(data[kind])}")
KNOWN.write_text(json.dumps(data, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")