test(b08): 원문 md ↔ 마스터 맞대기 시험 — 안티그래비티 손 대조를 자동으로

브레인 일감. 원문 `(산림청고시 제2025-82호) 산림사업 표준품셈.md` 를 **빌더 코드 없이 따로 읽어**
`pum_forest_2026.json`·`work_item_master_2026-01-01.json` 과 맞댐. 어긋나면 표 번호·줄·칸까지 찍음.

- 0 이어야 하는 것 — 표 수·차례(476) · 셀 값 글자까지(원본 헤더·줄 · 마스터 condition_note·raw_row). 지금 0
- 알려진 어긋남 목록(`fixtures/work_item_master_source_known.json`) 밖에서 새로 어긋나면 빨강
  · 표 자리 3 — 〔부록 1~3〕 표 F0452·F0453·F0454 가 14-2 근주이식에 붙음
  · 밑수 26 — 원문 괄호가 있는데 마스터 비음 13(4-4 「(인/100㎡)」 · 12-19 「(단위: 공㎥당)」 …) ·
    마스터에만 있음 13(3-2 「인/1일 1km당」 분모 둘인데 km · 사람 판정 머리 소실 표 · 「(단위 : ㎥)」 …)
  · [주] 51 — 마스터가 40줄 넘는 표의 [주]를 놓침(1-2-2) · `*` 줄에서 끊음(4-2-1 ③~⑧ 할증 빠짐) ·
    본문 소제목 `- 2. …` 을 [주]로 실음 · 다음 표의 [주]를 끌어옴
  · 표 성격 11 — 품 표 표지(직종·소요인력·수량 단위 + 숫자)가 있는데 reference·coefficient
- 목록에 있는데 고쳐졌거나 모습이 바뀐 것도 빨강 — `--prune` 은 지우기만 함(새 것은 안 보탬)
- 안티그래비티 1바퀴 96건과 겹침 — 밑수 4 · 표 성격 5. 나머지는 이미 반영됐거나(데스크탑 서브 16) 보고 표 번호가 한 칸 밀려 있었음
- ponytail: 품 표 알아보기는 317 중 276 — 값이 범위거나 머리가 여러 줄로 갈린 표의 성격 뒤집힘은 못 잡음

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-17 23:51:14 +09:00
co-authored by Claude Opus 5
parent eb3fb389b8
commit 71c5dc2583
2 changed files with 1128 additions and 0 deletions
@@ -0,0 +1,756 @@
{
"_설명": [
"원문 md ↔ 공종 마스터 — 지금 알고 있는 어긋남(2026-09-17 랩탑 메인이 시험으로 처음 뜸).",
"시험: resources/tester/test_work_item_master_source_match.py — 이 목록 밖에서 어긋나면 빨강.",
"고치면 지울 것: python resources/tester/test_work_item_master_source_match.py --prune (지우기만 함).",
"place: 원문 〔부록 1〕 뒤 표인데 공종에 붙었거나(부록 표), 앞 표인데 공종에 안 붙은 것.",
"basis: md=원문에서 읽은 밑수 [수, 단위] · master=마스터 밑수 · 둘 중 하나가 null 이면 한쪽만 있음.",
"notes: md_only=원문 [주]에 있는데 마스터에 없는 줄 · master_only=마스터에만 있는 줄.",
"form: 원문 표에 품 표 표지(직종 이름·소요인력·(인)·수량 단위 칸 + 숫자)가 있는데 마스터가 reference·coefficient 로 둔 표 · md_mark=그 표지 칸."
],
"place": {
"F0452": {
"section": "14-2",
"md": "부록 표",
"master": "공종에 붙음"
},
"F0453": {
"section": "14-2",
"md": "부록 표",
"master": "공종에 붙음"
},
"F0454": {
"section": "14-2",
"md": "부록 표",
"master": "공종에 붙음"
}
},
"basis": {
"F0046": {
"section": "2-1-1",
"md": null,
"md_evidence": "",
"master": [
1.0,
"인"
]
},
"F0050": {
"section": "2-1-4",
"md": [
1.0,
"ha"
],
"md_evidence": "표머리 재료비 (ha당 소요량)",
"master": null
},
"F0052": {
"section": "2-1-4",
"md": [
1.0,
"ha"
],
"md_evidence": "표머리 재료비 (ha당 소요량)",
"master": null
},
"F0068": {
"section": "2-2-5",
"md": null,
"md_evidence": "",
"master": [
1.0,
"인"
]
},
"F0076": {
"section": "3-2",
"md": null,
"md_evidence": "",
"master": [
1.0,
"km"
]
},
"F0089": {
"section": "4-4",
"md": [
100.0,
"㎡"
],
"md_evidence": "(인/100㎡)",
"master": null
},
"F0090": {
"section": "4-5",
"md": [
1.0,
"ha"
],
"md_evidence": "(인/ha)",
"master": null
},
"F0117": {
"section": "5-12",
"md": [
100.0,
"㎡"
],
"md_evidence": "(인/100㎡)",
"master": null
},
"F0129": {
"section": "5-21",
"md": [
1.0,
"㎡"
],
"md_evidence": "(인/㎡)",
"master": null
},
"F0132": {
"section": "5-22-4",
"md": [
1.0,
"일"
],
"md_evidence": "(일당)",
"master": null
},
"F0140": {
"section": "5-26-1",
"md": null,
"md_evidence": "",
"master": [
100.0,
"㎡"
]
},
"F0141": {
"section": "5-26-2",
"md": null,
"md_evidence": "",
"master": [
100.0,
"㎡"
]
},
"F0142": {
"section": "5-26-3",
"md": null,
"md_evidence": "",
"master": [
100.0,
"㎡"
]
},
"F0173": {
"section": "7-2",
"md": null,
"md_evidence": "",
"master": [
1.0,
"㎥"
]
},
"F0188": {
"section": "7-10",
"md": null,
"md_evidence": "",
"master": [
1.0,
"㎥"
]
},
"F0191": {
"section": "7-11",
"md": [
1.0,
"톤"
],
"md_evidence": "(대/ton)",
"master": null
},
"F0224": {
"section": "8-5",
"md": [
1.0,
"ha"
],
"md_evidence": "표머리 소요인력(조/ha당)",
"master": null
},
"F0225": {
"section": "8-6-1",
"md": null,
"md_evidence": "",
"master": [
160.0,
"ha"
]
},
"F0257": {
"section": "9-11-1",
"md": [
1.0,
"일"
],
"md_evidence": "(단위: 일당)",
"master": null
},
"F0322": {
"section": "10-13-1",
"md": null,
"md_evidence": "",
"master": [
1000.0,
"본"
]
},
"F0323": {
"section": "10-13-2",
"md": null,
"md_evidence": "",
"master": [
1000.0,
"본"
]
},
"F0326": {
"section": "11-2",
"md": null,
"md_evidence": "",
"master": [
1.0,
"개소"
]
},
"F0327": {
"section": "11-3",
"md": null,
"md_evidence": "",
"master": [
1.0,
"개소"
]
},
"F0338": {
"section": "12-6",
"md": [
1.0,
"일"
],
"md_evidence": "(단위: 일당)",
"master": null
},
"F0366": {
"section": "12-19",
"md": [
1.0,
"공㎥"
],
"md_evidence": "(단위: 공㎥당)",
"master": null
},
"F0447": {
"section": "13-15-2",
"md": [
1.0,
"조"
],
"md_evidence": "(단위: 인/조)",
"master": null
}
},
"notes": {
"F0002": {
"section": "1-2-2",
"md_only": [
"[주] ① 설계서 수량의 단위와 소수자리 표시는 본 표에 따르며, 반올림하여 적용한다.",
"- ② 품셈 각 항목에서 제시한 소숫자리가 본 표의 내용과 상이할 경우 항목에서 제시하는 소숫자리를 우선하여 적용한다.",
"- ③ 본 표에 제시하지 않은 품의 경우 유사품의 규격과 단위수량을 참고하여 적용하며, C.G.S 단위로 하는 것을 원칙으로 한다."
],
"master_only": []
},
"F0004": {
"section": "1-2-3",
"md_only": [],
"master_only": [
"- 라. 체적환산계수(f)표"
]
},
"F0008": {
"section": "1-3-1",
"md_only": [],
"master_only": [
"- 3. 노상 및 노반재료(선택층, 보조기층, 기층 등)"
]
},
"F0009": {
"section": "1-3-1",
"md_only": [],
"master_only": [
"[주] 이형철근의 경우, 해당 공사 또는 구조물의 시공실적에 따라 조정하여 적용할 수 있다.",
"- 6. 기타재료"
]
},
"F0010": {
"section": "1-3-1",
"md_only": [],
"master_only": [
"[주] 이형철근의 경우, 해당 공사 또는 구조물의 시공실적에 따라 조정하여 적용할 수 있다.",
"- 6. 기타재료"
]
},
"F0011": {
"section": "1-3-1",
"md_only": [],
"master_only": [
"- 6. 기타재료"
]
},
"F0015": {
"section": "1-4-3",
"md_only": [
"* A, B 사업지 간격은 A구역 경계에서 B구역 경계까지 거리를 말함",
"- - 예) 소반면적 20ha, 10개소일 경우, 20ha÷10개소 = 2.0ha/개소로 +5% 할증 적용"
],
"master_only": []
},
"F0044": {
"section": "2-1-1",
"md_only": [],
"master_only": [
"[주] 잡품은 엔진유, 기어유, 그리스 등 기타소모품에 해당하며 주 연료비에 대하여 금액비율로 적용한다.",
"- 5. 천공기"
]
},
"F0045": {
"section": "2-1-1",
"md_only": [],
"master_only": [
"- 5. 천공기"
]
},
"F0054": {
"section": "2-1-5",
"md_only": [],
"master_only": [
"- 3. 뿌리제거(100본당)"
]
},
"F0083": {
"section": "4-1-1",
"md_only": [],
"master_only": [
"- 3. 박피품은 별도 적용하여야 한다. 다만 작업조건에 따라 1일 작업량은 변동 적용한다."
]
},
"F0086": {
"section": "4-2-1",
"md_only": [
"* (전목) 나무베기, (전간목) 벌목+가지정리, (단목) 벌목+가지정리+토막내기",
"- ③ 가지제거 : 베어진 나무의 가지를 제거하는 작업이다.",
"- ④ 토막내기(자르기) : 베어진 나무를 2m 길이로 자르는 작업. 자르는 길이를 달리할 경우 품셈을 기준으로 변형 사용이 가능하다.(산주의 요구)",
"- ⑤ 관목류 제거 시 보통인부의 30%를 할증한다.",
"- ⑥ 규격재 생산을 위한 조재가 포함될 경우 20%를 할증한다.",
"- ⑦ 소나무재선충병 벌목조재는 소각, 매몰, 훈증, 박피(소나무재선충병)을 참고한다.",
"- ⑧ 할인․할증은 집단화정도(1-4-3), 경사도(1-4-5), 장애물의 정도(1-4-8), 규격재 생산(1-4-16)을 반영하되 산림병해충방제에는 집단화정도(1-4-3)를 적용하지 않고 방제대상목 분포(1-4-17)를 반영한다."
],
"master_only": []
},
"F0104": {
"section": "5-3-4",
"md_only": [],
"master_only": [
"[주] ① 본 품은 흉고 또는 근원직경을 추정하기 어려운 수종에 적용한다.",
"- ② 재료 소운반, 터파기, 나무 세우기, 묻기, 물주기, 지주목 세우기, 뒷정리 작업을 포함한다.",
"- ③ 식재 시 1회 기준의 물주기는 포함되어 있으며, 유지관리는 별도 계상한다.",
"- ④ 물주기를 위해 살수차 등의 장비가 필요한 경우 기계경비는 별도 계상한다.",
"- ⑤ 암반식재, 부적기식재 등 특수식재시는 품을 별도 계상할 수 있다.",
"- ⑥ 현장의 시공조건, 수목의 성상에 따라 기계 시공이 불가피한 경우는 별도 계상한다.",
"- ⑦ 굴착기 규격은 0.4㎥를 기준으로 한다."
]
},
"F0106": {
"section": "5-3-5",
"md_only": [],
"master_only": [
"[주] ① 본 품은 교목류 수종에 적용한다.",
"- ② 재료 소운반, 터파기, 나무 세우기, 묻기, 물주기, 지주목 세우기, 뒷정리 작업을 포함한다.",
"- ③ 식재 시 1회 기준의 물주기는 포함되어 있으며, 유지관리는 별도 계상한다.",
"- ④ 물주기를 위해 살수차 등의 장비가 필요한 경우 기계경비는 별도 계상한다.",
"- ⑤ 흉고직경은 지표면에서 높이 1.2m 부위의 나무줄기 지름이다.",
"- ⑥ 암반식재, 부적기식재 등 특수식재시는 품을 별도 계상할 수 있다.",
"- ⑦ 현장의 시공조건, 수목의 성상에 따라 기계시공이 불가피한 경우는 별도 계상한다.",
"- ⑧ 장비 규격은 다음을 기준으로 한다."
]
},
"F0116": {
"section": "5-11",
"md_only": [
"* 화본과 : 갯개고리풀, 갯쇠보리, 새, 솔새",
"* 사초과 : 보리사초, 통보리사초, 솔보리사초, 행부자(숨복사지)",
"* 국화과 : 갯쑥부쟁이, 갯쑥, 갯씀바귀, 갯상근, 갯메꽃, 갯질경, 모래지치, 갯보리, 큰개미자리, 자귀풀",
"* 콩 과 : 갯완두",
"* 도입 초종 : american beach grass, weeping love grass, switch grass"
],
"master_only": []
},
"F0137": {
"section": "5-24-2",
"md_only": [],
"master_only": [
"[주] ① 「도로비탈면 녹화공사의 설계및 시공 지침 2.7 종자배합설계(2009)」 참조한다.",
"- ② 기타 적용지역이란 자연경관복원형의 복원목표가 적용되지 않는 지역을 말한다.",
"- ③ 해안생태계지역에서는 해안에 적합한 종자배합을 하고, 외래도입초종이 10%를 상회하지 않도록 한다."
]
},
"F0169": {
"section": "7-1-1",
"md_only": [],
"master_only": [
"- 2. 인공림 모두베기"
]
},
"F0170": {
"section": "7-1-1",
"md_only": [],
"master_only": [
"[주] 경사도는 1개 기번 전임지의 평균경사도다."
]
},
"F0189": {
"section": "7-11",
"md_only": [],
"master_only": [
"[주]① 임업용 굴착기(우드그래플)이 임내를 주행하여 산물을 수집하는 산지 경사도는 15°∼25° 이하의 벌채지 집재 작업량이다.",
"- ② 본 품에는 벌채지의 재해방지 및 산불예방을 위한 산물정리비가 반영된 것으로 본다.",
"- ③ 잔존목 본 수가 100본 이상 130본 이하(잔존목간 거리 108.8m)인 경우 10%, 71본 이상 100본 미만(잔존목간 거리 12~10m)인 경우 5%를 할증한다.",
"- ④ 할인․할증은 경사도(1-4-5), 주행 장애물 상태(1-4-13)를 반영한다."
]
},
"F0206": {
"section": "8-2-1",
"md_only": [],
"master_only": [
"[주]① 조경용(전정) 소나무 또는 가지나 잎이 적은 소나무류는 주입량을 적게한다.",
"- ② 가슴높이 지름 100㎝이상의 대형목은 수목의 가치, 활력도 등을 고려하여 상단부 가지 주입 및 가압방식 적용한다.",
"- ③ 매개충나무주사를 실행할 시 30%를 가산한다."
]
},
"F0225": {
"section": "8-6-1",
"md_only": [],
"master_only": [
"- 2. 약제살포량"
]
},
"F0226": {
"section": "8-6-1",
"md_only": [],
"master_only": [
"[주] 기종별 헬기 운행안전을 고려하여 약제원액과 희석제 등 1회 총 살포량을 말한다."
]
},
"F0228": {
"section": "8-6-2",
"md_only": [],
"master_only": [
"- 2. 무인 멀티콥터"
]
},
"F0230": {
"section": "8-6-3",
"md_only": [],
"master_only": [
"- 2. 지상살포 희석배수별 약제소요량"
]
},
"F0247": {
"section": "9-7-1",
"md_only": [
"* 상차 및 운반은 폐기물 상차 및 운반비에 적용",
"* 소량일 경우에는 도로 계획고 1.0m 미만의 흙쌓기(노체) 재료로 재활용(폐아스콘 제외)할 수 있음 「건설공사 표준품셈 공통부문, 8-2-13 대형브레이커(2025)」, 「건설공사 표준품셈 공통부문, 1-2-3 토질. 3. 체적환산계수적용(2025)」 참조",
"- ⑥ 굴착기의 작업능력 산정 비교"
],
"master_only": []
},
"F0249": {
"section": "9-7-2",
"md_only": [
"* 굴착기작업능력은 무근콘크리트(T=30㎝미만) 값 적용",
"- ④ 공사완료 후 재시공 및 보수공사의 구조물 헐기에는 「건설공사 표준품셈 유지관리부문, 3-1-1 콘크리트구조물 헐기(인력)(2025)」, 「건설공사 표준품셈 유지관리부문, 3-1-2 콘크리트구조물 헐기(기계)(2025)」의 인력(할석공, 보통인부) 및 소형장비사용(소형브레이커, 착암공)을 비교ㆍ검토 후 적용한다."
],
"master_only": []
},
"F0250": {
"section": "9-8-1",
"md_only": [],
"master_only": [
"- 2. 철근절단"
]
},
"F0251": {
"section": "9-8-1",
"md_only": [],
"master_only": [
"[주]① 장비는 무한궤도 굴착기(0.7㎥)를 적용한다.",
"- ② 굴착기작업능력은 무근콘크리트(T=30㎝미만) 값 적용한다.",
"- 4. 고철공제 : A=1㎥×0.008×7,850kg/㎥×80% = 50.24 kg/㎥ ≒50 kg/㎥",
"[주] 「건설공사 표준품셈 공통부문, 8-2-13 대형브레이커(2025)」, 「건설공사 표준품셈 유지관리부문, 3-1-3 철골재 철거(인력)(2025)」, 「건설공사 표준품셈 유지관리부문, 3-1-4 철골재 철거(기계)(2025)」, 「건설공사 표준품셈 공통부문, 1-2-3 토질, 3. 체적환산계수적용(2025)」, 「국토건설 설계실무요령, 수량산출요령(2021)」 참조한다"
]
},
"F0252": {
"section": "9-8-2",
"md_only": [],
"master_only": [
"- 2. 철근절단 : 철근콘크리트(T=30㎝ 미만)품을 적용한다.",
"- 3. 집토 : Q=3600×q×k×f×E/㎝= ㎥/시간",
"[주]① 장비는 무한궤도 굴착기(0.7㎥)를 적용한다.",
"- ② 굴착기작업능력은 무근콘크리트(T=30㎝미만) 값 적용한다.",
"- 4. 고철공제 : A=1㎥×0.008×7,850kg/㎥×80% = 50.24 kg/㎥≒50 kg/㎥",
"[주]① 적사 및 운반은 폐기물 상차 및 운반비에서 적용한다.",
"- ② 「건설공사 표준품셈 유지관리부문, 3-1-3 철골재 철거(인력)(2025)」, 「건설공사 표준품셈 유지관리부문., 3-1-4 철골재 철거(기계)(2025)」 참조한다"
]
},
"F0254": {
"section": "9-10-1",
"md_only": [],
"master_only": [
"- 2. 집토 : Q=3600×q×k×f×E/㎝= ㎥/시간",
"[주]① 장비는 무한궤도 굴착기(0.7㎥)를 적용한다.",
"- ② 굴착기작업능력은 무근콘크리트(T=30㎝미만) 값 적용한다."
]
},
"F0255": {
"section": "9-10-2",
"md_only": [],
"master_only": [
"- 2. 집토 : Q=3600×q×k×f×E/㎝= ㎥/시간",
"[주]① 장비는 무한궤도 굴착기(0.7㎥)를 적용한다.",
"- ② 굴착기작업능력은 무근콘크리트(T=30㎝미만) 값을 적용한다.",
"- ③ 「건설공사 표준품셈 공통부문, 8-2-13 대형브레이커(2025)」 참조한다."
]
},
"F0288": {
"section": "9-19-1",
"md_only": [],
"master_only": [
"- 2. 성토면 고르기"
]
},
"F0308": {
"section": "10-7-4",
"md_only": [],
"master_only": [
"- 나. 1사이클의 소요시간(cm)"
]
},
"F0309": {
"section": "10-7-4",
"md_only": [],
"master_only": [
"- (2) 운반시간()"
]
},
"F0310": {
"section": "10-7-4",
"md_only": [],
"master_only": [
"- 3. 단가표",
"- 가. 모노레일 가설ㆍ철거단가표"
]
},
"F0311": {
"section": "10-7-4",
"md_only": [],
"master_only": [
"- 나. 모노레일 운전 단가표"
]
},
"F0320": {
"section": "10-12-1",
"md_only": [],
"master_only": [
"[주]① 장비는 덤프트럭(15ton)을 적용한다.",
"- ② 덤프트럭의 작업능력 산정 비교"
]
},
"F0323": {
"section": "10-13-2",
"md_only": [],
"master_only": [
"[주] 위 단가산출은 일본 임야청에서 묘목 5,000본의 운반을 가정하여 현장 검증을 통해 산출한 내용이므로, 묘목 이외의 다른 화물을 운반할 시에는 달라질 수 있다."
]
},
"F0355": {
"section": "12-17-1",
"md_only": [],
"master_only": [
"- 2. 일일 시공량"
]
},
"F0356": {
"section": "12-17-1",
"md_only": [
"* 시설유형(f₁)"
],
"master_only": []
},
"F0393": {
"section": "12-38-2",
"md_only": [],
"master_only": [
"[주] ① 재료량에는 재료의 할증 및 손율이 포함되어 있다.",
"- ② 플랫 타이(Flat Tie) 대신 폼 타이(Form Tie) 사용 시 소요 수량은 12-4 합판 거푸집 자재수량을 따른다."
]
},
"F0405": {
"section": "13-4-1",
"md_only": [],
"master_only": [
"[주] 본 기준은 인력 돌쌓기 및 돌붙임에 적용한다."
]
},
"F0417": {
"section": "13-5-2",
"md_only": [],
"master_only": [
"- 2. 찰붙임"
]
},
"F0427": {
"section": "13-10-2",
"md_only": [],
"master_only": [
"- 2. 기초말뚝 박기"
]
},
"F0429": {
"section": "13-10-2",
"md_only": [],
"master_only": [
"[주] ① 본 품은 보통 토사질 상태에서 관입길이 6~8할 박기의 경우이며, 3~5할 박기의 경우에는 본 품 중의 개수를 20% 가산한다.",
"- ② 지질상태의 경연에 따라서 본 품의 30% 이내에서 가감할 수 있다.",
"- ③ 인부품에는 말뚝머리 자르기를 포함한다.",
"- ④ 콘크리트 말뚝박기의 경우에는 1일 시공개수를 20% 가산한다.",
"- ⑤ 원동기에 전력을 사용할 때에는 경유 대신 전력량 0.75KWH/HP/hr를 계상한다.",
"- ⑥ 항타 시설의 조립 및 해체는 별도로 계상한다.",
"- ⑦ 윈치 및 발동기의 손료는 별도로 계상하고 기타 시설은 인력품에 포함되어 있다.",
"- ⑧ 계항인 경우에는 선타항의 길이를 표준으로 하여 그 품의 50%를 가산한다.",
"- ⑨ 잡재료(잡유 기타)는 주화연료(主火燃料)(경유)에 대한 율을 표시한다.",
"- ⑩ 널말뚝 박기 때에는 널말뚝 단면이 내접하여 만들어진 구형 단면의 주장과 동일 주장을 갖는 말뚝의 품으로 한다.",
"- ⑪ 강관 및 콘크리트 말뚝을 디젤해머로 타설하는 경우 「건설공사 표준품셈 공통부문, 8-2-22 디젤 파일 해머(2025)」 항목에 의한다."
]
},
"F0431": {
"section": "13-11-1",
"md_only": [],
"master_only": [
"- 2. 기계"
]
},
"F0433": {
"section": "13-11-2",
"md_only": [],
"master_only": [
"- 2. 기계"
]
},
"F0441": {
"section": "13-13-2",
"md_only": [],
"master_only": [
"[주] ① 목재공작물의 설치에 소요되는 잡재료비는 실소요량을 산출하여 계상한다.",
"- ② 공작물별 구조의 구분과 적용은 다음의 기준을 따른다.",
"- - 간단구조 : 현장 가공이 필요 없는 외부 제작 목구조체의 현장 설치",
"- - 보통구조 : 현장 실정에 맞춰 일부 가공이 필요한 목구조체의 현장 설치"
]
},
"F0442": {
"section": "13-13-2",
"md_only": [],
"master_only": [
"[주] ① 목재공작물의 설치에 소요되는 잡재료비는 실소요량을 산출하여 계상한다.",
"- ② 공작물별 구조의 구분과 적용은 다음의 기준을 따른다.",
"- - 간단구조 : 현장 가공이 필요 없는 외부 제작 목구조체의 현장 설치",
"- - 보통구조 : 현장 실정에 맞춰 일부 가공이 필요한 목구조체의 현장 설치"
]
},
"F0446": {
"section": "13-15-2",
"md_only": [],
"master_only": [
"[주] ① 인부품에는 횡목준비, 횡목설치, 잔가지 정리 및 되메우기를 포함한다.",
"- ② 횡목 또는 지지목으로 사용하고 남은 산물을 처리하는데 소요되는 시간을 포함한다.",
"- ③ 횡목의 직경은 20 30cm으로 제한한다.",
"- ④ 벌도목(횡목, 지지목, 주변 방해목)의 직경과 가지량에 따라 작업조건을 분류한다."
]
},
"F0454": {
"section": "14-2",
"md_only": [
"[주] ① 노임단가는 2025년 6월 기준",
"- ② 관급자재는 묘목 재료비 미반영, 구입이 필요한 경우 별도 반영"
],
"master_only": []
}
},
"form": {
"F0002": {
"section": "1-2-2",
"master": "reference",
"md_mark": "직공인부"
},
"F0007": {
"section": "1-2-7",
"master": "reference",
"md_mark": "㎥"
},
"F0038": {
"section": "1-4-26",
"master": "reference",
"md_mark": "적용시공량"
},
"F0257": {
"section": "9-11-1",
"master": "coefficient",
"md_mark": ""
},
"F0325": {
"section": "11-1",
"master": "reference",
"md_mark": "비계공"
},
"F0338": {
"section": "12-6",
"master": "reference",
"md_mark": "배치인원(인)"
},
"F0380": {
"section": "12-30-1",
"master": "reference",
"md_mark": "특별인부"
},
"F0381": {
"section": "12-30-2",
"master": "reference",
"md_mark": "특별인부"
},
"F0391": {
"section": "12-37",
"master": "reference",
"md_mark": "콘크리트공"
},
"F0393": {
"section": "12-38-2",
"master": "reference",
"md_mark": "매"
},
"F0454": {
"section": "14-2",
"master": "reference",
"md_mark": "작업량"
}
}
}
@@ -0,0 +1,372 @@
"""원문 md ↔ 공종 마스터 맞대기 — 2026-09-17 브레인 일감(랩탑 메인).
안티그래비티가 손으로 하던 대조를 시험으로 옮김. 원문은
`(산림청고시 제2025-82호) 산림사업 표준품셈.md` 한 벌, 마스터는 `pum_forest_2026.json`(표 원본)과
`work_item_master_2026-01-01.json`(공종 축).
- **어긋남이 0 이어야 하는 것** — 표 수·차례 · 셀 값(글자까지). 하나라도 다르면 빨강.
- **알려진 어긋남이 있는 것** — 표 자리(부록 표가 공종에 붙음) · 밑수 · [주] · 표 성격. 지금 어긋난 것은
`fixtures/work_item_master_source_known.json` 에 적어 두고, **그 목록 밖**에서 새로 어긋나면 빨강.
⚠ 목록에 있는데 **고쳐졌거나 모습이 바뀐 것도 빨강** — 목록이 낡지 않게.
고친 뒤 목록 지우기: `python resources/tester/test_work_item_master_source_match.py --prune`
(지우기만 함 · 새 어긋남은 안 보탬 — 새 것은 원인을 보고 사람이 적을 것).
⚠ **빌더 코드를 안 씀** — 원문 읽기를 여기서 따로 함. 같은 코드로 재면 같은 틀림을 못 잡음.
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
MD = (
ROOT
/ "resources/knowledge/original/행정규칙/임도 품셈 적용기준 (현 산림사업 표준품셈)/첨부"
/ "(산림청고시 제2025-82호) 산림사업 표준품셈.md"
)
PUM = ROOT / "resources/data_cost_input_value/pum_forest_2026.json"
MASTER = ROOT / "resources/data_work_item_master/work_item_master_2026-01-01.json"
KNOWN = Path(__file__).resolve().parent / "fixtures/work_item_master_source_known.json"
#: 목차 표 — 공종 줄이 아니라 목차를 세우는 데 쓰임.
TOC_TABLE = "F0001"
#: 이 줄 뒤는 부록(할인·할증 공종표 · 적용 공종 · 단가산출서 예시) — 공종 품이 아니라 `orphan_tables` 몫.
APPENDIX = "〔부록 1"
# ── 원문 읽기 ────────────────────────────────────────────────────────────
_SEP = re.compile(r"\s*\|(\s*:?-+:?\s*\|)+\s*")
def _cells(line: str) -> list[str]:
s = line.strip()
s = s[1:-1] if s.endswith("|") else s[1:]
return [c.strip() for c in s.split("|")]
def md_tables(lines: list[str]) -> list[dict]:
"""`|` 표 = 머리 줄 + `|---|` 줄 + 몸 줄. 나온 차례대로 F0001… (줄 번호는 1부터)."""
tables: list[dict] = []
i = 0
while i < len(lines):
if (
lines[i].lstrip().startswith("|")
and i + 1 < len(lines)
and _SEP.fullmatch(lines[i + 1])
):
j = i + 2
while j < len(lines) and lines[j].lstrip().startswith("|"):
j += 1
tables.append(
{
"id": f"F{len(tables) + 1:04d}",
"line": i + 1,
"end": j, # 표 다음 줄의 0부터 번호
"headers": _cells(lines[i]),
"rows": [_cells(x) for x in lines[i + 2 : j]],
}
)
i = j
else:
i += 1
return tables
#: 밑수가 될 수 있는 세는 단위. 「회」「%」「분」은 밑수가 아님(2026-09-17 데스크탑 서브 원문 확인).
_UNIT = "㎥|m3|㎡|m2|㏊|ha|㎞|km|m|매|본|개소|개|인|공㎥|공|kg|㎏|톤|ton|주|식|대|일|기|조|롤|RM|시간|hr"
_SAME = {"": "ha", "": "km", "m3": "", "m2": "", "": "kg", "ton": ""}
_ONE = re.compile(rf"^([\d.]*)\s*({_UNIT})$")
_PAREN_END = re.compile(r"[(]([^()()]*)[)]+\s*$")
_PAREN_ANY = re.compile(r"[(]([^()()]*)[)]")
def parse_basis(text: str) -> tuple[float, str] | str | None:
"""괄호 속 글 하나 → `(수, 단위)` · 분모가 둘이면 `""` · 밑수 글이 아니면 `None`.
- `인/100㎡` · `단위 : 인/ha` → 분모가 밑수 · `100본당` · `일 당` → 「당」 앞이 밑수
- `ha당 소요량` · `1km 소요량기준` → 「소요량」 앞이 밑수
- ⚠ 「인」「공」은 숫자가 붙을 때만(`(단위 : 인)` 은 품의 단위 — 2026-09-09 원문 대조)
"""
t = re.sub(r"^\s*단\s*위\s*[:]?\s*", "", text).strip()
t = re.sub(r"(\d),(\d{3})", r"\1\2", t)
t = re.sub(r"\s*당?\s*소요량\s*(기준)?$", "", t)
if "/" in t:
denominators = t.split("/")[1:]
den = re.sub(r"\s*당$", "", denominators[-1]).strip()
if len(denominators) > 1 or re.search(r"[,,·\s]", den):
return ""
elif t.endswith(""):
den = t[:-1].strip()
else:
return None
m = _ONE.match(den)
if m is None:
return None
raw, unit = m.group(1), m.group(2)
if unit in ("", "") and not raw:
return None
try:
return (float(raw) if raw else 1.0), _SAME.get(unit, unit)
except ValueError:
return None
def md_basis(lines: list[str], table: dict) -> tuple[tuple[float, str] | None, str]:
"""`(밑수, 근거 글)`. 표 바로 위 본문 → 표머리 차례(본문이 정본). 못 찾으면 `(None, "")`.
본문은 가까운 줄부터 올라가며 줄 끝 괄호를 봄 — 앞 표·`[주]` 를 만나면 멈추고 제목 줄은 보고 멈춤.
표머리는 칸 속 괄호와 칸 전체를 봄 — 서로 다른 밑수가 둘 이상이면 못 정함.
"""
for k in range(table["line"] - 2, -1, -1):
s = lines[k].strip()
if s.startswith("|") or s.startswith("[주"):
break
if (m := _PAREN_END.search(s)) and isinstance(hit := parse_basis(m.group(1)), tuple):
return hit, s
if s.startswith("#"):
break
found: dict[tuple[float, str], str] = {}
for head in table["headers"]:
for text in [m.group(1) for m in _PAREN_ANY.finditer(head)] + [head]:
if isinstance(hit := parse_basis(text), tuple):
found.setdefault(hit, head)
if len(found) == 1:
((hit, head),) = found.items()
return hit, f"표머리 {head}"
return None, ""
_NOTE_CONT = re.compile(r"^(-\s*[①-⑳]|-\s*-|[※*·])")
def md_notes(lines: list[str], table: dict) -> list[str]:
"""표 바로 아래 `[주]` 덩어리. 첫 글줄이 `[주` 가 아니면 없음.
이어지는 줄 = `- ②` 꼴 · `- -` 꼴 · `※`·`*`·`·` 로 시작하는 줄. 빈 줄은 건너뜀. 그 밖의 줄에서 끝.
⚠ `- 2. 소제목` 같은 본문 목록은 [주]가 아님.
"""
out: list[str] = []
for k in range(table["end"], len(lines)):
s = lines[k].strip()
if not s:
continue
if not out and s.startswith("[주"):
out.append(s)
elif out and _NOTE_CONT.match(s):
out.append(s)
else:
break
return out
_WORKER = re.compile(
r"\s*부|기능공|운전|목\s*공|석\s*공|철근공|콘크리트공|기\s*사|벌목부|작업반장|기술자|"
r"용접공|미장공|방수공|배관공|비계공"
)
_WORK_MARK = re.compile(
r"\(인\)|소요인력|소요량|주입량|작업능력|작업량|수집량|집재량|공정량|시공량|인/|/인|\(시간\)|\(hr\)"
)
_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,.]+)*$")
def md_work_mark(table: dict) -> str | None:
"""품 표 표지 칸 — 있으면 그 칸 글, 없으면 `None`.
① 표 어딘가에 **짧은 칸(20자 이하)** 으로 직종 이름·품 낱말(`소요인력`·`(인)`·`작업능력` …)이 있고
숫자 칸도 있음 · ② 한 줄에 수량 단위 칸(`인`·`매`·`㎥` …)과 숫자 칸이 함께 있음.
긴 설명 글 속 「보통인부」는 이름이 아님(손료계수표 적용기준 칸).
ponytail: 품 표 317 중 276 만 알아봄 — 값이 범위(`3.3∼5.9`)거나 머리가 여러 줄로 갈린 표는 못 봄.
"""
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
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
return None
# ── 맞대기 ──────────────────────────────────────────────────────────────
def _norm(text: str) -> str:
return " ".join(str(text).split())
@pytest.fixture(scope="module")
def src() -> dict:
return load_sources()
def load_sources() -> dict:
lines = MD.read_text(encoding="utf-8").splitlines()
pum = json.loads(PUM.read_text(encoding="utf-8"))["variables"]["pum"]["tables"]
master = json.loads(MASTER.read_text(encoding="utf-8"))
entries = [(w, t) for w in master["work_items"] for t in w.get("tables", [])]
return {
"lines": lines,
"tables": md_tables(lines),
"pum": pum,
"master": master,
"entries": entries,
"by_id": {t["pum_table_id"]: (w, t) for w, t in entries},
}
def current_mismatches(src: dict) -> dict[str, dict]:
"""표 자리·밑수·[주]·표 성격 어긋남 — 알려진 목록과 같은 꼴. 밑수·[주]·성격은 공종에 붙은 표만 봄."""
lines = src["lines"]
out: dict[str, dict] = {"place": {}, "basis": {}, "notes": {}, "form": {}}
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"]:
in_appendix = table["line"] > appendix
if table["id"] in orphans and not in_appendix:
out["place"][table["id"]] = {
"section": None,
"md": "본문 표",
"master": "공종에 안 붙음",
}
if table["id"] not in src["by_id"]:
continue
if in_appendix:
out["place"][table["id"]] = {
"section": src["by_id"][table["id"]][0]["number"],
"md": "부록 표",
"master": "공종에 붙음",
}
work, entry = src["by_id"][table["id"]]
section = work["number"]
md_hit, evidence = md_basis(lines, table)
md_value = list(md_hit) if md_hit else None
unit = entry["basis_unit"]
master_value = [entry["basis_quantity"], _SAME.get(unit, unit)] if unit else None
if md_value != master_value:
out["basis"][table["id"]] = {
"section": section,
"md": md_value,
"md_evidence": evidence,
"master": master_value,
}
md_note = md_notes(lines, table)
if md_note != entry["notes"]:
out["notes"][table["id"]] = {
"section": section,
"md_only": [x for x in md_note if x not in entry["notes"]],
"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"]] = {
"section": section,
"master": entry["pum_form"],
"md_mark": mark,
}
return out
def load_known() -> dict[str, dict]:
return json.loads(KNOWN.read_text(encoding="utf-8"))
# ── 시험 ────────────────────────────────────────────────────────────────
def test_표_수와_자리(src: dict) -> None:
"""원문 표가 원본(`pum_forest`)에 같은 차례·같은 줄로 있고, 마스터에 빠짐없이 한 번씩 있음."""
md_ids = [(t["id"], t["line"]) for t in src["tables"]]
pum_ids = [(t["table_id"], t["line"]) for t in src["pum"]]
assert len(md_ids) == len(pum_ids), f"표 수 — 원문 {len(md_ids)} · 원본 {len(pum_ids)}"
moved = [(a, b) for a, b in zip(md_ids, pum_ids) if a != b]
assert not moved, f"표 자리 어긋남 {len(moved)}건 (원문, 원본) — 앞 5: {moved[:5]}"
attached = [t["pum_table_id"] for _, t in src["entries"]]
doubled = sorted({x for x in attached if attached.count(x) > 1})
assert not doubled, f"두 공종에 붙은 표: {doubled}"
orphans = [o["pum_table_id"] for o in src["master"]["orphan_tables"]]
all_ids = {i for i, _ in md_ids}
missing = sorted(all_ids - set(attached) - set(orphans) - {TOC_TABLE})
assert not missing, f"마스터에 없는 원문 표: {missing}"
extra = sorted((set(attached) | set(orphans)) - all_ids)
assert not extra, f"원문에 없는 마스터 표: {extra}"
def test_셀_값이_글자까지_같음(src: dict) -> None:
"""표머리·줄 칸을 글자까지 맞댐 — 원문 ↔ 원본(`headers`·`rows`) ↔ 마스터(`condition_note`·`raw_row`)."""
diffs: list[str] = []
def compare(tid: str, line: int, what: str, md_rows: list, other: list) -> None:
if len(md_rows) != len(other):
diffs.append(f"{tid} L{line} {what} 줄 수 — 원문 {len(md_rows)} · {len(other)}")
for r, (a, b) in enumerate(zip(md_rows, other)):
for c in range(max(len(a), len(b))):
va = a[c] if c < len(a) else "(없음)"
vb = b[c] if c < len(b) else "(없음)"
if va != vb:
diffs.append(
f"{tid} L{line + 2 + r} {what} {r + 1}{c + 1}열 — 원문 {va!r} · {vb!r}"
)
pum = {t["table_id"]: t for t in src["pum"]}
for table in src["tables"]:
tid, line = table["id"], table["line"]
p = pum.get(tid)
if p is None:
continue # 표 수 시험이 잡음
if table["headers"] != p["headers"]:
diffs.append(f"{tid} L{line} 원본 표머리 — 원문 {table['headers']} · {p['headers']}")
compare(tid, line, "원본", table["rows"], p["rows"])
if tid in src["by_id"]:
_, entry = src["by_id"][tid]
if entry["source_line"] != line:
diffs.append(
f"{tid} 마스터 줄 번호 — 원문 L{line} · 마스터 L{entry['source_line']}"
)
heads = [_norm(h) for h in table["headers"] if _norm(h)]
if heads != entry["condition_note"]:
diffs.append(
f"{tid} L{line} 마스터 표머리 — 원문 {heads} · {entry['condition_note']}"
)
compare(tid, line, "마스터", table["rows"], entry["raw_row"])
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:
"""표 자리·밑수·[주]·표 성격 — 알려진 목록과 똑같아야 함. 새로 어긋남 · 모습 바뀜 · 고쳐짐 셋 다 빨강."""
now = current_mismatches(src)[kind]
known = load_known()[kind]
lines: list[str] = []
for tid in sorted(set(now) - set(known)):
lines.append(f" 새로 어긋남 {tid}: {json.dumps(now[tid], ensure_ascii=False)}")
for tid in sorted(set(now) & set(known)):
if now[tid] != known[tid]:
lines.append(
f" 모습 바뀜 {tid}: 목록 {json.dumps(known[tid], ensure_ascii=False)}"
f"\n 지금 {json.dumps(now[tid], ensure_ascii=False)}"
)
for tid in sorted(set(known) - set(now)):
lines.append(f" 고쳐짐 {tid} — 목록에서 지울 것(--prune)")
assert not lines, f"{KIND_NAMES[kind]} {len(lines)}건 — 원문 ↔ 마스터:\n" + "\n".join(lines)
if __name__ == "__main__" and "--prune" in sys.argv:
# 고쳐졌거나 모습이 바뀐 줄만 지움 — 새 어긋남은 안 보탬(보태면 빨강이 조용히 묻힘).
current = current_mismatches(load_sources())
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}
print(f"{KIND_NAMES[kind]}: {before}{len(data[kind])}")
KNOWN.write_text(json.dumps(data, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")