From 71c5dc2583580b5693f4ff5636bd29e349d8a0fd Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 17 Sep 2026 23:51:14 +0900 Subject: [PATCH] =?UTF-8?q?test(b08):=20=EC=9B=90=EB=AC=B8=20md=20?= =?UTF-8?q?=E2=86=94=20=EB=A7=88=EC=8A=A4=ED=84=B0=20=EB=A7=9E=EB=8C=80?= =?UTF-8?q?=EA=B8=B0=20=EC=8B=9C=ED=97=98=20=E2=80=94=20=EC=95=88=ED=8B=B0?= =?UTF-8?q?=EA=B7=B8=EB=9E=98=EB=B9=84=ED=8B=B0=20=EC=86=90=20=EB=8C=80?= =?UTF-8?q?=EC=A1=B0=EB=A5=BC=20=EC=9E=90=EB=8F=99=EC=9C=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 브레인 일감. 원문 `(산림청고시 제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) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- .../work_item_master_source_known.json | 756 ++++++++++++++++++ .../test_work_item_master_source_match.py | 372 +++++++++ 2 files changed, 1128 insertions(+) create mode 100644 resources/tester/fixtures/work_item_master_source_known.json create mode 100644 resources/tester/test_work_item_master_source_match.py diff --git a/resources/tester/fixtures/work_item_master_source_known.json b/resources/tester/fixtures/work_item_master_source_known.json new file mode 100644 index 00000000..fbac5e79 --- /dev/null +++ b/resources/tester/fixtures/work_item_master_source_known.json @@ -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본 이하(잔존목간 거리 10~8.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": "작업량" + } + } +} diff --git a/resources/tester/test_work_item_master_source_match.py b/resources/tester/test_work_item_master_source_match.py new file mode 100644 index 00000000..b8c521e0 --- /dev/null +++ b/resources/tester/test_work_item_master_source_match.py @@ -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")