chore(조사): 338 외부 일위대가 맞춤 설계 — 연결된 단가산출 블록 → 우리 로직 + 옵션 → 일위대가 구성 · 창 셋 · 시험 · 기준 수치 + 실험 자료 사본

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
This commit is contained in:
2026-10-15 23:08:43 +09:00
co-authored by Claude Opus 5.5
parent 8a4598c051
commit 7a8f334520
93 changed files with 46854 additions and 0 deletions
@@ -0,0 +1,67 @@
"""실패 까닭 갈래 — 1순위(엄격) 실패 줄마다 주 갈래 하나(첫 칸) + 곁 갈래.
이름만(씨앗 끔) 실패 · 지금 실패를 따로 봄(같은 줄이라도 까닭이 다르면 둘째 표에서 고침)."""
A = "같은 뜻 다른 말"
B = "줄임말"
C = "낱말 차례"
D = "괄호 속 규격"
E = "비율 표기(인력100%·9:1)"
F = "단위 표기(hr·공/㎥·개소)"
G = "장비 · 노임 이름 줄"
H = "책 갈래(산림↔건설)"
I = "우리 로직이 더 잘게/굵게"
J = "엑셀 복합 호표"
K = "근거 조항 비어 있음 · 엉뚱"
LL = "품셈 판 차이(번호 바뀜)"
M = "그 밖(오타 · 비슷한 이름 함정 · 접힘)"
ORDER = [A, B, C, D, E, F, G, H, I, J, K, LL, M]
NAME = { # 이름만(씨앗 끔) 실패 까닭
"N000": [B, J, H], "N013": [B, J], "N024": [B, J], "N030": [B, J], "N043": [B, J],
"S052": [B, J], "S053": [B, J], "S069": [B, J], "S075": [B, J],
"N001": [H], "N025": [H], "N014": [H], "N015": [H], "N032": [H], "N044": [H, F], "N046": [H, F],
"S056": [H], "S057": [H, F], "S058": [H, D], "S054": [H, D], "S064": [H], "S070": [H], "S086": [H],
"N007": [H, A], "N027": [H, A], "N028": [H, A, C], "N029": [H, A, C], "N031": [H, A],
"S098": [H, A, C], "S099": [H, A, C], "N038": [H, A], "N040": [H, A], "N037": [H, M],
"S084": [H, B], "Y001": [H], "Y009": [H], "Y021": [H], "Y029": [H],
"N003": [I, C, M], "N019": [I, C, M], "N045": [I, C, M], "S055": [I, C, M], "S059": [I, C, M],
"Y010": [I, C, M],
"N005": [A, J, K, M], "N012": [A, J, K, M], "N036": [A, J, K, M], "S081": [A, J, K], "S091": [A, J, K],
"Y015": [A, J],
"N006": [A, LL], "Y024": [A, LL], "Y025": [A, LL],
"N008": [A, E], "S076": [A, H], "N018": [A], "N022": [A, J], "S092": [A, J], "S096": [A],
"N039": [A, J], "S061": [A, K], "S072": [A, M, F], "S083": [A, M, F], "Y016": [A, J],
"Y017": [A, M], "Y023": [A, B], "Y027": [A, M], "Y032": [A], "Y033": [A, J],
"N023": [D], "N042": [D], "S066": [D, J], "S082": [D], "N050": [J, D],
"N035": [M], "N047": [M], "S067": [M], "Y000": [M], "Y020": [M], "Y028": [M],
"Y008": [LL, H],
"S085": [G, F], "Y005": [G, F],
}
NOW = { # 지금(330 뒤) 실패 까닭
"N001": [H], "N025": [H], "N015": [H], "S086": [H], "N032": [H], "N044": [H, F], "S057": [H, F],
"N046": [H, F], "S056": [H], "N037": [H, M], "Y029": [H, M],
"N003": [I, LL], "N019": [I, LL], "N045": [I, LL], "S055": [I, LL], "S059": [I, LL],
"Y010": [M, I],
"N005": [A, K, J], "N012": [A, K, J], "N036": [A, K, J], "S081": [A, K, J], "S091": [A, K, J],
"Y015": [A, J],
"N006": [A, LL], "Y024": [A, LL], "Y025": [A, LL], "S061": [A, K], "Y027": [A, M],
"S062": [J, LL], "S066": [D, J], "S085": [G, F], "Y005": [G, F],
"Y009": [M], "Y021": [M], "Y020": [M], "Y028": [M],
}
NOTE = { # 표 예시 글(갈래별 셋)
A: ["휠라설치 → 신축이음 채움재 설치", "구조물터파기(인력100%) → 굴착(인력/토사)", "고임돌 · 조약돌 채집 → 모래·자갈·약돌 채집(1위 궤도 자갈채집)"],
B: ["레미콘타설(장비사용) → 레디믹스트콘크리트 타설(1위 펌프카 타설)", "배수관부설및접합(P.V.C) → PVC관 고무링 접합 및 부설", "B/H 90%(백호 = 굴착기)"],
C: ["철근가공조립(현장) ↔ 철근 현장가공 및 조립", "큰돌찰붙임 ↔ 큰돌붙이기 찰붙이기", "(곁 갈래만 · 주 까닭 아님)"],
D: ["큰돌쌓기(찰쌓기) → 1위 메쌓기(괄호 「찰쌓기」 버림)", "구조물터파기(토사) → 1위 터파기(기계) 암", "구조물터파기(암) → 1위 육상토사"],
E: ["구조물터파기(인력100%)", "고임돌채집및운반(9:1)", "(곁 갈래만 — 비율 글은 지금도 낱말로 안 셈)"],
F: ["대형브레이카 hr ↔ 시간", "강관동바리 공/㎥ ↔ 공㎥", "도로반사경 개소 ↔ 본(환산 「입력」)"],
G: ["콘크리트 펌프차 41m [hr] → 기계 시간당 기계경비(기계 입력)", "크레인(타이어) 10톤 [시간]", "철골공 · 특별인부 [인] = 노임(우리에 없음)"],
H: ["합판거푸집 → 같음 산림 12-4(엑셀은 건설 6-3-1)", "강관비계 · 강관동바리 → 같음 산림(엑셀 건설 2-7-1 · 2-6-1)", "큰돌메붙임 → 1위 건설 메붙임(굴착기+집게)(엑셀 산림 13-7-1)"],
I: ["철근가공조립(현장) = 우리 현장가공 + 현장조립 둘", "잔토처리(인력) = 우리 굴착(인력) 입력 「잔토처리」에 묶임", "원형돌망태 조립설치 · 채움 = 우리 원형 돌망태 하나"],
J: ["레미콘타설 + 굴착기 시간", "고임돌 = 채집 + 상차 + 운반", "도장(철재면2회) = 바탕 + 녹막이 + 유성 셋"],
K: ["철골설치(출처 없음)", "조약돌채집운반(9:1)(출처 없음)", "고임돌 「1-10」 = 단위중량 인용(작업 조항 아님)"],
LL: ["철근 「건설 6-2」 = 옛 한 조항 → 지금 6-2-2 · 6-2-3", "야면석채집 「건설 8-2 (2016 삭제)」", "영월 「건설 3-1-3 · 9-3 · 1-9-3 · 임도품셈 5-5」 옛 번호"],
M: ["흙깍기(오타 깍/깎) → 후보 없음", "원형돌망태 → 1위 타원형(원형 ⊂ 타원형)", "유로폼 설치,해체 → 같은 이름 산림 · 건설 둘이 이름 후보에서 하나로 접힘"],
}
@@ -0,0 +1,461 @@
"""정답 표시 — 고유 로직 줄 100(북부청 임도 N000~N051 · 사방 S052~S099).
정답 = 엑셀 단가산출 블록의 출처 조항 · 직종 · 품 · 계산식이 같은 우리 로직(복합이면 주된 몫 · 여럿 가능).
허용 = 같은 일의 다른 책(산림 ↔ 건설) 로직 · 복합의 곁 몫(느슨 지표에서만 맞음).
부분 = 「우리에 없음」 줄의 가까운 조각(지표에 안 셈 · 참고).
짐작 = 블록에 출처가 없거나 우리 로직이 일부만 덮어 사람 판단이 섞임.
갈래 = 이름이 엇나간 까닭(실패 갈래 표에 씀).
"""
CONCRETE = {
"정답": ["GC000198"],
"허용": ["GF000155"],
"근거": "건설 6-1-1 콘크리트공 3 · 보통인부 1 ÷ 일 시공량(+소량 ×2) + 굴착기 시간 — GC000198 6-1-1(타설방법 · 소량감률 · 굴착기 입력)",
"갈래": ["같은 뜻 다른 말", "엑셀 복합"],
}
REBAR = {
"정답": ["GC000206", "GC000207"],
"허용": ["GF000159"],
"근거": "건설 6-2 Type 가공(철근공 0.67 · 보통 0.22) + 조립 — 우리 6-2-2 가공 · 6-2-3 조립 둘(복합) · 산림 12-3 GF000159 같은 일 · 이름 1위 GF000187 은 산림 12-18 복잡 고정 4.2인(다른 품)",
"갈래": ["우리 로직이 더 잘게", "엑셀 조항이 윗마디", "비슷한 이름 엉뚱"],
}
PLY = {
"정답": ["GC000211"],
"허용": ["GF000160"],
"근거": "건설 6-3-1 형틀목공 5 · 보통인부 2 ÷ 일 시공량 = GC000211 6-3-1 · 이름 같음 GF000160 은 산림 12-4",
"갈래": ["같은 이름 다른 책"],
}
FORM_PATTERN = {
"정답": ["GC000215"],
"허용": ["GF000161"],
"근거": "건설 6-3-4 형틀목공 0.07 · 보통 0.03 = GC000215 · 이름 같음 GF000161 은 산림 12-5",
"갈래": ["같은 이름 다른 책"],
}
EURO = {
"정답": ["GC000214"],
"허용": ["GF000210"],
"근거": "건설 6-3-3 형틀목공 4 · 보통 1 ÷ 35~40 = GC000214",
"갈래": [],
}
WEDGE = {
"정답": ["GF000211"],
"허용": ["GF000212"],
"근거": "채집(기계 90 · 인력 10% 보통인부 0.3인/㎥ = 산림 13-2-1 막자갈) + 상차 + 운반 — GF000211 은 인력 채집 몫만 · 출처 1-10 은 단위중량 인용 · 1위 GC000363 은 궤도 자갈(토목 4-6-1)",
"짐작": True,
"갈래": ["같은 뜻 다른 말", "엑셀 복합", "근거 조항 엉뚱", "비슷한 이름 엉뚱"],
}
PEBBLE = {
**WEDGE,
"허용": [],
"근거": "채집 인력 10%(보통인부 0.3 · 0.6인/㎥ = 산림 13-2-1 막자갈 · 조약돌) + 상차 + 운반 — GF000211(부분) · 1위 GC000363 궤도 자갈",
}
FIELDSTONE = {
"정답": ["GF000214"],
"허용": [],
"근거": "인력 10% 보통인부 0.17인/㎡(= 산림 13-2-4 뒷길이 35㎝) + 굴착기 채집 + 운반 — GF000214 은 인력 채집 몫(부분) · 건설 8-2 는 2016 삭제",
"짐작": True,
"갈래": ["엑셀 복합", "품셈 판 번호 바뀜"],
}
SCAFFOLD = {
"정답": ["GC000022"],
"허용": ["GF000188"],
"근거": "건설 2-7-1 비계공 3 · 보통 1 ÷ 55㎡ = GC000022 강관비계(쌍줄) · 이름 같음 GF000188 은 산림 12-19(공㎥)",
"갈래": ["같은 이름 다른 책", "단위 다름"],
}
SHORING = {
"정답": ["GC000015"],
"허용": ["GF000189"],
"근거": "건설 2-6-1 형틀목공 3 · 보통 1 ÷ 50공㎥ = GC000015 강관 동바리(토목) · 이름 같음 GF000189 은 산림 12-20",
"갈래": ["같은 이름 다른 책"],
}
PAVE = {
"정답": ["GC000297"],
"허용": ["GF000162"],
"근거": "건설 1-6-2 포장공 4 · 보통 2 ÷ 100㎥(15% 이상 ×1.2) = GC000297 콘크리트 표층 인력포설 · 산림 12-6 GF000162(종단경사 입력) 같은 일",
"갈래": ["같은 뜻 다른 말"],
}
SOIL_LEFT_HAND = {
"정답": [],
"허용": [],
"부분": ["GC000083"],
"근거": "보통인부 0.2인/㎥ = 건설 3-2-1 비고 잔토처리 — 우리 단독 로직 없음(GC000083 입력 「잔토처리」로 굴착과 묶임)",
"갈래": ["우리 로직이 더 굵게"],
}
LABOR_ONLY = {
"정답": [],
"허용": [],
"근거": "노임 한 줄(인 단위) — 로직 아님(인력 단가)",
"갈래": ["노임 줄"],
}
BIG_STONE = {
"정답": ["GF000213"],
"허용": [],
"근거": "산림 13-2-3 작업반장 0.026인/개 × 4개/㎡ × 75% × 50 · 40% + 굴착기 = GF000213 큰돌 채집(기계)(개 ↔ ㎡)",
"갈래": ["단위 다름"],
}
BACKFILL_STONE = {
"정답": ["GC000106"],
"허용": ["GF000218"],
"근거": "채집(9:1) · 운반 + 뒷채움 및 다짐 건설 3-4-4(소형장비) = GC000106(부분)",
"짐작": True,
"갈래": ["엑셀 복합", "같은 뜻 다른 말"],
}
BASE_STONE = {
"정답": ["GC000110", "GF000168"],
"허용": [],
"근거": "채집 · 적사 · 운반 + 기초지정 건설 3-4-8 = GC000110(부분) · 산림 12-12(옛 번호) → 지금 12-25 기초잡석 GF000168",
"짐작": True,
"갈래": ["엑셀 복합", "품셈 판 번호 바뀜"],
}
DRAIN = {
"정답": ["GC000404"],
"허용": ["GF000176", "GF000179"],
"근거": "건설 6-7-2 파형강관 배관공(수도) · 보통 ÷ 6 = GC000404 파형강관 부설 및 접합(본 ↔ M) · 산림 12-11-3 GF000176",
"갈래": ["같은 뜻 다른 말", "단위 다름"],
}
DUMP = {
"정답": ["GF000135", "GF000284"],
"허용": [],
"근거": "적사(굴착기 0.8) + 덤프 15t 운반 18.7㎞(출처 없음) = 산림 10-3-2 골재운반 잡석 · 10-12-1 덤프운반",
"짐작": True,
"갈래": ["근거 조항 비어 있음"],
}
L = {
"N000": CONCRETE,
"N001": PLY,
"N002": BASE_STONE,
"N003": REBAR,
"N004": FIELDSTONE,
"N005": WEDGE,
"N006": {
"정답": ["GF000280"],
"허용": ["GF000279"],
"근거": "건설 1-2-7 인력운반 공식(575㎏/㎡ ÷ 25㎏ ÷ 42회) — 우리 건설 1-2-7 없음 · 산림 10-6-2 인력운반 돌(지게) 같은 일(㎡ ↔ ㎥)",
"짐작": True,
"갈래": ["같은 뜻 다른 말", "우리에 그 책 조항 없음"],
},
"N007": {
"정답": ["GF000225"],
"허용": ["GC000263"],
"근거": "산림 13-5-2 석공 0.11 · 보통 0.04 + 굴착기 집게 = GF000225 돌붙임 찰붙임(장비)",
"갈래": ["같은 뜻 다른 말"],
},
"N008": {
"정답": ["GC000083"],
"허용": ["GF000101"],
"근거": "건설 3-2-1 특별인부 (3.6+2.7+2.2)/3 = GC000083 굴착(인력/토사)",
"갈래": ["같은 뜻 다른 말"],
},
"N009": {
"정답": ["GF000113"],
"허용": [],
"근거": "산림 9-14 인력품 보통인부 0.1인 = GF000113 되메우기(9-14-1)",
"갈래": ["엑셀 조항이 윗마디"],
},
"N010": SOIL_LEFT_HAND,
"N011": FIELDSTONE,
"N012": WEDGE,
"N013": CONCRETE,
"N014": EURO,
"N015": FORM_PATTERN,
"N016": {
"정답": ["GF000192"],
"허용": [],
"근거": "산림 12-22 재료비 + 설치비 5% = GF000192 P.V.C 파이프 설치(개소 ↔ M)",
"갈래": ["단위 다름"],
},
"N017": {
"정답": ["GC000226"],
"허용": ["GF000201"],
"근거": "건설 6-3-10 다웰바 형틀목공 0.043 = GC000226 · 산림 12-30-1 GF000201(D32 · L1000) 같은 일",
"갈래": [],
},
"N018": {
"정답": ["GC000227"],
"허용": [],
"근거": "건설 6-3-10 휠라(채움재) 형틀목공 0.029 = GC000227 신축이음 채움재 설치",
"갈래": ["같은 뜻 다른 말"],
},
"N019": REBAR,
"N020": BIG_STONE,
"N021": {
"정답": ["GF000226"],
"허용": ["GC000260"],
"근거": "산림 13-6-1 = GF000226 큰돌쌓기 메쌓기",
"갈래": [],
},
"N022": BACKFILL_STONE,
"N023": {
"정답": ["GF000227"],
"허용": [],
"근거": "산림 13-6-2 = GF000227 큰돌쌓기 찰쌓기",
"갈래": [],
},
"N024": CONCRETE,
"N025": PLY,
"N026": {
"정답": ["GC000310"],
"허용": [],
"근거": "건설 1-8-3 = GC000310 도로반사경 설치(본 ↔ 개소)",
"갈래": ["단위 다름"],
},
"N027": {
"정답": ["GF000224"],
"허용": ["GC000262"],
"근거": "산림 13-5-2 석공 0.13 · 보통 0.04 = GF000224 돌붙임 메붙임(장비)",
"갈래": ["같은 뜻 다른 말"],
},
"N028": {
"정답": ["GF000229"],
"허용": ["GC000262"],
"근거": "산림 13-7-1 = GF000229 큰돌붙이기 메붙이기",
"갈래": ["같은 뜻 다른 말"],
},
"N029": {
"정답": ["GF000230"],
"허용": ["GC000263"],
"근거": "산림 13-7-2 = GF000230 큰돌붙이기 찰붙이기",
"갈래": ["같은 뜻 다른 말"],
},
"N030": CONCRETE,
"N031": {
"정답": ["GF000222"],
"허용": ["GC000261"],
"근거": "산림 13-4-5 석공 0.09 · 보통 0.05 = GF000222 돌쌓기 찰쌓기(장비)",
"갈래": ["같은 뜻 다른 말"],
},
"N032": {
"정답": ["GC000111"],
"허용": ["GF000124"],
"근거": "건설 3-5-1 절토면 고르기 굴착기 1.0 = GC000111 · 씨앗 같음 GF000124 은 산림 9-19-1",
"갈래": ["같은 이름 다른 책"],
},
"N033": {
"정답": ["GC000271"],
"허용": [],
"근거": "기계 hr 대형브레이카 + 굴착기 0.7 = GC000271",
"갈래": ["장비 이름"],
},
"N034": {"정답": ["GC000665"], "허용": [], "근거": "건설 8-1-3 = GC000665", "갈래": []},
"N035": {
"정답": ["GF000328"],
"허용": [],
"근거": "산림 13-11-1 조립 0.08 · 돌채움 0.17 = GF000328 원형 돌망태",
"갈래": [],
},
"N036": PEBBLE,
"N037": {
"정답": ["GF000235"],
"허용": ["GC000342"],
"근거": "산림 13-114(= 13-11-4 오타) 사각돌망태 = GF000235 · 이름 같음 GC000342 은 건설 토목 2-2-3",
"갈래": ["같은 이름 다른 책", "근거 조항 엉뚱"],
},
"N038": PAVE,
"N039": {
"정답": ["GC000143"],
"허용": ["GF000032"],
"근거": "건설 4-1-1 조경공 1 · 보통 4 ÷ 150 + 떼꽂이(산림 5-23-3) = GC000143 잔디붙임(주)",
"갈래": ["같은 뜻 다른 말", "엑셀 복합"],
},
"N040": PAVE,
"N041": {
"정답": [],
"허용": [],
"부분": ["GF000124", "GF000126"],
"근거": "고화매트120 설치(건설품셈 p.266 · 우리 없음) + 토사면 고르기(산림 9-19-1) + 크레인 · 살수",
"갈래": ["우리에 없음(새 공종)"],
},
"N042": {
"정답": ["GF000227"],
"허용": [],
"근거": "산림 13-6-2 = GF000227",
"갈래": [],
},
"N043": CONCRETE,
"N044": SHORING,
"N045": REBAR,
"N046": SCAFFOLD,
"N047": {
"정답": ["GC000085"],
"허용": ["GF000089"],
"근거": "건설 3-2-3 굴착기 1.0 310㎥/일 = GC000085 흙깎기(기계) 보통토사",
"갈래": ["오타"],
},
"N048": {
"정답": [],
"허용": [],
"부분": ["GC000267"],
"근거": "굴착기 0.6 사이클 공식만(출처 없음) — 우리 「잔토정리」 로직 없음",
"짐작": True,
"갈래": ["우리에 없음(새 공종)"],
},
"N049": {
"정답": ["GC000676"],
"허용": [],
"근거": "건설(건축) 8-3-1 = GC000676 잡철물 제작 및 설치",
"갈래": [],
},
"N050": {
"정답": ["GC000705", "GC000714", "GC000712"],
"허용": [],
"근거": "건설(건축) 11-1-3 바탕 + 11-2-6 녹막이 + 11-2-4 유성 — 우리 셋(복합)",
"갈래": ["엑셀 복합"],
},
"N051": {
"정답": ["GC000308"],
"허용": [],
"근거": "건설 1-8-1 지주 · 표지판 = GC000308 교통 안전표지판 설치",
"갈래": ["같은 뜻 다른 말"],
},
# ── 사방 ──
"S052": CONCRETE,
"S053": CONCRETE,
"S054": {**EURO, "갈래": ["괄호 속 영문"]},
"S055": REBAR,
"S056": SCAFFOLD,
"S057": SHORING,
"S058": EURO,
"S059": REBAR,
"S060": {
"정답": [],
"허용": [],
"부분": ["GF000283"],
"근거": "구역화물자동차 운임(왕복 150㎞ + 60㎞) — 우리 화물 운임 로직 없음(덤프 운반 GF000283 은 다른 셈)",
"짐작": True,
"갈래": ["우리에 없음(새 공종)"],
},
"S061": {
"정답": ["GC000563"],
"허용": ["GC000561"],
"근거": "크레인 10Ton 1hr/Ton 철골세우기 기계만(출처 없음) = GC000563 철골세우기 장비 손료(ton당)",
"짐작": True,
"갈래": ["근거 조항 비어 있음", "같은 뜻 다른 말"],
},
"S062": {
"정답": ["GC000559"],
"허용": ["GC000560"],
"근거": "건설(건축) 1-1 철골공 2.48 × 0.71(89%) + 1-1-3 용접 — GC000559 주 · GC000560 곁",
"갈래": ["엑셀 복합", "엑셀 조항이 윗마디"],
},
"S063": {
"정답": ["GC000676"],
"허용": [],
"근거": "건설 8-3-1 = GC000676",
"갈래": ["같은 뜻 다른 말"],
},
"S064": PLY,
"S065": {"정답": ["GC000665"], "허용": [], "근거": "건설 8-1-3 = GC000665", "갈래": []},
"S066": {
"정답": ["GC000089"],
"허용": ["GC000088"],
"근거": "인력 10% + 기계 90% 혼합토사 건설 3-2-4 = GC000089 터파기(기계) 혼합토사",
"갈래": ["괄호 속 규격", "엑셀 복합"],
},
"S067": {
"정답": ["GC000109"],
"허용": [],
"근거": "건설 3-4-7 대형장비 290㎥/일 = GC000109",
"갈래": [],
},
"S068": {
"정답": [],
"허용": [],
"근거": "굴착기 0.8 사이클 기계 100%(출처 없음) — 우리 잔토처리 로직 없음",
"짐작": True,
"갈래": ["우리에 없음(새 공종)"],
},
"S069": CONCRETE,
"S070": PLY,
"S071": BASE_STONE,
"S072": DRAIN,
"S073": LABOR_ONLY,
"S074": LABOR_ONLY,
"S075": CONCRETE,
"S076": {
"정답": ["GC000083"],
"허용": ["GF000101"],
"근거": "건설 3-2-1 특별인부 = GC000083",
"갈래": ["같은 뜻 다른 말"],
},
"S077": {
"정답": ["GF000113"],
"허용": [],
"근거": "보통인부 0.1인/㎥(출처 없음) = 산림 9-14-1 GF000113(임도 파일 단산12 와 같은 값)",
"짐작": True,
"갈래": ["근거 조항 비어 있음"],
},
"S078": SOIL_LEFT_HAND,
"S079": {
"정답": ["GF000328"],
"허용": [],
"근거": "산림 13-11-1 조립설치 = GF000328(시공 입력 · 조 ↔ ㎡)",
"갈래": ["단위 다름", "우리 로직이 더 굵게"],
},
"S080": {
"정답": ["GF000328"],
"허용": [],
"근거": "산림 13-11-1 돌채움 = GF000328",
"갈래": ["단위 다름", "우리 로직이 더 굵게"],
},
"S081": PEBBLE,
"S082": {"정답": ["GC000090"], "허용": [], "근거": "건설 3-2-4 암 = GC000090", "갈래": []},
"S083": DRAIN,
"S084": {
"정답": ["GC000201"],
"허용": ["GF000182"],
"근거": "건설 6-1-4 펌프차 편성 = GC000201",
"갈래": [],
},
"S085": {
"정답": ["GC000268", "GC000269", "GC000275"],
"허용": [],
"근거": "기계 hr 콘크리트펌프차 41m — 우리 「기계 시간당 기계경비」(기계 입력)",
"짐작": True,
"갈래": ["장비 이름"],
},
"S086": FORM_PATTERN,
"S087": {
"정답": ["GF000196"],
"허용": [],
"근거": "산림 12-27-1 = GF000196 지수판 설치",
"갈래": [],
},
"S088": {
"정답": ["GF000192"],
"허용": [],
"근거": "산림 12-22 = GF000192(개소 ↔ M)",
"갈래": ["단위 다름"],
},
"S089": {
"정답": ["GF000025"],
"허용": ["GF000032"],
"근거": "산림 5-12 보통인부 0.06 + 떼 소운반 = GF000025 떼붙임(재배잔디)",
"갈래": [],
},
"S090": {"정답": ["GF000226"], "허용": ["GC000260"], "근거": "산림 13-6-1", "갈래": []},
"S091": WEDGE,
"S092": BACKFILL_STONE,
"S093": {"정답": ["GF000227"], "허용": ["GC000261"], "근거": "산림 13-6-2", "갈래": []},
"S094": BIG_STONE,
"S095": DUMP,
"S096": {
"정답": ["GC000106"],
"허용": [],
"근거": "건설 3-4-4 소형장비 = GC000106",
"갈래": ["같은 뜻 다른 말"],
},
"S097": DUMP,
"S098": {
"정답": ["GF000229"],
"허용": ["GC000262"],
"근거": "산림 13-7-1",
"갈래": ["같은 뜻 다른 말"],
},
"S099": {
"정답": ["GF000230"],
"허용": ["GC000263"],
"근거": "산림 13-7-2",
"갈래": ["같은 뜻 다른 말"],
},
}
@@ -0,0 +1,43 @@
"""셋째 파일(동부청 영월 2024 · STmate 꼴) 정답 — 씨앗(일위대가조합.json 메모 = 사용자 실무 이름 → 로직)이
있는 줄은 사용자 선택을 정답으로 봄(「씨앗」 표시)."""
CON = {"정답": ["GC000198"], "허용": ["GF000155"], "근거": "6-1-1 콘크리트공 3 · 보통 ÷ 일 시공량", "갈래": ["같은 뜻 다른 말"]}
EURO = {"정답": ["GC000214"], "허용": ["GF000210"], "근거": "건설 6-3-3 유로폼 — 이름 같은 산림 GF000210 과 접힘(이름 후보 중복 빼기)", "갈래": ["같은 이름 다른 책"]}
LY = {
"Y000": {**CON, "근거": "건설 6-1-1 인력운반 23㎥/일 · 씨앗 같음"},
"Y001": {"정답": ["GC000211"], "허용": ["GF000160"], "근거": "건설 6-3-1 · 씨앗 같음", "갈래": []},
"Y002": {"정답": ["GF000101"], "허용": ["GC000083"], "근거": "옛 건설 3-1-3 인력 터파기 보통인부 0.26 · 씨앗 = GF000101", "짐작": True, "갈래": ["품셈 판 번호 바뀜"]},
"Y003": {"정답": ["GF000113"], "허용": [], "근거": "보통인부 0.1 · 씨앗 같음", "갈래": ["품셈 판 번호 바뀜"]},
"Y004": {"정답": [], "허용": [], "근거": "잔토처리 인력 0.2인 — 단독 로직 없음", "갈래": ["우리 로직이 더 굵게"]},
"Y005": {"정답": ["GC000268", "GC000269", "GC000275"], "허용": [], "근거": "기계 시간 크레인(타이어) 10톤 — 기계 시간당 기계경비(기계 입력)", "짐작": True, "갈래": ["장비 이름"]},
"Y006": {"정답": ["GF000101"], "허용": ["GC000089"], "근거": "임도품셈 5-1-2(옛) 기계 90 + 인력 10 · 씨앗 = GF000101", "짐작": True, "갈래": ["품셈 판 번호 바뀜"]},
"Y007": {"정답": [], "허용": [], "근거": "잔토처리 기계 100%(백호 0.7) — 로직 없음", "갈래": ["우리에 없음(새 공종)"]},
"Y008": {"정답": ["GC000198"], "허용": ["GF000155", "GF000166"], "근거": "임도품셈 5-5(옛) 콘크리트공 0.15 · 보통 0.27 · 씨앗 = GC000198", "짐작": True, "갈래": ["품셈 판 번호 바뀜"]},
"Y009": {**EURO, "근거": "건설 6-3-3 유로폼(복잡) — GC000214 · 이름 후보에서 산림 GF000210 과 한 줄로 접혀 근거 점수만 받음"},
"Y010": {"정답": ["GC000206", "GC000207"], "허용": ["GF000159"], "근거": "건설 6-2-3 · 6-2-2 가공 + 조립", "갈래": ["우리 로직이 더 잘게", "비슷한 이름 엉뚱"]},
"Y011": {"정답": ["GF000168"], "허용": ["GC000110"], "근거": "채집(9:1) · 운반 · 부설 — 산림 12-25 기초잡석(짐작)", "짐작": True, "갈래": ["엑셀 복합", "품셈 판 번호 바뀜"]},
"Y012": {"정답": ["GC000308"], "허용": [], "근거": "옛 1-9-3 표지판 · 씨앗 같음", "갈래": []},
"Y013": {"정답": ["GF000214"], "허용": [], "근거": "기계 8 : 인력 2 채집 + 운반 — 인력 채집 몫(부분)", "짐작": True, "갈래": ["엑셀 복합"]},
"Y014": {"정답": ["GC000263"], "허용": ["GF000225"], "근거": "건설 7-2-2 석공 0.05 + 굴착기 집게 = GC000263", "갈래": ["같은 뜻 다른 말"]},
"Y015": {"정답": ["GF000211"], "허용": ["GF000212"], "근거": "채집(9:1) + 덤프 — 인력 채집 몫(짐작)", "짐작": True, "갈래": ["같은 뜻 다른 말", "엑셀 복합"]},
"Y016": {"정답": ["GF000218"], "허용": [], "근거": "씨앗 같음(잡석운반및다짐)", "갈래": []},
"Y017": {"정답": ["GF000124"], "허용": ["GC000111"], "근거": "보통인부 0.009 + 굴착기 0.7 · 씨앗 같음", "갈래": []},
"Y018": {"정답": ["GF000101"], "허용": ["GC000083"], "근거": "씨앗 같음", "짐작": True, "갈래": []},
"Y019": {"정답": ["GC000310"], "허용": [], "근거": "씨앗 같음", "갈래": []},
"Y020": {**CON, "근거": "건설 6-1-1 장비사용 55㎥/일 + 굴삭기 — GC000198(1위 펌프차 · 토목 6-1-1 소켓관이 부문 없이 조항 같음)", "갈래": ["같은 뜻 다른 말", "부문 없는 조항"]},
"Y021": EURO,
"Y022": {"정답": ["GC000224"], "허용": [], "근거": "씨앗 같음", "갈래": []},
"Y023": {"정답": ["GC000393"], "허용": [], "근거": "토목 6-4-2 · 씨앗 같음", "갈래": []},
"Y024": {"정답": ["GF000280"], "허용": ["GF000279"], "근거": "옛 건설 9-1 인력운반(지게) 575㎏/㎡ — 산림 10-6-2(짐작)", "짐작": True, "갈래": ["같은 뜻 다른 말", "품셈 판 번호 바뀜"]},
"Y025": {"정답": ["GF000279", "GF000281"], "허용": [], "근거": "옛 건설 9-1 인력운반 2,300㎏/㎥ — 산림 10-6 인력운반(짐작)", "짐작": True, "갈래": ["같은 뜻 다른 말", "품셈 판 번호 바뀜"]},
"Y026": {"정답": ["GF000224", "GC000262"], "허용": [], "근거": "건설 7-2-1 석공 0.13 = GC000262 · 씨앗 = GF000224", "갈래": []},
"Y027": {"정답": ["GC000251"], "허용": [], "근거": "공통 6-7-1 플륨관 특별 · 보통 + 트럭크레인 = GC000251 플륨관 설치(1위 PC관 = 토목 6-7-1)", "갈래": ["같은 뜻 다른 말", "부문 없는 조항"]},
"Y028": {**CON, "근거": "6-1-1 장비사용(소형) — GC000198 · 1위 GF000166 은 산림 12-37 다른 품", "갈래": ["비슷한 이름 엉뚱"]},
"Y029": {"정답": ["GC000211"], "허용": ["GF000160"], "근거": "건설 6-3-1 4회 보통", "갈래": ["같은 이름 다른 책"]},
"Y030": {"정답": ["GF000136"], "허용": [], "근거": "골재장 → 현장 덤프 운반(옛 9-7) = 산림 10-3-3(짐작)", "짐작": True, "갈래": []},
"Y031": {"정답": ["GF000162"], "허용": ["GC000297"], "근거": "임도품셈 7-1-1(옛) · 씨앗 같음", "갈래": []},
"Y032": {"정답": ["GF000163"], "허용": [], "근거": "임도품셈 7-1-2(옛) · 씨앗 같음", "갈래": []},
"Y033": {"정답": ["GF000218"], "허용": [], "근거": "씨앗 같음", "갈래": []},
"Y034": {"정답": ["GF000226"], "허용": [], "근거": "사방품셈 P52 · 씨앗 같음", "갈래": []},
"Y035": {"정답": ["GF000213"], "허용": [], "근거": "큰돌 채집 장비 80 + 인력 20", "갈래": ["단위 다름"]},
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,105 @@
"""req225 C1 — 실무 엑셀 일위대가 줄마다 지금 맞춤 결과(기억 없음) + 단가산출 블록 글 뽑기.
읽기만: 엑셀 · master_data · 우리 로직. 서버 · DB · storage 안 씀.
출력 = 같은 폴더 out_lines.json · out_ours.json
"""
import json
import sys
from pathlib import Path
ROOT = Path(r"C:\Program_coding\임도설계 및 견적자동화 프로그램 개발")
sys.path.insert(0, str(ROOT))
HERE = Path(__file__).resolve().parent
from M01_MasterData import M01_MasterData_Store as m01 # noqa: E402
from M02_MasterTemplete import M02_Hopyo_Basis as hb # noqa: E402
from M02_MasterTemplete import M02_Hopyo_Excel as hx # noqa: E402
from M02_MasterTemplete import M02_Hopyo_Match as hm # noqa: E402
from M02_MasterTemplete import M02_Labor_Inputs as li # noqa: E402
SAMPLES = ROOT / "resources/knowledge/original/실무문서/2026_실무일위대가 모음"
FILES = {
"북부청임도": SAMPLES / "2026년_북부지방산림청_임도사업_설계내역서(공통단가).xls",
"북부청사방": SAMPLES / "2026년_북부지방산림청_사방사업(계류+댐)_설계내역서.xls",
}
def block_text(rows, span):
start, col, _ = hb._columns(rows)
out = []
for at in range(span[0], span[1] + 1):
row = rows[at - 1]
text = row[col] if col < len(row) else ""
if isinstance(text, str) and text.strip():
out.append(text.rstrip())
return out
def main():
got = hm.targets()
lines_out = []
for tag, path in FILES.items():
data = path.read_bytes()
book = hx.read_book(path.name, data)
sheets = hx._sheets(path.name, data)
calc = sheets.get(hb.SHEET) or []
heads = {b["번"]: b for b in book["단산"]}
units = hm.units_of(book)
for h in book["호표"]:
for line in h["줄"]:
m = hm.match_line(line, [], units, True, got)
ref = line.get("참조") or {}
rec = {
"파일": tag,
"호표": h["번"],
"호표이름": h["이름"],
"호표규격": h["규격"],
"호표단위": h["단위"],
"차례": line["차례"],
"이름": line["이름"],
"규격": line["규격"],
"수량": line["수량"],
"단위": line["단위"],
"단가": line["단가"],
"갈래": line["갈래"],
"참조": ref or None,
"근거": line.get("근거"),
"맞춤": m,
}
if ref.get("종류") == "단산" and ref["번"] in heads:
b = heads[ref["번"]]
rec["단산머리"] = {"이름": b["이름"], "단위": b["단위"]}
rec["단산글"] = block_text(calc, b["행"])
lines_out.append(rec)
(HERE / "out_lines.json").write_text(
json.dumps(lines_out, ensure_ascii=False, indent=1), encoding="utf-8"
)
# 우리 돈 로직 목록(읽기만)
table = hb.ours()
whole = m01.loaded()[1]
ours = []
for one in got["로직"]:
row = whole.logic(one["로직"])
o = table.get(one["로직"], {})
ours.append(
{
"로직": one["로직"],
"이름": one["이름"],
"단위": one["단위"],
"구분": o.get("구분"),
"조항": sorted(o.get("조항") or []),
"직종": sorted(o.get("직종") or []),
"출처": str(li.sources(row) or ""),
"원문번호": row.get("원문번호"),
"입력": [str(s.get("이름")) for s in row.get("입력") or []],
}
)
(HERE / "out_ours.json").write_text(
json.dumps(ours, ensure_ascii=False, indent=1), encoding="utf-8"
)
print("lines", len(lines_out), "ours", len(ours), "mats", len(got["자재"]))
if __name__ == "__main__":
main()
@@ -0,0 +1,63 @@
"""고유 로직 줄(단가산출 · 기계 · 기타)마다 엑셀 근거 · 블록 글 · 지금 후보 다섯 — 사람 판정용 보기.
python s02_view.py <파일 태그> <시작> <끝> → 고유 줄 번호 범위만 찍음 · out_items.json 저장
"""
import json
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
LOGIC = ("단가산출", "기계", "기타")
def items():
lines = json.loads((HERE / "out_lines.json").read_text(encoding="utf-8"))
out, seen = [], {}
for x in lines:
if x["갈래"] not in LOGIC:
continue
ref = (x.get("참조") or {}).get("번")
key = (x["파일"], x["이름"], x["규격"], x["단위"], ref)
if key in seen:
seen[key]["줄수"] += 1
seen[key]["호표들"].append(x["호표"])
continue
rec = {**x, "줄수": 1, "호표들": [x["호표"]]}
seen[key] = rec
out.append(rec)
for i, r in enumerate(out):
r["id"] = f"{'N' if r['파일'] == '북부청임도' else 'S'}{i:03d}"
return out
def show(r):
m = r["맞춤"]
b = r.get("근거") or {}
print(f"## {r['id']} [{r['파일']}] 줄{r['줄수']} 호표{sorted(set(r['호표들']))[:6]}")
print(f" 엑셀: {r['이름']} | 규격 {r['규격']} | 단위 {r['단위']} | 갈래 {r['갈래']} | 단가 {r['단가']}")
if r.get("단산머리"):
print(f" 단산{r['참조']['번']}: {r['단산머리']} 출처 {b.get('출처')} 직종 {b.get('직종')}")
for t in r.get("단산글", [])[1:16]:
t = " ".join(t.split())
if t.startswith(("2. 합", "노무비 :", "재료비 :", "경 비 :", "계 :")):
continue
print(f" · {t[:110]}")
tgt = m.get("대상")
print(f" 상태 {m['상태']} 대상 {tgt}")
for c in m.get("후보") or []:
why = (c.get("근거") or {}).get("까닭", "")
print(f" - {c.get('로직') or c.get('자재')} {c['이름']} [{c['단위']}] {c['점수']} {why}")
if __name__ == "__main__":
allx = items()
(HERE / "out_items.json").write_text(
json.dumps(allx, ensure_ascii=False, indent=1), encoding="utf-8"
)
tag = sys.argv[1] if len(sys.argv) > 1 else ""
lo, hi = (int(sys.argv[2]), int(sys.argv[3])) if len(sys.argv) > 3 else (0, 10**6)
pick = [r for r in allx if r["파일"].endswith(tag) or not tag]
print("총", len(allx), "고른 파일", len(pick))
for r in pick[lo:hi]:
show(r)
@@ -0,0 +1,18 @@
"""우리 돈 로직 찾기 — python s03_find.py <정규식> [<정규식> …] (이름 · 출처 · 키 · 조항에서)."""
import json
import re
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
ours = json.loads((HERE / "out_ours.json").read_text(encoding="utf-8"))
for pat in sys.argv[1:]:
rx = re.compile(pat)
print(f"== /{pat}/")
for o in ours:
hay = f"{o['로직']} {o['이름']} {o['출처']} {' '.join(o['조항'])}"
if rx.search(hay):
print(
f" {o['로직']} {o['이름']} [{o['단위']}] {o['구분']} | {o['출처'][:60]} | 직종 {','.join(o['직종'])} | 입력 {','.join(o['입력'])[:70]}"
)
@@ -0,0 +1,257 @@
"""req225 C1 — 정답 표시 대비 지표 · 개선 방법 흉내(같은 프로세스 안 함수 바꿔 끼움 · 저장소 안 고침).
python s04_eval.py → 지표 표(지금 · 이름만 · 방법별) 찍고 out_eval.json 저장
"""
import contextlib
import json
import re
import sys
import unicodedata
from pathlib import Path
ROOT = Path(r"C:\Program_coding\임도설계 및 견적자동화 프로그램 개발")
sys.path.insert(0, str(ROOT))
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
from M02_MasterTemplete import M02_Hopyo_Basis as hb # noqa: E402
from M02_MasterTemplete import M02_Hopyo_Match as hm # noqa: E402
from labels import L # noqa: E402
ITEMS = json.loads((HERE / "out_items.json").read_text(encoding="utf-8"))
BY_ID = {r["id"]: r for r in ITEMS}
FILES = ("북부청임도", "북부청사방")
# ── 지표 ─────────────────────────────────────────────────────────────
def score(results: dict, ids=None) -> dict:
"""results = {id: {상태, top: [키 …]}} → 지표(고유 줄 · 줄 수 무게)."""
ids = ids or list(BY_ID)
out = {}
for weight in ("고유", "줄"):
w = (lambda i: 1) if weight == "고유" else (lambda i: BY_ID[i]["줄수"])
have = [i for i in ids if L[i]["정답"]]
none = [i for i in ids if not L[i]["정답"]]
tot = sum(w(i) for i in have)
def rate(pred):
return round(100 * sum(w(i) for i in have if pred(i)) / tot, 1) if tot else 0
def strict(i):
return set(L[i]["정답"])
def loose(i):
return set(L[i]["정답"]) | set(L[i]["허용"])
top = {i: results[i]["top"] for i in ids}
out[weight] = {
"있음": sum(w(i) for i in have),
"없음": sum(w(i) for i in none),
"1위 엄격": rate(lambda i: top[i][:1] and top[i][0] in strict(i)),
"1위 느슨": rate(lambda i: top[i][:1] and top[i][0] in loose(i)),
"5안 엄격": rate(lambda i: bool(set(top[i][:5]) & strict(i))),
"5안 느슨": rate(lambda i: bool(set(top[i][:5]) & loose(i))),
"없음인데 후보": sum(w(i) for i in none if top[i]),
}
return out
def table(name: str, results: dict) -> dict:
row = {"전체": score(results)}
for f in FILES:
row[f] = score(results, [r["id"] for r in ITEMS if r["파일"] == f])
return {"이름": name, **row}
def show(row: dict) -> None:
def cell(s):
u = s["고유"]
return f"1위 {u['1위 엄격']:>5}/{u['1위 느슨']:>5} 5안 {u['5안 엄격']:>5}/{u['5안 느슨']:>5} 없음후보 {u['없음인데 후보']}"
print(f"{row['이름'][:22]:<22} | 전체 {cell(row['전체'])} | 임도 {cell(row['북부청임도'])} | 사방 {cell(row['북부청사방'])}")
# ── 돌리기 ───────────────────────────────────────────────────────────
def stored() -> dict:
out = {}
for r in ITEMS:
m = r["맞춤"]
if m["상태"] == "같음":
top = [m["대상"].get("로직")]
else:
top = [c.get("로직") for c in m.get("후보") or []]
out[r["id"]] = {"상태": m["상태"], "top": [k for k in top if k]}
return out
def line_of(r: dict) -> dict:
keep = ("이름", "규격", "수량", "단위", "단가", "갈래", "참조", "근거")
return {k: r.get(k) for k in keep}
def run(fn=None) -> dict:
"""지금 파이프라인(hm.match_line) — 바꿔 끼운 함수가 있으면 그것으로."""
hm._targets.cache_clear()
hb._ours.cache_clear()
got = hm.targets()
out = {}
for r in ITEMS:
line = line_of(r)
m = (fn or default_line)(line, got, r)
out[r["id"]] = m
return out
def default_line(line, got, r):
m = hm.match_line(line, [], {}, True, got)
if m["상태"] == "같음":
top = [m["대상"].get("로직")]
else:
top = [c.get("로직") for c in m.get("후보") or []]
return {"상태": m["상태"], "top": [k for k in top if k]}
def name_only(line, got, r):
"""330 앞(이름만) — 같음 · 씨앗 + 글자 두 개 묶음 후보 다섯."""
found = hm._exact(line, "로직", got)
if len(found) == 1:
return {"상태": "같음", "top": [found[0]["로직"]]}
top = [x["로직"] for x in found] + [c["로직"] for c in hm.candidates(line, "로직", got, 5)]
return {"상태": "후보" if top else "없음", "top": list(dict.fromkeys(top))[:5]}
@contextlib.contextmanager
def patched(obj, **funcs):
old = {k: getattr(obj, k) for k in funcs}
for k, v in funcs.items():
setattr(obj, k, v)
try:
yield
finally:
for k, v in old.items():
setattr(obj, k, v)
hm._targets.cache_clear()
hb._ours.cache_clear()
# ── 방법 ① 이름 다듬기 — 괄호 글 살림 · 수 · 단위 · 규격 조각 뺌 ─────────────
_SPECISH = re.compile(
r"[a-z]\d?\s*[=:]\s*[\d.,]+\s*(mm|cm|m|km)?" # L=500m · D=1000mm
r"|[\d.,]+\s*(㎥|㎡|m3|m2|mm|cm|km|m|톤|ton|t|%|회|kg|인|본|개)?" # 0.6㎥ · 4.5톤 · 100%
r"|[×x*~∼:/+]",
re.I,
)
_FILLER = ("적용", "사용", "포함", "이상", "미만", "이하", "내외", "산림사업품셈", "산림사업", "품셈")
def tidy_keep(name) -> str:
text = unicodedata.normalize("NFKC", str(name or "")).lower()
for old, new in hm.MACH_WORDS:
text = text.replace(old, new)
text = _SPECISH.sub(" ", text)
for w in _FILLER:
text = text.replace(w, " ")
text = re.sub(r"[()\[\]{}<>,._\-·]", " ", text)
return hm.mm.plain(text)
# ── 방법 ② 같은 뜻 말 사전(임도 파일에서만 뽑음 · 사방은 처음 보는 셈) ─────────
SYN_NORTH = [
("레미콘", "레디믹스트콘크리트"),
("깍기", "깎기"),
("뒷채움", "뒤채움"),
("붙이기", "붙임"),
("휠라", "채움재"),
("메쉬", "메시"),
("야면석", "돌"),
("소운반", "인력운반"),
("면고르기", "절토면고르기"),
("구조물터파기", "굴착"),
("터파기", "굴착"),
("비탈면보호공", "잔디붙임"),
("평떼", "잔디붙임"),
("콘크리트포장", "콘크리트표층포설"),
("고임돌", "약돌"),
("조약돌", "약돌"),
]
def with_syn(base, pairs):
def tidy_syn(name):
text = str(name or "")
flat = re.sub(r"\s+", "", unicodedata.normalize("NFKC", text))
for old, new in pairs:
flat = flat.replace(old, new)
return base(flat)
return tidy_syn
# ── 방법 ④ 글자 n-그램 ─────────────────────────────────────────────────
def grams_123(text: str) -> set:
out = set(text)
out |= {text[i : i + 2] for i in range(len(text) - 1)}
out |= {text[i : i + 3] for i in range(len(text) - 2)}
return out or {text}
def grams_3(text: str) -> set:
return {text[i : i + 3] for i in range(len(text) - 2)} or {text}
# ── 방법 ⑥ 근거 조항 — 엑셀이 윗마디(6-2 ⊃ 6-2-2) · 오타 13-114 → 13-11-4 ────────────
def clause_prefix(theirs, our):
nums = [n for book, n in theirs if book == our["책"]]
same = sorted(n for n in nums if n in our["조항"])
if same:
return 1.0, [f"{our['구분']} {n}" for n in same], "조항 같음"
kids = sorted(c for c in our["조항"] for n in nums if c.startswith(n + "-"))
if kids:
return 0.9, [f"{our['구분']} {n}" for n in kids], "조항 같음" # 윗마디 = 같음 취급
heads = {hb._head(n) for n in nums}
near = sorted(n for n in our["조항"] if hb._head(n) in heads)
if near:
return 0.5, [f"{our['구분']} {n}" for n in near], "앞 마디 같음"
return 0.0, [], ""
_TYPO_CLAUSE = re.compile(r"^(\d+)-(\d)(\d)(\d)$") # 13-114 → 13-11-4
def fix_typo(theirs):
out = []
for book, n in theirs:
m = _TYPO_CLAUSE.match(n)
out.append([book, f"{m.group(1)}-{m.group(2)}{m.group(3)}-{m.group(4)}" if m else n])
return out
def basis_prefix_line(line, got, r):
b = dict(line.get("근거") or {})
if b.get("출처"):
b["출처"] = fix_typo(b["출처"])
line = {**line, "근거": b}
return default_line(line, got, r)
if __name__ == "__main__":
rows = []
rows.append(table("저장된 지금", stored()))
rows.append(table("지금 다시 돌림", run()))
rows.append(table("이름만(330 앞)", run(name_only)))
with patched(hm, tidy=tidy_keep):
rows.append(table("①이름 다듬기", run()))
with patched(hm, tidy=with_syn(hm.tidy, SYN_NORTH)):
rows.append(table("②사전(임도에서 뽑음)", run()))
with patched(hm, _grams=grams_123):
rows.append(table("④1·2·3그램", run()))
with patched(hm, _grams=grams_3):
rows.append(table("④3그램", run()))
with patched(hb, _clause=clause_prefix):
rows.append(table("⑥조항 윗마디", run()))
rows.append(table("⑥+오타 조항", run(basis_prefix_line)))
for row in rows:
show(row)
(HERE / "out_eval.json").write_text(json.dumps(rows, ensure_ascii=False, indent=1), "utf-8")
@@ -0,0 +1,30 @@
"""두 결과의 1위 · 5안 바뀐 줄 — python s04b_diff.py (s04_eval 함수 씀)."""
from s04_eval import * # noqa: F401,F403
def mark(i, top):
s, l = set(L[i]["정답"]), set(L[i]["정답"]) | set(L[i]["허용"])
t1 = top[0] if top else None
return ("O" if t1 in s else "o" if t1 in l else "x") + ("5" if set(top[:5]) & l else "-")
def diff(a, b, title):
print("==", title)
for i in BY_ID:
if not L[i]["정답"]:
continue
ma, mb = mark(i, a[i]["top"]), mark(i, b[i]["top"])
if ma != mb:
r = BY_ID[i]
print(f" {i} {r['이름'][:18]:<18} {ma}->{mb} {a[i]['top'][:2]} -> {b[i]['top'][:2]} 정답{L[i]['정답']}")
if __name__ == "__main__":
base = run()
with patched(hm, tidy=with_syn(hm.tidy, SYN_NORTH)):
diff(base, run(), "② 사전")
with patched(hm, tidy=tidy_keep):
diff(base, run(), "① 다듬기")
with patched(hb, _clause=clause_prefix):
diff(base, run(), "⑥ 윗마디")
diff(run(name_only), base, "이름만 → 지금")
@@ -0,0 +1,330 @@
"""req225 C1 — 방법 더: 이름만 쪽 방법(①②③④) · 책 맞춤 · 단위 거름망 · 직종 · 장비 겹침 · 금액 ·
같음 근거 검사 · 결합. 저장소 안 고침(같은 프로세스 안 바꿔 끼움 · 읽기만).
python s05_more.py money → 정답 로직 값 ↔ 엑셀 단가 비(진단) · out_money.json
python s05_more.py → 방법별 지표 · out_more.json
"""
import difflib
import json
import re
import sys
import unicodedata
from functools import lru_cache
from s04_eval import ( # noqa: F401
BY_ID,
HERE,
ITEMS,
L,
SYN_NORTH,
clause_prefix,
default_line,
grams_123,
hb,
hm,
name_only,
patched,
run,
show,
table,
tidy_keep,
with_syn,
)
from M01_MasterData import M01_MasterData_Store as m01 # noqa: E402
from M02_MasterTemplete import M02_Labor_Doc as ldoc # noqa: E402
WHOLE = m01.loaded()[1]
_PER = re.compile(r"원\s*/\s*(\d[\d,]*\.?\d*|\.\d+)?\s*([^\s·(\d,.][^\s·(]*)")
# ── 이름만 쪽 방법 ────────────────────────────────────────────────────
def name_words(line, got, r):
"""③ 낱말 차례 무시 — 괄호 글 포함 뜻 낱말 겹침(부분 글 포함)을 점수에 더함."""
base = name_only(line, got, r)
if base["상태"] == "같음":
return base
mine = hb._loose_words(f"{line.get('이름')} {line.get('규격')}")
scored = []
for one in got["로직"]:
theirs = hb._loose_words(one["이름"])
hit = sum(1 for a in mine if any(a in b or b in a for b in theirs))
if not hit:
continue
g1, g2 = hm._grams(hm.tidy(line.get("이름"))), one["grams"]
dice = 2 * len(g1 & g2) / (len(g1) + len(g2))
s = dice + 0.5 * hit / max(len(mine), 1) + (0.15 if hm.fits(line.get("단위"), one["단위"]) else 0)
scored.append((s, one["로직"]))
scored.sort(key=lambda x: -x[0])
top = list(dict.fromkeys(k for _, k in scored))[:5]
return {"상태": "후보" if top else "없음", "top": top}
def name_difflib(line, got, r):
"""④' difflib 비(가장 긴 같은 조각) — 정리 이름끼리."""
base = name_only(line, got, r)
if base["상태"] == "같음":
return base
me = hm.tidy(line.get("이름"))
scored = []
for one in got["로직"]:
s = difflib.SequenceMatcher(None, me, one["tidy"]).ratio()
s += 0.15 if hm.fits(line.get("단위"), one["단위"]) else 0
scored.append((s, one["로직"]))
scored.sort(key=lambda x: -x[0])
return {"상태": "후보", "top": [k for _, k in scored[:5]]}
# ── 책 맞춤 — 엑셀 출처 책(산림 · 건설)과 같은 책 덤(지금 = 산림이면 늘 0.05) ──────────
def book_of(line):
src = ((line.get("근거") or {}).get("출처") or [])
return src[0][0] if src else None
def candidates_book(line, pool, got=None, limit=5):
me = hm.tidy(line.get("이름"))
grams, unit = hm._grams(me), line.get("단위")
text = hm.mm.plain_spec(f"{line.get('이름')}{line.get('규격')}")
want = book_of(line)
scored, seen = [], set()
mine = hm.words(line.get("이름"))
for one in (got or hm.targets())[pool]:
if not grams & one["grams"] or not hm.near(mine, one["words"]):
continue
s = 2 * len(grams & one["grams"]) / (len(grams) + len(one["grams"]))
s += 1 if one["tidy"] == me else 0
s += 0.15 if hm.fits(unit, one["단위"]) else 0
if want:
s += 0.2 if ("산림품셈" if one.get("산림") else "건설품셈") == want else 0
else:
s += 0.05 if one.get("산림") else 0
s += 0.5 if one.get("spec") and one["spec"] in text else 0
scored.append((s, one))
scored.sort(key=lambda x: -x[0])
out = []
for s, one in scored:
label = (one["이름"], one.get("규격"), one["단위"])
if label in seen:
continue
seen.add(label)
out.append({**hm._public(one), "점수": round(s, 3)})
if len(out) == limit:
break
return out
# ── ⑤ 단위 거름망 ─────────────────────────────────────────────────────
def unit_filter_line(line, got, r):
m = default_line(line, got, r)
if m["상태"] == "같음":
return m
units = {x["로직"]: x["단위"] for x in got["로직"]}
keep = [k for k in m["top"] if hm.fits(line.get("단위"), units.get(k))]
return {"상태": "후보" if keep else "없음", "top": keep}
# ── ⑦ 직종 · 장비 구성 겹침(조항 없이도) ───────────────────────────────────
MACH = {
"굴착기": ("굴착기", "굴삭기", "백호", "back-hoe", "backhoe"),
"불도저": ("불도저", "불도우저"),
"덤프": ("덤프",),
"크레인": ("크레인",),
"롤러": ("롤러", "로울러"),
"브레이커": ("브레이커", "브레이카"),
"펌프차": ("펌프차", "펌프카"),
"집게": ("집게",),
"콤팩터": ("콤팩터", "컴팩터"),
"살수": ("살수차", "물탱크"),
"화물차": ("화물자동차", "화물차"),
}
_EQ = re.compile(r"\d{4}-\d{4}")
def mach_types(text: str) -> set:
t = unicodedata.normalize("NFKC", text).lower()
return {k for k, ws in MACH.items() if any(w in t for w in ws)}
@lru_cache(maxsize=4096)
def our_mach(code: str) -> frozenset:
row = WHOLE.logic(code)
eq = WHOLE.numbers.get("EQ", {})
bits = []
for h in row.get("호표") or []:
el = str(h.get("요소") or "")
bits.append(el)
bits += [f"{eq[c].get('상세구분')} {eq[c].get('이름')}" for c in _EQ.findall(el) if c in eq]
for s in row.get("입력") or []:
for v in s.get("고르기") or []:
if isinstance(v, str) and v in eq:
bits.append(f"{eq[v].get('상세구분')} {eq[v].get('이름')}")
bits.append(str(s.get("이름") or ""))
return frozenset(mach_types(" ".join(bits)))
def excel_mach(r) -> set:
return mach_types(" ".join(r.get("단산글") or []) + " " + str(r.get("규격") or ""))
def compose_line(line, got, r, pool_n=40):
"""지금 합친 차례(다섯 자르기 전) + 직종 · 장비 겹침 덤 — 다시 다섯."""
m = hm.match_line(line, [], {}, True, got)
if m["상태"] == "같음":
return {"상태": "같음", "top": [m["대상"]["로직"]]}
basis = hb.candidates(line, got)
named = hm.candidates(line, "로직", got, None)
ordered = hb.merge(basis, named)[:pool_n]
trades = set((line.get("근거") or {}).get("직종") or []) - {"보통인부"}
em = excel_mach(r)
ours = hb.ours()
rescored = []
for at, c in enumerate(ordered):
k = c.get("로직")
s = c["점수"]
ot = ours.get(k, {}).get("직종", set()) - {"보통인부"}
if trades:
s += 0.4 * len(trades & ot) / len(trades)
om = our_mach(k)
if em or om:
s += 0.3 * len(em & om) / max(len(em | om), 1)
rescored.append((s, -at, k))
rescored.sort(reverse=True)
top = [k for _, _, k in rescored]
first = next((c.get("로직") for c in named), None)
five = top[:5] if first in top[:5] or first is None else top[:4] + [first]
return {"상태": "후보" if five else "없음", "top": five}
# ── ⑧ 금액 — 후보 로직 값(자동값 + 규격 입력) ↔ 엑셀 단가 ────────────────────
@lru_cache(maxsize=8192)
def price(code: str, given_json: str) -> tuple:
got = ldoc.run_line(WHOLE, code, json.loads(given_json))
base = got.get("기준")
if not base:
return (None, got.get("까닭"))
row = WHOLE.logic(code)
per = _PER.findall(str(row.get("결과단위") or ""))
times = float(per[0][0].replace(",", "")) if per and per[0][0] else 1.0
return (float(base["계"]) / times, None)
def money_ratio(line, code, unit_of) -> float | None:
try:
specs = hm._logic_specs(code)
values, _ = hm.spec_inputs(line, specs)
except Exception: # noqa: BLE001 — 진단용
values = {}
p, _why = price(code, json.dumps(values, ensure_ascii=False, sort_keys=True, default=str))
excel = (line.get("단가") or {}).get("계")
if p is None or not excel:
return None
conv = hm.factor(line.get("단위"), unit_of.get(code), line.get("규격"))
if conv["배"] is None:
return None
return p * float(conv["배"]) / float(excel)
def money_line(line, got, r, pool_n=15):
m = hm.match_line(line, [], {}, True, got)
if m["상태"] == "같음":
return {"상태": "같음", "top": [m["대상"]["로직"]]}
basis = hb.candidates(line, got)
named = hm.candidates(line, "로직", got, None)
ordered = hb.merge(basis, named)[:pool_n]
unit_of = {x["로직"]: x["단위"] for x in got["로직"]}
rescored = []
for at, c in enumerate(ordered):
k = c.get("로직")
s = c["점수"]
q = money_ratio(line, k, unit_of)
if q is not None and q > 0:
gap = abs(q - 1)
s += 0.6 if gap <= 0.15 else 0.3 if gap <= 0.35 else 0
rescored.append((s, -at, k))
rescored.sort(reverse=True)
top = [k for _, _, k in rescored]
first = next((c.get("로직") for c in named), None)
five = top[:5] if first in top[:5] or first is None else top[:4] + [first]
return {"상태": "후보" if five else "없음", "top": five}
# ── ⑪ 같음도 근거 검사 — 이름 같음이 엑셀 출처 책 · 조항과 어긋나면 근거 후보를 앞에 ───────
def same_check_line(line, got, r):
m = hm.match_line(line, [], {}, True, got)
if m["상태"] != "같음":
return default_line(line, got, r)
tgt = m["대상"]["로직"]
theirs = (line.get("근거") or {}).get("출처") or []
if not theirs:
return {"상태": "같음", "top": [tgt]}
our = hb.ours().get(tgt)
sc, _, _ = hb._clause(theirs, our) if our else (0, None, None)
if sc >= 0.9:
return {"상태": "같음", "top": [tgt]}
basis = [c for c in hb.candidates(line, got) if c["근거"]["까닭"].startswith("조항 같음")]
if not basis:
return {"상태": "같음", "top": [tgt]}
top = list(dict.fromkeys([basis[0]["로직"], tgt] + [c["로직"] for c in basis[1:]]))[:5]
return {"상태": "후보", "top": top}
def diag_money():
got = hm.targets()
unit_of = {x["로직"]: x["단위"] for x in got["로직"]}
rows = []
for r in ITEMS:
lab = L[r["id"]]
if not lab["정답"]:
continue
line = {k: r.get(k) for k in ("이름", "규격", "단위", "단가", "갈래", "참조", "근거")}
right = {k: money_ratio(line, k, unit_of) for k in lab["정답"]}
top = (r["맞춤"].get("후보") or [{}])[0].get("로직") or (r["맞춤"].get("대상") or {}).get("로직")
rows.append(
{
"id": r["id"],
"이름": r["이름"],
"엑셀": (r.get("단가") or {}).get("계"),
"정답 비": right,
"1위": top,
"1위 비": money_ratio(line, top, unit_of) if top and top not in lab["정답"] else None,
}
)
(HERE / "out_money.json").write_text(json.dumps(rows, ensure_ascii=False, indent=1), "utf-8")
best = [min((abs(v - 1) for v in x["정답 비"].values() if v), default=None) for x in rows]
have = [b for b in best if b is not None]
print("정답 값 셈 됨", len(have), "/", len(rows))
for cut in (0.05, 0.15, 0.35, 1.0):
print(f" |비-1| ≤ {cut}: {sum(1 for b in have if b <= cut)}")
for x in rows:
print(f" {x['id']} {x['이름'][:16]:<16} 엑셀 {x['엑셀']} 정답비 { {k: round(v, 2) if v else v for k, v in x['정답 비'].items()} } 1위 {x['1위']} {round(x['1위 비'], 2) if x['1위 비'] else ''}")
if __name__ == "__main__":
if sys.argv[1:] == ["money"]:
diag_money()
sys.exit()
rows = [table("지금", run()), table("이름만", run(name_only))]
with patched(hm, tidy=tidy_keep):
rows.append(table("이름만+①다듬기", run(name_only)))
with patched(hm, tidy=with_syn(hm.tidy, SYN_NORTH)):
rows.append(table("이름만+②사전", run(name_only)))
with patched(hm, tidy=with_syn(tidy_keep, SYN_NORTH)):
rows.append(table("이름만+①②", run(name_only)))
rows.append(table("이름만+③낱말", run(name_words)))
with patched(hm, _grams=grams_123):
rows.append(table("이름만+④123그램", run(name_only)))
rows.append(table("이름만+④difflib", run(name_difflib)))
with patched(hm, candidates=candidates_book):
rows.append(table("책 맞춤", run()))
rows.append(table("⑤단위 거름망", run(unit_filter_line)))
rows.append(table("⑦직종·장비 겹침", run(compose_line)))
rows.append(table("⑧금액", run(money_line)))
rows.append(table("⑪같음 근거 검사", run(same_check_line)))
with patched(hb, _clause=clause_prefix), patched(hm, tidy=tidy_keep, candidates=candidates_book):
rows.append(table("결합 ⑥①책", run()))
rows.append(table("결합 ⑥①책⑪", run(same_check_line)))
rows.append(table("결합 ⑥①책⑪⑧", run(lambda a, b, c: money_line(a, b, c) if hm.match_line(a, [], {}, True, b)["상태"] != "같음" else same_check_line(a, b, c))))
for row in rows:
show(row)
(HERE / "out_more.json").write_text(json.dumps(rows, ensure_ascii=False, indent=1), "utf-8")
@@ -0,0 +1,17 @@
from s04b_diff import diff
from s05_more import * # noqa: F401,F403
if __name__ == "__main__":
base = run()
with patched(hm, candidates=candidates_book):
diff(base, run(), "책 맞춤")
with patched(hb, _clause=clause_prefix), patched(hm, tidy=tidy_keep, candidates=candidates_book):
diff(base, run(), "결합 ⑥①책")
with patched(hb, _clause=clause_prefix):
p6 = run()
with patched(hb, _clause=clause_prefix), patched(hm, tidy=tidy_keep):
diff(p6, run(), "⑥ → ⑥①")
diff(base, run(compose_line), "⑦")
diff(base, run(money_line), "⑧")
diff(base, run(same_check_line), "⑪")
diff(base, run(unit_filter_line), "⑤")
@@ -0,0 +1,103 @@
"""⑨ 맞춤 기억 재사용 — 한 파일 정답을 기억으로 넣고 다른 파일에 몇 줄이 바로 들어가는지(열쇠 꼴별).
+ 낮은 확신 표시(점수 문턱) 흉내 · ⑥①⑪ 결합(책 빼고) · 사전 덤(글자 묶음에만)."""
import json
import re
import unicodedata
from s04_eval import ( # noqa: F401
BY_ID,
HERE,
ITEMS,
L,
SYN_NORTH,
clause_prefix,
hb,
hm,
patched,
run,
show,
table,
tidy_keep,
with_syn,
)
from s05_more import same_check_line
KEYS = {
"지금(품명|규격|단위)": lambda r: hm.line_key(r),
"품명|단위": lambda r: f"{hm.mm.plain(r['이름'])}|{hm.mm.plain_unit(r['단위'])}",
"정리이름|단위": lambda r: f"{hm.tidy(r['이름'])}|{hm.mm.plain_unit(r['단위'])}",
"다듬은이름+규격낱말|단위": lambda r: f"{tidy_keep(str(r['이름']) + ' ' + str(r['규격']))}|{hm.mm.plain_unit(r['단위'])}",
}
def reuse(src: str, dst: str) -> None:
for name, key in KEYS.items():
memo = {}
for r in ITEMS:
if r["파일"] == src and L[r["id"]]["정답"]:
memo.setdefault(key(r), L[r["id"]]["정답"][0])
hit = ok = lines = ok_lines = 0
for r in ITEMS:
if r["파일"] != dst:
continue
k = memo.get(key(r))
if k is None:
continue
hit += 1
lines += r["줄수"]
good = k in set(L[r["id"]]["정답"]) | set(L[r["id"]]["허용"])
ok += good
ok_lines += r["줄수"] if good else 0
n = sum(1 for r in ITEMS if r["파일"] == dst)
nl = sum(r["줄수"] for r in ITEMS if r["파일"] == dst)
print(f" {src}→{dst} {name:<22} 고유 {hit}/{n} 맞음 {ok} · 줄 {lines}/{nl} 맞음 {ok_lines}")
def threshold() -> None:
"""1순위 점수 문턱 — 없음 줄 엉뚱 후보 숨김 대 정답 1순위 잃음."""
rows = []
for r in ITEMS:
m = r["맞춤"]
if m["상태"] != "후보":
continue
top = (m.get("후보") or [{}])[0]
lab = L[r["id"]]
good = top.get("로직") in set(lab["정답"]) | set(lab["허용"])
rows.append((top.get("점수", 0), bool(lab["정답"]), good, r["id"]))
for cut in (0.5, 0.6, 0.8, 1.0):
hidden_none = sum(1 for s, have, g, _ in rows if s < cut and not have)
lost = sum(1 for s, have, g, _ in rows if s < cut and have and g)
wrong_hidden = sum(1 for s, have, g, _ in rows if s < cut and have and not g)
print(f" 문턱 {cut}: 없음 줄 숨김 {hidden_none} · 맞는 1순위 숨김 {lost} · 틀린 1순위 숨김 {wrong_hidden}")
print(" 없음 줄 1순위 점수", sorted((round(s, 3), i) for s, have, g, i in rows if not have))
def grams_syn(pairs):
base = hm._grams
def g(text: str) -> set:
flat = unicodedata.normalize("NFKC", str(text))
for old, new in pairs:
flat = flat.replace(old, new)
return base(text) | base(flat)
return g
if __name__ == "__main__":
print("⑨ 기억 재사용(정답 첫 로직을 기억으로)")
reuse("북부청임도", "북부청사방")
reuse("북부청사방", "북부청임도")
print("낮은 확신 문턱(지금 후보 줄)")
threshold()
rows = [table("지금", run())]
with patched(hb, _clause=clause_prefix), patched(hm, tidy=tidy_keep):
rows.append(table("결합 ⑥①⑪", run(same_check_line)))
with patched(hm, _grams=grams_syn(SYN_NORTH)):
rows.append(table("②사전(글자 묶음만)", run()))
with patched(hb, _clause=clause_prefix), patched(hm, tidy=tidy_keep, _grams=grams_syn(SYN_NORTH)):
rows.append(table("결합 ⑥①⑪②", run(same_check_line)))
for row in rows:
show(row)
(HERE / "out_memory.json").write_text(json.dumps(rows, ensure_ascii=False, indent=1), "utf-8")
@@ -0,0 +1,33 @@
"""실패 줄 목록 — 이름만 · 지금 · 결합 ⑥①⑪ 의 1순위 엄격 실패(+ 5안 느슨 실패) · 갈래 꼬리표."""
import json
from s04_eval import BY_ID, HERE, ITEMS, L, clause_prefix, hb, hm, name_only, patched, run, tidy_keep
from s05_more import same_check_line
NAMES = {}
def fails(res, title):
print("==", title)
out = []
for i, r in BY_ID.items():
lab = L[i]
top = res[i]["top"]
s, l = set(lab["정답"]), set(lab["정답"]) | set(lab["허용"])
if not s:
continue
t1 = top[0] if top else None
if t1 in s:
continue
kind = "느슨맞음" if t1 in l else ("5안" if set(top[:5]) & l else "5밖")
out.append({"id": i, "줄수": r["줄수"], "이름": r["이름"], "1위": t1, "형": kind, "갈래": lab["갈래"]})
print(f" {i} 줄{r['줄수']:>2} {r['이름'][:20]:<20} 1위 {t1} {kind:<5} {lab['갈래']}")
return out
if __name__ == "__main__":
got = {}
got["이름만"] = fails(run(name_only), "이름만(330 앞)")
got["지금"] = fails(run(), "지금")
with patched(hb, _clause=clause_prefix), patched(hm, tidy=tidy_keep):
got["결합"] = fails(run(same_check_line), "결합 ⑥①⑪")
(HERE / "out_fail.json").write_text(json.dumps(got, ensure_ascii=False, indent=1), "utf-8")
@@ -0,0 +1,124 @@
"""셋째 파일(다른 꼴 · STmate · 동부청 영월 2024) — 지금 읽기는 「일위대가 시트 없음」 400 이라
여기서 따로 읽어(일위대가표 D 코드 줄 · X 기계 줄 + 단가산출근거 블록) 같은 맞춤 함수에 넣어 봄.
python s09_stmate.py view → 고유 줄 · 블록 글 · 지금 후보(판정용) · out_stmate.json
"""
import json
import re
import sys
import warnings
from pathlib import Path
import openpyxl
from s04_eval import HERE, hb, hm, name_only # noqa: F401
warnings.filterwarnings("ignore")
ROOT = Path(r"C:\Program_coding\임도설계 및 견적자동화 프로그램 개발")
FILE = ROOT / "resources/knowledge/original/실무문서/05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx"
_HOPYO = re.compile(r"^\s*제\s*(\d+)\s*호표")
_CODE = re.compile(r"^([DXLMB])\d{5}$")
def _num(v):
try:
return float(str(v).replace(",", ""))
except (TypeError, ValueError):
return None
def read():
book = openpyxl.load_workbook(FILE, read_only=True, data_only=True)
rows = [list(r) for r in book["일위대가표"].iter_rows(values_only=True)]
calc = [list(r) for r in book["단가산출근거"].iter_rows(values_only=True)]
book.close()
lines, labor, no = [], {}, None
for r in rows:
head = str(r[0] or "")
hit = _HOPYO.match(head)
if hit:
no = int(hit.group(1))
continue
code = str(r[12] or "").strip() if len(r) > 12 else ""
m = _CODE.match(code)
if not m or not r[0]:
continue
if m.group(1) == "L":
labor[code] = str(r[0]).strip()
if m.group(1) in "DX":
lines.append(
{
"호표": no,
"이름": str(r[0]).strip(),
"규격": str(r[1] or "").strip(),
"단위": str(r[3] or "").strip(),
"단가": {"계": _num(r[4]), "노무비": _num(r[6]), "재료비": _num(r[8]), "경비": _num(r[10])},
"갈래": "단가산출" if m.group(1) == "D" else "기계",
"코드": code,
}
)
blocks, cur = {}, None
for at, r in enumerate(calc, 1):
first = str(r[0] or "").strip()
second = str(r[1] or "").strip() if len(r) > 1 else ""
if _CODE.match(second) and second.startswith("D") and len(r) > 2 and _num(r[2]) is not None:
cur = blocks.setdefault(second, {"글": [], "행": [at, at], "직종코드": []})
continue
if cur is None:
continue
cur["행"][1] = at
if second:
cur["글"].append(second)
lc = re.match(r"^(L\d{5})L$", first)
if lc:
cur["직종코드"].append(lc.group(1))
names = hb.trade_names()
seen, out = {}, []
for x in lines:
key = (x["이름"], x["규격"], x["단위"], x["코드"])
if key in seen:
seen[key]["줄수"] += 1
continue
b = blocks.get(x["코드"])
if b:
text = " ".join(b["글"])
trades = []
for c in b["직종코드"]:
nm = names.get(re.sub(r"\s+", "", labor.get(c, "")))
if nm and nm not in trades:
trades.append(nm)
x["근거"] = {"출처": hb.sources(text), "직종": trades, "행": b["행"]}
x["단산글"] = b["글"][:40]
x["줄수"] = 1
x["id"] = f"Y{len(out):03d}"
x["파일"] = "동부청영월"
seen[key] = x
out.append(x)
return out
if __name__ == "__main__":
items = read()
got = hm.targets()
for x in items:
line = {k: x.get(k) for k in ("이름", "규격", "단위", "단가", "갈래", "근거")}
m = hm.match_line(line, [], {}, True, got)
x["맞춤"] = m
x["이름만"] = name_only(line, got, x)
(HERE / "out_stmate.json").write_text(json.dumps(items, ensure_ascii=False, indent=1), "utf-8")
print("고유", len(items), "줄", sum(x["줄수"] for x in items))
if sys.argv[1:] == ["view"]:
for x in items:
b = x.get("근거") or {}
print(f"## {x['id']} 줄{x['줄수']} {x['이름']} | {x['규격']} | {x['단위']} | {x['갈래']} | 단가 {x['단가']['계']}")
print(f" 출처 {b.get('출처')} 직종 {b.get('직종')}")
for t in (x.get("단산글") or [])[1:9]:
print(f" · {' '.join(t.split())[:100]}")
m = x["맞춤"]
if m["상태"] == "같음":
print(f" 같음 {m['대상']}")
for c in m.get("후보") or []:
why = (c.get("근거") or {}).get("까닭", "")
print(f" - {c.get('로직')} {c['이름']} [{c['단위']}] {c['점수']} {why}")
print(f" 이름만 {x['이름만']['top'][:3]}")
@@ -0,0 +1,201 @@
"""req225 C1 — 세 파일(북부청 임도 · 사방 · 동부청 영월 STmate) 함께 지표 + 방법 더
(⑫ 블록 글 낱말 · ⑬ 같은 이름 접힘 고침 · ⑭ 부문 · 씨앗 끔). 저장소 안 고침.
python s10_all.py → 표 · out_all.json
"""
import json
import re
import s04_eval as E
from s04_eval import clause_prefix, fix_typo, hb, hm, name_only, patched, tidy_keep
from s05_more import compose_line, money_line, same_check_line
from labels import L
from labels_y import LY
Y = json.loads((E.HERE / "out_stmate.json").read_text(encoding="utf-8"))
ALL = E.ITEMS + Y
LAB = {**L, **LY}
BY = {r["id"]: r for r in ALL}
GROUPS = {
"북부청임도": [r["id"] for r in ALL if r["파일"] == "북부청임도"],
"북부청사방": [r["id"] for r in ALL if r["파일"] == "북부청사방"],
"동부청영월": [r["id"] for r in ALL if r["파일"] == "동부청영월"],
}
def score(results, ids):
have = [i for i in ids if LAB[i]["정답"]]
none = [i for i in ids if not LAB[i]["정답"]]
def rate(pred, w=lambda i: 1):
tot = sum(w(i) for i in have)
return round(100 * sum(w(i) for i in have if pred(i)) / tot, 1) if tot else 0
s = {i: set(LAB[i]["정답"]) for i in ids}
lo = {i: set(LAB[i]["정답"]) | set(LAB[i]["허용"]) for i in ids}
top = {i: results[i]["top"] for i in ids}
lines = lambda i: BY[i]["줄수"] # noqa: E731
return {
"고유": len(ids),
"있음": len(have),
"없음": len(none),
"1위 엄격": rate(lambda i: bool(top[i]) and top[i][0] in s[i]),
"1위 느슨": rate(lambda i: bool(top[i]) and top[i][0] in lo[i]),
"5안 엄격": rate(lambda i: bool(set(top[i][:5]) & s[i])),
"5안 느슨": rate(lambda i: bool(set(top[i][:5]) & lo[i])),
"줄 1위 느슨": rate(lambda i: bool(top[i]) and top[i][0] in lo[i], lines),
"줄 5안 느슨": rate(lambda i: bool(set(top[i][:5]) & lo[i]), lines),
"없음인데 후보": sum(1 for i in none if top[i]),
}
def table(name, results):
row = {"이름": name, "전체": score(results, list(BY))}
row.update({g: score(results, ids) for g, ids in GROUPS.items()})
return row
def show(row):
def c(s):
return f"{s['1위 엄격']:>5}/{s['1위 느슨']:>5} {s['5안 엄격']:>5}/{s['5안 느슨']:>5}"
print(
f"{row['이름'][:24]:<24}| 전체 {c(row['전체'])} | 임도 {c(row['북부청임도'])} | 사방 {c(row['북부청사방'])} | 영월 {c(row['동부청영월'])} | 없음후보 {row['전체']['없음인데 후보']}"
)
def run(fn=None):
hm._targets.cache_clear()
hb._ours.cache_clear()
got = hm.targets()
out = {}
for r in ALL:
line = {k: r.get(k) for k in ("이름", "규격", "수량", "단위", "단가", "갈래", "참조", "근거")}
CURRENT["r"] = r
out[r["id"]] = (fn or E.default_line)(line, got, r)
return out
CURRENT = {"r": None}
# ── ⑫ 블록 글 낱말 — 우리 로직 이름 낱말이 엑셀 단가산출 블록 글에 나오면 덤 · 후보에 들임 ──────
_GENERIC = frozenset(
"인력 기계 장비 소계 합계 노무비 재료비 경비 적용 기준 별도계상 별도 인부 보통인부 특별인부 설치 해체 "
"운반 작업 시공 사용 포함 공구손료 경장비 손료 기계경비 재료 인건비 품셈 표준품셈 건설표준품셈 이하 이상".split()
)
def block_words(r) -> str:
text = " ".join((r.get("단산글") or [])[:30])
return re.sub(r"\s+", "", text)
def our_words(name):
return {w for w in hb._loose_words(name) if w not in _GENERIC and len(w) >= 2}
def block_line(line, got, r, pool_n=40):
m = hm.match_line(line, [], {}, True, got)
if m["상태"] == "같음":
return {"상태": "같음", "top": [m["대상"]["로직"]]}
text = block_words(r)
basis = hb.candidates(line, got)
named = hm.candidates(line, "로직", got, None)
ordered = hb.merge(basis, named)[:pool_n]
have = {c.get("로직") for c in ordered}
if text:
for one in got["로직"]:
ws = our_words(one["이름"])
if one["로직"] not in have and ws and all(w in text for w in ws):
ordered.append({"로직": one["로직"], "점수": 0.3})
have.add(one["로직"])
rescored = []
for at, c in enumerate(ordered):
k = c.get("로직")
nm = next((x["이름"] for x in got["로직"] if x["로직"] == k), "")
ws = our_words(nm)
hit = sum(1 for w in ws if w in text) / len(ws) if ws and text else 0
rescored.append((c["점수"] + 0.4 * hit, -at, k))
rescored.sort(reverse=True)
top = [k for _, _, k in rescored]
first = next((c.get("로직") for c in named), None)
five = top[:5] if first in top[:5] or first is None else top[:4] + [first]
return {"상태": "후보" if five else "없음", "top": five}
# ── ⑬ 같은 이름 접힘 고침 — 이름 후보가 (이름 · 규격 · 단위) 같으면 하나만 남기던 것 → 로직마다 ──────
def candidates_nofold(line, pool, got=None, limit=5):
got = got or hm.targets()
me = hm.tidy(line.get("이름"))
grams, unit = hm._grams(me), line.get("단위")
text = hm.mm.plain_spec(f"{line.get('이름')}{line.get('규격')}")
mine = hm.words(line.get("이름"))
scored = []
for one in got[pool]:
if not grams & one["grams"] or not hm.near(mine, one["words"]):
continue
s = 2 * len(grams & one["grams"]) / (len(grams) + len(one["grams"]))
s += 1 if one["tidy"] == me else 0
s += 0.15 if hm.fits(unit, one["단위"]) else 0
s += 0.05 if one.get("산림") else 0
s += 0.5 if one.get("spec") and one["spec"] in text else 0
scored.append((s, one))
scored.sort(key=lambda x: -x[0])
return [{**hm._public(o), "점수": round(s, 3)} for s, o in scored[: limit or None]]
# ── ⑭ 부문 — 엑셀 글 「공통품셈 · 토목품셈 · (건축)」 이 있으면 그 부문 조항만 「조항 같음」 ────────
_PART = re.compile(r"(공통|토목|건축|기계설비|유지관리)")
def clause_part(theirs, our):
got = clause_prefix(theirs, our)
r = CURRENT["r"] or {}
parts = set(_PART.findall(" ".join((r.get("단산글") or [])[:6])))
if got[0] >= 0.9 and parts and our["책"] == "건설품셈" and our["부문"] not in parts:
return 0.5, got[1], "앞 마디 같음"
return got
def typo_same(line, got, r):
b = dict(line.get("근거") or {})
if b.get("출처"):
b["출처"] = fix_typo(b["출처"])
line = {**line, "근거": b}
return same_check_line(line, got, r)
def no_seeds():
return patched(hm, _seeds=lambda: {})
if __name__ == "__main__":
rows = []
rows.append(table("지금", run()))
with no_seeds():
rows.append(table("지금(씨앗 끔)", run()))
rows.append(table("이름만(씨앗 끔)", run(name_only)))
rows.append(table("이름만(씨앗 켬)", run(name_only)))
with patched(hm, tidy=tidy_keep):
rows.append(table("①이름 다듬기", run()))
with patched(hb, _clause=clause_prefix):
rows.append(table("⑥조항 윗마디", run()))
rows.append(table("⑦직종·장비", run(compose_line)))
rows.append(table("⑧금액", run(money_line)))
rows.append(table("⑪같음 근거 검사", run(same_check_line)))
rows.append(table("⑫블록 글 낱말", run(block_line)))
with patched(hm, candidates=candidates_nofold):
rows.append(table("⑬같은 이름 접힘 고침", run()))
with patched(hb, _clause=clause_part):
rows.append(table("⑭부문(+⑥)", run()))
with patched(hb, _clause=clause_prefix), patched(hm, tidy=tidy_keep, candidates=candidates_nofold):
rows.append(table("결합 ⑥①⑪⑬+오타", run(typo_same)))
with patched(hb, _clause=clause_part), patched(hm, tidy=tidy_keep, candidates=candidates_nofold):
rows.append(table("결합 ⑥①⑪⑬⑭+오타", run(typo_same)))
rows.append(table("결합 …+⑫", run(lambda a, b, c: typo_same(a, b, c) if hm.match_line(a, [], {}, True, b)["상태"] == "같음" else block_line(a, b, c))))
for row in rows:
show(row)
(E.HERE / "out_all.json").write_text(json.dumps(rows, ensure_ascii=False, indent=1), "utf-8")
@@ -0,0 +1,29 @@
from s10_all import * # noqa: F401,F403
def mark(i, top):
s, l = set(LAB[i]["정답"]), set(LAB[i]["정답"]) | set(LAB[i]["허용"])
t1 = top[0] if top else None
return ("O" if t1 in s else "o" if t1 in l else "x") + ("5" if set(top[:5]) & l else "-")
def diff(a, b, title, only="Y"):
print("==", title)
for i in BY:
if not LAB[i]["정답"] or not i.startswith(only):
continue
ma, mb = mark(i, a[i]["top"]), mark(i, b[i]["top"])
if ma != mb:
print(f" {i} {BY[i]['이름'][:18]:<18} {ma}->{mb} {a[i]['top'][:2]} -> {b[i]['top'][:2]} 정답{LAB[i]['정답']}")
if __name__ == "__main__":
base = run()
with no_seeds():
diff(base, run(), "씨앗 끔")
diff(base, run(same_check_line), "⑪")
with patched(hm, candidates=candidates_nofold):
diff(base, run(), "⑬")
diff(base, run(block_line), "⑫ 영월")
diff(base, run(block_line), "⑫ 임도", "N")
diff(base, run(block_line), "⑫ 사방", "S")
@@ -0,0 +1,27 @@
"""화면 단추 정확도 — [같음 모두 확정] · [근거 맞음 모두 확정](TS basisOk · basisPick 그대로 옮김)."""
from s10_all import ALL, LAB
def basis_ok(b, line_basis):
if not b:
return False
trades = (line_basis or {}).get("직종") or []
return b["까닭"].startswith("조항 같음") and bool(b["단위"]) and (bool(b["직종"]) or not any(x != "보통인부" for x in trades))
for f in ("북부청임도", "북부청사방", "동부청영월"):
same = [r for r in ALL if r["파일"] == f and r["맞춤"]["상태"] == "같음" and (r["맞춤"].get("환산") or {}).get("배") is not None]
s_ok = sum(1 for r in same if r["맞춤"]["대상"]["로직"] in LAB[r["id"]]["정답"])
l_ok = sum(1 for r in same if r["맞춤"]["대상"]["로직"] in set(LAB[r["id"]]["정답"]) | set(LAB[r["id"]]["허용"]))
picks = []
for r in ALL:
if r["파일"] != f or r["맞춤"]["상태"] != "후보":
continue
c = next((c for c in r["맞춤"].get("후보") or [] if basis_ok(c.get("근거"), r.get("근거"))), None)
if c:
picks.append((r, c["로직"]))
p_s = sum(1 for r, k in picks if k in LAB[r["id"]]["정답"])
p_l = sum(1 for r, k in picks if k in set(LAB[r["id"]]["정답"]) | set(LAB[r["id"]]["허용"]))
wrong = [(r["id"], r["이름"], k) for r, k in picks if k not in set(LAB[r["id"]]["정답"]) | set(LAB[r["id"]]["허용"])]
cand = sum(1 for r in ALL if r["파일"] == f and r["맞춤"]["상태"] == "후보")
print(f"{f}: 같음 확정 {len(same)} 줄(고유) 엄격 맞음 {s_ok} 느슨 {l_ok} | 근거 맞음 확정 {len(picks)}/{cand} 엄격 {p_s} 느슨 {p_l} 틀림 {wrong}")
@@ -0,0 +1,26 @@
"""정답 로직과 엑셀 줄 단위 — 환산이 「입력」(사람이 넣어야)인 줄 수 · 단위 별칭(hr=시간 · 공/㎥=공㎥ · 개소=개=본) 뒤."""
from s10_all import ALL, LAB, hm
units = {x["로직"]: x["단위"] for x in hm.targets()["로직"]}
ALIAS = {"hr": "시간", "h": "시간", "공/㎥": "공㎥", "개소": "개", "본": "개", "ea": "개", "EA": "개"}
def norm(u):
u = str(u or "").strip()
return ALIAS.get(u, ALIAS.get(u.lower(), u))
rows = []
for r in ALL:
lab = LAB[r["id"]]
if not lab["정답"]:
continue
k = lab["정답"][0]
want = units.get(k)
a = hm.factor(r["단위"], want, r.get("규격"))
b = hm.factor(norm(r["단위"]), " · ".join(norm(x.strip()) for x in str(want).split("·")), r.get("규격"))
rows.append((r["id"], r["이름"], r["단위"], want, a["갈래"], b["갈래"]))
print("정답 줄", len(rows), "· 환산 입력(지금)", sum(1 for x in rows if x[4] == "입력"), "· 별칭 뒤", sum(1 for x in rows if x[5] == "입력"))
for x in rows:
if x[4] == "입력":
print(" ", x)
@@ -0,0 +1,20 @@
"""금액을 「확인 표시」로 쓸 때 — 지금 1순위(또는 같음 대상)의 우리 값 ÷ 엑셀 단가가 ±5% · ±15% 안이면 맞는 비율."""
from s05_more import money_ratio
from s10_all import ALL, LAB, hm
units = {x["로직"]: x["단위"] for x in hm.targets()["로직"]}
buckets = {0.05: [0, 0], 0.15: [0, 0], "밖": [0, 0], "못셈": [0, 0]}
for r in ALL:
m = r["맞춤"]
top = (m.get("대상") or {}).get("로직") if m["상태"] == "같음" else ((m.get("후보") or [{}])[0].get("로직"))
if not top:
continue
lab = LAB[r["id"]]
good = top in set(lab["정답"]) | set(lab["허용"])
line = {k: r.get(k) for k in ("이름", "규격", "단위", "단가", "갈래", "근거")}
q = money_ratio(line, top, units)
key = "못셈" if q is None else 0.05 if abs(q - 1) <= 0.05 else 0.15 if abs(q - 1) <= 0.15 else "밖"
buckets[key][0] += 1
buckets[key][1] += good
for k, (n, g) in buckets.items():
print(f" |비-1| {k}: 1순위 {n} 개 중 맞음 {g}")
@@ -0,0 +1,26 @@
import json
from s10_all import BY, LAB, E, no_seeds, run, name_only
def fails(res):
out = {}
for i in BY:
s = set(LAB[i]["정답"])
if not s:
continue
top = res[i]["top"]
if not top or top[0] not in s:
lo = s | set(LAB[i]["허용"])
out[i] = {"1위": top[0] if top else None, "형": "느슨맞음" if top and top[0] in lo else ("5안" if set(top[:5]) & lo else "5밖")}
return out
with no_seeds():
a = fails(run(name_only))
b = fails(run())
(E.HERE / "out_fails_all.json").write_text(json.dumps({"이름만": a, "지금": b}, ensure_ascii=False, indent=1), "utf-8")
for name, f in (("이름만(씨앗 끔)", a), ("지금", b)):
print("==", name, len(f))
for i, v in f.items():
if i.startswith("Y"):
print(f" {i} {BY[i]['이름'][:22]:<22} {v}")
@@ -0,0 +1,19 @@
"""실패 갈래 표 — out_fails_all.json(이름만 · 지금 실패 목록) × cats.py(주 갈래)."""
import json
from cats import NAME, NOW, NOTE, ORDER
from s10_all import BY
f = json.loads(open("out_fails_all.json", encoding="utf-8").read())
miss = {k: [i for i in f[k] if i not in (NAME if k == "이름만" else NOW)] for k in f}
print("갈래 빠짐", miss)
rows = []
for cat in ORDER:
def cnt(k, table):
ids = [i for i in f[k] if (table.get(i) or [None])[0] == cat]
side = [i for i in f[k] if cat in (table.get(i) or [])[1:]]
return len(ids), sum(BY[i]["줄수"] for i in ids), len(side)
a, b = cnt("이름만", NAME), cnt("지금", NOW)
rows.append((cat, a, b))
print(f"{cat:<22} 이름만 주 {a[0]:>2}(줄 {a[1]:>3}) 곁 {a[2]:>2} | 지금 주 {b[0]:>2}(줄 {b[1]:>3}) 곁 {b[2]:>2}")
print("합", len(f["이름만"]), sum(BY[i]["줄수"] for i in f["이름만"]), "|", len(f["지금"]), sum(BY[i]["줄수"] for i in f["지금"]))
json.dump(rows, open("out_cats.json", "w", encoding="utf-8"), ensure_ascii=False)
@@ -0,0 +1,57 @@
"""작은 고침 묶음 · ⑪ 판 지킴(옛 판 글이면 같음 안 내림 · 근거 후보가 직종 또는 뜻 낱말을 나눠야)."""
import json
import re
from s04_eval import clause_prefix, fix_typo, hb, hm, patched, tidy_keep
from s05_more import same_check_line
from s10_all import CURRENT, E, _GENERIC, candidates_nofold, run, show, table, typo_same
_OLD = re.compile(r"(20(1\d|2[0-4])|1\d년|2[0-4]년|임도품셈|임도표준품셈|사방품셈)")
def guarded_same(line, got, r):
text = " ".join((r.get("단산글") or [])[:6])
m = hm.match_line(line, [], {}, True, got)
if m["상태"] != "같음" or _OLD.search(text):
return same_check_line(line, got, r) if m["상태"] != "같음" else {"상태": "같음", "top": [m["대상"]["로직"]]}
theirs = (line.get("근거") or {}).get("출처") or []
if not theirs:
return {"상태": "같음", "top": [m["대상"]["로직"]]}
tgt = m["대상"]["로직"]
our = hb.ours().get(tgt)
if our and hb._clause(theirs, our)[0] >= 0.9:
return {"상태": "같음", "top": [tgt]}
mine = {w for w in hb._loose_words(f"{line.get('이름')}") if w not in _GENERIC}
def strong(c):
if c["근거"]["직종"]:
return True
theirs_w = {w for w in hb._loose_words(c["이름"]) if w not in _GENERIC}
return any(a in b or b in a for a in mine for b in theirs_w)
basis = [c for c in hb.candidates(line, got) if c["근거"]["까닭"].startswith("조항 같음") and strong(c)]
if not basis:
return {"상태": "같음", "top": [tgt]}
top = list(dict.fromkeys([basis[0]["로직"], tgt] + [c["로직"] for c in basis[1:]]))[:5]
return {"상태": "후보", "top": top}
def typo_guard(line, got, r):
b = dict(line.get("근거") or {})
if b.get("출처"):
b["출처"] = fix_typo(b["출처"])
line = {**line, "근거": b}
return guarded_same(line, got, r)
if __name__ == "__main__":
rows = [table("지금", run())]
with patched(hb, _clause=clause_prefix), patched(hm, candidates=candidates_nofold):
rows.append(table("작은 고침 ⑥⑬+오타", run(lambda a, b, c: E.default_line({**a, "근거": {**(a.get("근거") or {}), "출처": fix_typo((a.get("근거") or {}).get("출처") or [])}} if a.get("근거") else a, b, c))))
rows.append(table("⑥⑬+오타+⑪(판 지킴)", run(typo_guard)))
rows.append(table("⑪(판 지킴)만", run(guarded_same)))
with patched(hb, _clause=clause_prefix), patched(hm, tidy=tidy_keep, candidates=candidates_nofold):
rows.append(table("⑥①⑪(판 지킴)⑬+오타", run(typo_guard)))
for row in rows:
show(row)
(E.HERE / "out_guard.json").write_text(json.dumps(rows, ensure_ascii=False, indent=1), "utf-8")
@@ -0,0 +1,265 @@
"""최종 보고 — req225/C1_이름맞춤_실측.md 를 결과 파일(out_*.json) · 정답 표시(labels*.py) · 갈래(cats.py)로 만듦."""
import json
from pathlib import Path
from cats import NOTE, ORDER
from s10_all import ALL, LAB
HERE = Path(__file__).resolve().parent
OUT = HERE.parent / "C1_이름맞춤_실측.md"
def load(name):
return {r["이름"]: r for r in json.loads((HERE / name).read_text(encoding="utf-8"))}
ALLR = load("out_all.json")
GUARD = load("out_guard.json")
NAME = load("out_name_all.json")
CATS = json.loads((HERE / "out_cats.json").read_text(encoding="utf-8"))
CAT = {c: (a, b) for c, a, b in CATS}
def pair(s, a, b):
return f"{s[a]:.1f} / {s[b]:.1f}"
def t1(s):
return pair(s, "1위 엄격", "1위 느슨")
def t5(s):
return pair(s, "5안 엄격", "5안 느슨")
# ── 지표 표 ─────────────────────────────────────────────────────────────
name0 = ALLR["이름만(씨앗 끔)"]
now = ALLR["지금"]
best = GUARD["⑥⑬+오타+⑪(판 지킴)"]
lines_of = {g: sum(r["줄수"] for r in ALL if r["파일"] == g) for g in ("북부청임도", "북부청사방", "동부청영월")}
none_lines = {g: sum(r["줄수"] for r in ALL if r["파일"] == g and not LAB[r["id"]]["정답"]) for g in lines_of}
metric_rows = []
for g, label in (("북부청임도", "북부청 임도"), ("북부청사방", "북부청 사방"), ("동부청영월", "동부청 영월*"), ("전체", "세 파일")):
s0, s1, s2 = name0[g], now[g], best[g]
nl = sum(lines_of.values()) if g == "전체" else lines_of[g]
nn = sum(none_lines.values()) if g == "전체" else none_lines[g]
metric_rows.append(
f"| {label} | {s1['고유']}({nl}) | {s1['있음']} | {s1['없음']}({nn}) · {100 * s1['없음'] / s1['고유']:.1f}% "
f"| {t1(s0)} | {t5(s0)} | {t1(s1)} | {t5(s1)} | {t1(s2)} | {t5(s2)} |"
)
# ── 실패 갈래 표 ───────────────────────────────────────────────────────
cat_rows = []
for cat, a, b in CATS:
ex = " · ".join(NOTE.get(cat, [])[:3])
cat_rows.append(f"| {cat} | {a[0]}({a[1]}) · 곁 {a[2]} | {b[0]}({b[1]}) · 곁 {b[2]} | {ex} |")
tot_a = sum(x[1][0] for x in CATS)
tot_b = sum(x[2][0] for x in CATS)
tot_al = sum(x[1][1] for x in CATS)
tot_bl = sum(x[2][1] for x in CATS)
# ── 부록 C — 정답 표시 한 줄씩 ──────────────────────────────────────────
def top_of(r):
m = r["맞춤"]
if m["상태"] == "같음":
return [m["대상"]["로직"]], "같음"
return [c.get("로직") for c in m.get("후보") or [] if c.get("로직")], m["상태"]
def mark(i, top):
s, lo = set(LAB[i]["정답"]), set(LAB[i]["정답"]) | set(LAB[i]["허용"])
if not s:
return "없음 줄 · " + ("엉뚱 후보 보임" if top else "후보 없음")
t = top[0] if top else None
if t in s:
return "✓"
if t in lo:
return "△(허용)"
return "5안" if set(top[:5]) & lo else "✗"
app = []
for r in ALL:
i = r["id"]
lab = LAB[i]
top, state = top_of(r)
right = " · ".join(lab["정답"]) or "우리에 없음"
extra = f" · 허용 {' · '.join(lab['허용'])}" if lab.get("허용") else ""
part = f" · 부분 {' · '.join(lab['부분'])}" if lab.get("부분") else ""
guess = " · 짐작" if lab.get("짐작") else ""
spec = f" · {r['규격']}" if r.get("규격") else ""
app.append(
f"| {i} | {r['이름']}{spec} [{r['단위']}] ×{r['줄수']} | {right}{extra}{part}{guess} | {lab['근거']} | {state} {top[0] if top else '—'} {mark(i, top)} |"
)
# ── 방법별 표(숫자) ────────────────────────────────────────────────────
A = ALLR
N = NAME
G = GUARD
def cell(row):
s = row["전체"]
return f"{t1(s)} | {t5(s)}"
meth = [
("기준 ① 이름만(330 앞 · 씨앗 끔)", cell(A["이름만(씨앗 끔)"]), "—", "—"),
("기준 ② 지금(330 뒤)", cell(A["지금"]), "—", "—"),
("① 이름 다듬기(괄호 낱말 살림 · 수 · 단위 · 규격 조각 뺌) — 이름만에", cell(N["이름만+①다듬기"]), "작음", "괄호 속 규격이 엉뚱 후보를 끌어옴 · 느슨 1위 −7.1"),
("① 이름 다듬기 — 지금에", cell(A["①이름 다듬기"]), "작음", "영월 −2.9 · 사방 터파기(토사)만 나아짐"),
("② 같은 뜻 말 사전(임도 파일에서 16쌍) — 이름만에", cell(N["이름만+②사전(임도에서 뽑음)"]), "작음(사전) · 키우기 큼", "사방(처음 봄) 느슨 1위 62.8→69.8 · 영월(다른 꼴) 61.8→52.9"),
("② 같은 뜻 말 사전 — 지금에", cell(N["지금+②사전"]), "작음", "레미콘 → 산림 GF000155 로 넘어감(이름 같음 덤 + 산림 덤) · 엄격 −7.1"),
("③ 낱말 차례 무시(뜻 낱말 겹침 덤) — 이름만에", cell(N["이름만+③낱말 차례 무시"]), "작음", "다섯 안 느슨 −5.6 · 한국어 붙여 쓴 이름이라 효과 적음"),
("④ 글자 1·2·3그램 — 이름만에", cell(N["이름만+④1·2·3그램"]), "작음", "변화 거의 없음"),
("④ difflib(가장 긴 같은 조각) — 이름만에", cell(N["이름만+④difflib"]), "작음", "다섯 안 엄격 ↑ · 느슨 ↓ · 없음 줄 엉뚱 후보 4→10"),
("⑤ 단위 거름망(단위 안 맞으면 뺌) — 지금에", cell(N["지금+⑤단위 거름망"]), "작음", "해로움 — 맞는 짝 단위가 달라(개소↔M · 개↔㎡ · 조↔㎡ · hr↔시간) 1위 엄격 −7.9"),
("⑥ 근거 조항 윗마디(엑셀 6-2 ⊃ 우리 6-2-2) — 지금에", cell(A["⑥조항 윗마디"]), "작음(_clause 몇 줄)", "낮음 — 윗마디 = 0.9 · 직종 · 낱말 거름 그대로"),
("⑦ 직종 · 장비 구성 겹침(조항 없이도) — 지금에", cell(A["⑦직종·장비"]), "중간", "효과 작음 — 직종은 이미 근거 점수에 듦"),
("⑧ 금액(우리 값 ÷ 엑셀 단가 ±15% 덤) — 지금에", cell(A["⑧금액"]), "중간", "해로움 — 복합 줄 · 입력 기본값 탓 · 확인 표시로는 ±5% 안 1순위 21 중 20 맞음"),
("⑪ 같음도 근거 검사(판 지킴 없이) — 지금에", cell(A["⑪같음 근거 검사"]), "중간", "영월 −5.8 — 옛 판 1-9-3 이 지금 낙석방지책으로 걸림"),
("⑪ 같음도 근거 검사(판 지킴: 옛 판 글이면 안 내림 · 직종/뜻 낱말 요구)", cell(G["⑪(판 지킴)만"]), "중간", "낮음 — 같음 → 후보로 바뀌어 사람 확정 한 번 더"),
("⑫ 블록 글 낱말(우리 이름 낱말이 엑셀 산출 글에 나오면 덤)", cell(A["⑫블록 글 낱말"]), "중간", "섞임 — 느슨 ↑ 엄격 ↓(블록에 단위중량 · 자재 이름이 섞임)"),
("⑬ 같은 이름 접힘 고침(산림·건설 같은 이름을 하나로 접던 것)", cell(A["⑬같은 이름 접힘 고침"]), "작음(중복 빼기 열쇠)", "다섯 안에 같은 이름 둘 → 화면에 책 표시 필요"),
("⑭ 부문(공통 · 토목 글로 부문 가름)", cell(A["⑭부문(+⑥)"]), "작음", "이 표본에선 변화 0"),
("책 맞춤 덤(엑셀 출처 책 = 우리 책 +0.2)", cell(N["지금+책 맞춤 덤"]), "작음", "섞임 — 영월 +11.8 · 임도 −2.0(건설 삭제 조항 · 산림에만 있는 일)"),
("결합 ⑥ + ⑬ + 조항 오타", cell(G["작은 고침 ⑥⑬+오타"]), "작음", "낮음"),
("결합 ⑥ + ⑬ + 조항 오타 + ⑪(판 지킴) ← 추천", cell(G["⑥⑬+오타+⑪(판 지킴)"]), "작음~중간", "낮음 — 같음 11 줄이 후보로 바뀜(사람 확정 한 번 더)"),
("결합 + ① 다듬기", cell(G["⑥①⑪(판 지킴)⑬+오타"]), "중간", "영월 기초잡석 · 큰돌 채집 둘 뒤집힘"),
]
meth_rows = [f"| {a} | {b} | {c} | {d} |" for a, b, c, d in meth]
TEXT = f"""# C1 외부 일위대가 이름 맞춤 실측 (요구 225 · 검토)
M02 「외부 일위대가 가져오기」 맞춤을 실제 엑셀로 재고 실패 까닭 · 개선 방법 효과를 가늠한 기록. 코드 안 고침(읽기 · 스크래치패드 흉내만).
기준 = main_laptop_1 31e6adf7d · 우리 돈 로직 1,438(산림 300 · 건설 1,138) · 기억 없음(층 기억 빈 것)으로 잼.
## 1. 결론 요약
1. 잰 자료 = 북부청 임도 · 사방(지금 올라가는 꼴) + 동부청 영월 2024(STmate 꼴 · 지금은 「일위대가 시트 없음」 400 → 스크래치패드에서 따로 읽어 같은 맞춤 함수에 넣음). 로직 줄 고유 136(줄 504) 전수 정답 표시 · 정답 있음 126 · 우리에 없음 10(7.4%).
2. 이름만(330 앞) 1순위 엄격 {t1(name0['전체']).split(' / ')[0]}% · 느슨 {t1(name0['전체']).split(' / ')[1]}% · 다섯 안 {t5(name0['전체'])}% — 이름 문제 실재(같은 뜻 다른 말 · 산림/건설 책 갈래 · 레미콘 같은 줄임말이 주 까닭).
3. 지금(330 근거 맞춤 뒤) 1순위 {t1(now['전체'])}% · 다섯 안 {t5(now['전체'])}% — 엑셀 단가산출 조항 · 직종이 이름 문제 대부분을 덮음(+37pt). 줄 무게로는 1순위 느슨 78.1% · 다섯 안 느슨 97.9%.
4. 지금 남은 1순위 실패 36 줄(고유)의 주 까닭 = 책 갈래 11(「같음」 자동 대상이 엑셀 출처와 다른 책 10 · 이름 같음 덤이 근거를 이김 1) · 같은 뜻 다른 말 11(고임돌 · 조약돌 · 소운반 · 철골설치 · 측구수로관) · 우리 로직이 더 잘게 5(철근 = 가공 + 조립) · 그 밖 5(같은 이름 접힘 · 이름 같음 덤) · 장비 줄 2 · 복합 1 · 괄호 1.
5. 이름 쪽 손질(다듬기 · n-그램 · 낱말 차례 · 단위 거름망)은 효과 없거나 해로움. 사전(임도에서 16쌍)은 사방 +7.0 · 영월 −8.9(느슨 1위) → 한 파일로 만든 사전은 다른 꼴에 안 통함.
6. 효과 큰 것 = 근거 쪽 작은 고침 — 조항 윗마디(6-2 ⊃ 6-2-2) · 같은 이름 접힘 고침 · 조항 오타 → 1순위 {t1(G['작은 고침 ⑥⑬+오타']['전체'])}% · 여기에 「같음도 근거 검사(판 지킴)」 더하면 {t1(best['전체'])}% · 다섯 안 {t5(best['전체'])}%.
7. 금액(우리 값 ÷ 엑셀 단가)은 순위 점수로 넣으면 엄격 −4.7pt(복합 줄 · 입력 기본값) · 확인 표시로는 ±5% 안 1순위 21 중 20 맞음(95%).
8. 기억 — 지금 열쇠(품명|규격|단위)로는 같은 회사 다른 파일 재사용 고유 4/48 · 둘째 열쇠(품명|단위)면 15/48(모두 맞음) · 씨앗(사용자 실무 이름 80)이 영월 1순위 엄격 44.1 → 67.6.
9. 단추 정밀 — [근거 맞음 모두 확정] 고른 55 줄(고유) 모두 맞음(엄격 54) · [같음 모두 확정] 25 중 엄격 20(다른 책 5 · 느슨은 25).
10. 「다른 외부문서도 같음」의 실제 걸림돌 = 저장소 실무 내역 xlsx 6 개를 열어 보니 5 개가 STmate 꼴(일위대가표 · 단가산출근거 · D/M/L/X 코드) → 지금은 올리기부터 400 · 이름 맞춤 앞 단계 문제.
## 2. 지표 표
고유 줄 = 같은 (품명 · 규격 · 단위 · 단산 번호) 한 번 셈(확정 한 번이면 같은 열쇠 줄 모두 기억으로 들어감). 칸 = 엄격 / 느슨(%). 엄격 = 엑셀 조항 · 품과 같은 우리 로직 · 느슨 = 같은 일의 다른 책 로직 · 복합의 곁 몫까지. 정답 있는 줄만 분모 · 「우리에 없음」은 따로.
\\* 영월 = STmate 꼴을 스크래치패드에서 읽은 가정(지금 화면은 400). 「지금」 · 「추천 결합」은 기억 없음 · 씨앗 켬.
| 파일 | 고유 줄(줄) | 정답 있음 | 우리에 없음(줄) | 이름만 1위 | 이름만 5안 | 지금 1위 | 지금 5안 | 추천 결합 1위 | 추천 결합 5안 |
|---|---|---|---|---|---|---|---|---|---|
{chr(10).join(metric_rows)}
- 추천 결합 = ⑥ 조항 윗마디 + ⑬ 같은 이름 접힘 고침 + 조항 오타(13-114 → 13-11-4) + ⑪ 같음도 근거 검사(판 지킴).
- 우리에 없음 10 줄 중 지금 엉뚱 후보가 보이는 줄 5(고화매트 → 식생매트 · 잔토정리 → 타설후 정리 · 철강재운반 → 덤프 자재 운반 · 철골공 → 철골 공장용접 · 영월 잔토처리 → 터널굴착 1발파당 인력).
- 줄 무게(같은 줄 되풀이 셈) — 이름만 1위 느슨 56.1 · 지금 78.1 · 추천 결합 85.6 · 다섯 안 느슨 90.0 · 97.9 · 97.9.
- 앞서 잰 「1순위 근거 맞음 123/201」은 근거 꼬리표 기준(정답 대조 아님) — 이번 고유 줄 지금 [근거 맞음] 고름 임도 26/41 · 사방 22/40 과 같은 결.
- 자재 · 별도계상 줄(북부청 임도 142 · 사방 56)은 정답 표시 밖 — 상태만: 임도 자재 같음 12 · 후보 50 · 없음 16 / 별도계상 후보 53 · 없음 11.
## 3. 실패 까닭 갈래 표
1순위 엄격 실패 줄(고유)마다 주 갈래 하나 + 곁 갈래(겹쳐 셈). 칸 = 주 갈래 고유(줄) · 곁 갈래 수. 이름만 = 330 앞 · 씨앗 끔(실패 {tot_a} · 줄 {tot_al}) / 지금 = 330 뒤(실패 {tot_b} · 줄 {tot_bl}).
| 갈래 | 이름만 실패 | 지금 실패 | 예 |
|---|---|---|---|
{chr(10).join(cat_rows)}
- 이름만 실패의 {round(100 * (CAT['같은 뜻 다른 말'][0][0] + CAT['책 갈래(산림↔건설)'][0][0]) / tot_a)}%({CAT['같은 뜻 다른 말'][0][0] + CAT['책 갈래(산림↔건설)'][0][0]}/{tot_a})가 같은 뜻 다른 말 · 책 갈래 — 330 근거가 이 둘을 대부분 풂(책 갈래 {CAT['책 갈래(산림↔건설)'][0][0]} → {CAT['책 갈래(산림↔건설)'][1][0]} · 같은 뜻 {CAT['같은 뜻 다른 말'][0][0]} → {CAT['같은 뜻 다른 말'][1][0]}).
- 지금 책 갈래 11 = 「같음」(이름 같음 · 씨앗) 자동 대상이 엑셀 출처와 다른 책 10(산림 로직 ↔ 엑셀 건설 조항 9 · 반대 1 = 돌망태형 옹벽) + 이름 같음 덤(+1)이 근거 점수를 이김 1(영월 합판거푸집(4회)) — 같음에도 근거 검사를 하면 10 이 풀림(⑪).
- 지금 같은 뜻 다른 말 11 = 근거로도 못 품 — 엑셀 출처가 비었거나(사방 철골설치 · 조약돌 9:1) · 단위중량 인용(고임돌 「1-10」) · 옛 판 번호(영월 9-1 · 9-3) · 우리에 그 책 조항 없음(건설 1-2-7 인력운반).
## 4. 방법별 효과 표
세 파일 정답 있는 126 줄(고유) · 칸 = 1위 엄격 / 느슨 · 5안 엄격 / 느슨(%). 「이름만에」 = 330 앞 맞춤에 얹음 · 「지금에」 = 330 맞춤에 얹음. 흉내는 스크래치패드에서 함수를 바꿔 끼워 같은 줄을 다시 돌림.
| 방법 | 1위 | 5안 | 들 일 | 위험 · 비고 |
|---|---|---|---|---|
{chr(10).join(meth_rows)}
| ⑨ 맞춤 기억 재사용(임도 정답 → 사방) | 지금 열쇠 4/48 · 품명\\|단위 15/48(맞음 15) · 정리이름\\|단위 24/48(맞음 21) | 사방 → 임도 4/52 · 18/52(18) · 23/52(22) | 중간 | 정리이름 열쇠는 괄호 낱말을 버려 엉뚱 3(구조물터파기(토사) ↔ (인력100%)) → 품명\\|단위가 알맞음 |
| ⑨' 씨앗(사용자 실무 이름 80 = 기억의 한 꼴) | 영월 1위 엄격 44.1 → 67.6 · 느슨 55.9 → 73.5 | 영월 79.4 → 88.2 | — | 임도 · 사방은 씨앗 「면고르기 · 문양거푸집 → 산림」 탓에 엄격 −4.1 · −2.3 |
| 단위 별칭(hr=시간 · 공/㎥=공㎥ · 개소=개=본) | 순위 변화 거의 없음 | — | 작음 | 정답 짝 환산 「입력」 20 → 14 줄 |
| 장비 줄 길(갈래 기계 · 단위 시간 → 「기계 시간당 기계경비」 + 기계 입력) | 흉내 안 함 · 남은 실패 2 줄(펌프차 · 크레인) 풂 예상 | — | 작음 | 낮음 |
| 임베딩(한국어 문장 벡터) 재순위 | 기대 — 같은 뜻 다른 말 일부(터파기/굴착 · 레미콘/레디믹스트) | — | 중간~큼(새 패키지 · 모델 · 미리 셈) | 휠라 · 고임돌 · 야면석 같은 현장말은 모를 가능성 · 산림/건설 같은 이름은 벡터도 같아 책 가름 못 함 → 근거와 함께만 |
| 큰 언어 모델 재순위(후보 20 + 엑셀 산출 글 + 우리 호표 · 출처) | 기대 — 남은 실패(복합 · 판 차이 · 같은 뜻)의 대부분 | — | 중간(길 · 열쇠) · 비용 줄당 수 원 | 외부 전송(실무 자료) · 매번 다른 답 · 없는 키 지어냄 → 후보 목록 안에서만 고르게 · 근거 실패 줄(약 15%)에만 · 사람 확정 그대로 |
## 5. 추천 차례 — 먼저 할 것 셋
1. **근거를 이름보다 앞세우는 서버 고침 넷**(들 일 작음~중간 · 위험 낮음) — `M02_Hopyo_Basis._clause` 윗마디(엑셀 6-2 → 우리 6-2-2 · 6-2-3) · `M02_Hopyo_Match.candidates` 접힘 열쇠에 로직 키 · 조항 오타(13-114) · 같음일 때도 근거 검사(엑셀 글이 옛 판 「24년 · 임도품셈 · 사방품셈」이면 안 내림 · 근거 후보가 직종 또는 뜻 낱말을 나눌 때만) → 1순위 엄격 {t1(now['전체']).split(' / ')[0]} → {t1(best['전체']).split(' / ')[0]}% · 다섯 안 엄격 {t5(now['전체']).split(' / ')[0]} → {t5(best['전체']).split(' / ')[0]}%. 덤으로 장비 줄 길(기계 갈래 → 기계 시간당 기계경비 + 기계 입력)과 단위 별칭(hr · 공/㎥ · 개소).
2. **고르기 창 손질**(중간 · 부록 A) — 후보마다 책 · 조항 · 우리 값 ÷ 엑셀 단가(±5% 표시) · 엑셀 산출 글의 직종 · 품과 우리 호표를 나란히 · 같음 줄에도 근거 후보 · [같음 모두 확정]에서 근거 어긋남 빼기 · [다른 로직 찾기]에 엑셀 조항 미리 넣기. 다섯 안 정답이 94%라 남은 일은 「사람이 빨리 고르기」.
3. **기억 넓히기**(중간) — 확정 때 둘째 열쇠(품명|단위)를 「기억 후보」로 · 확정한 엑셀 이름을 층 별칭으로 남겨 이름 후보 · 찾기에 씀(손 사전 대신 사용자 확정이 사전이 됨) → 같은 회사 다음 파일 4/48 → 15/48 · 씨앗 효과(영월 +23.5pt)와 같은 결.
- 별도로 알아 둘 것 — 다른 외부문서 대부분(STmate 꼴)은 지금 올리기부터 400. 「다른 문서에서도 이름이 다름」을 풀려면 STmate 읽기(일위대가표 · 단가산출근거 · D/L/X 코드로 줄 ↔ 블록 · 노임 · 장비 바로 이음)가 먼저 — 코드가 있어 이름 · 블록 잇기는 북부청 꼴보다 쉬움.
- 손 사전 · 금액 순위 점수 · 단위 거름망 · n-그램은 하지 않음(위 표에서 해로움 · 효과 없음).
## 부록 A. 화면 사용 흐름 — 사람이 고르기 어렵게 만드는 것(추천 2 의 근거)
- 후보 단추 글 = 「이름 [단위] · 키」 뿐(View.ts:446-452 · targetText :86-91) — 이름 후보에는 책 · 조항이 안 보여 같은 이름 산림 · 건설(유로폼 설치 및 해체 GF000210 · GC000214)을 구별 못 함.
- 점수 · 까닭은 마우스 올림(title)에만(View.ts:459) · 초록 「근거 맞음」은 조항 같음 + 단위 + 직종 셋 다일 때만(basisOk :122-126) — 윗마디 조항(6-2)이면 늘 회색.
- 「같음」 줄은 서버가 후보를 안 만듦(Match.py:395-403) → 고르기 창에 「가까운 것 없음」만(View.ts:499) · 다른 책으로 바꾸려면 [다른 로직 찾기…]로 이름을 다시 쳐야 함.
- [같음 모두 확정](View.ts:276-282)은 근거를 안 봄 — 이번 표본 25 중 5 가 엑셀과 다른 책.
- 엑셀 근거는 한 줄 요약(출처 · 직종 · 행 · View.ts:464-487) — 계산식(품 · 수량 · 장비)과 우리 로직 호표를 나란히 못 봄 · [엑셀에서 보기]로 창을 하나 더 엶.
- 후보마다 우리 값(엑셀 단위로 환산) ÷ 엑셀 단가가 없음 — ±5% 안이면 95% 맞는 신호인데 안 씀.
- [다른 로직 찾기…](View.ts:488-496)는 고르기 창을 닫고 빈 찾기 창을 엶 — 찾기 칸은 원문번호(조항)로도 걸러지지만(ui_template_logic_find.ts:44-53) 엑셀 조항을 미리 넣어 주지 않음 · 안내 없음.
- 표 「대상」 칸(View.ts:424)도 키 · 책 없이 이름만 — 같은 이름 둘이면 무엇을 골랐는지 안 보임.
- 확정 열쇠 = 품명|규격|단위 정확히 같을 때만(Match.py:68-76) · M01 로직 별칭(208-1)은 로직 1,438 중 0 — 확정한 엑셀 이름이 다음 파일 · 찾기 창에 안 쓰임.
## 부록 B. 맞춤 흐름(파일:줄)
- 올리기 — 화면 `M02_MasterTemplete_Hopyo_Side.ts` → `Hopyo_Api.ts:219-231 uploadHopyo` → `M02_Labor_Router.py:206 POST /layers/{{층}}/hopyo` → `_upload :174-184` → `M02_Hopyo_Excel.read_book :211-279`(`_sheets :74-102` 「일위대가 · 일위대가총괄표 · 단가산출」 세 시트만 · 없으면 400 `NO_SHEET :32` · 호표 · 줄 · `kind_of :132-149` 갈래 · 단산N참조 줄에 `Basis.read :84-125` 블록 근거 `{{출처, 직종, 행}}` :257-261) → `Grid.read` → `Store.save :78`(맞춤은 저장 안 함 · 열 때마다 셈).
- 호표 열기 — `Hopyo_View.ts drawOne :261-344` → `Api readHopyoOne :180-181` → `Router :243-252 GET …/hopyo/{{key}}/{{no}}` → `Match.view :460-474` → `match_hopyo :440-453` → `match_line :367-414`.
- `match_line` 차례 — % · 빼기 · 일위N(호표) → 층 기억 `recall :348-350`(열쇠 `line_key :68-76` = 품명|규격|단위) → 이름 같음 · 씨앗 `_exact :286-297`(`_seeds :266-278` 일위대가조합.json 메모) → 하나면 「같음」 끝(:395-403 · 후보 안 만듦) → 아니면 근거 후보 `Basis.candidates :191-235`(`_clause :166-176` 조항 같음 1.0 · 앞 마디 0.5 · 건설 공통/토목 0.1 · 직종 겹침 0.5 · 단위 0.15 · 직종도 뜻 낱말도 안 겹치면 버림 :213-214) + 이름 후보 `Match.candidates :300-329`(정리 이름 `tidy :79-84` 괄호 글 버림 · 글자 두 개 묶음 dice · 정리 이름 같음 +1 · 단위 0.15 · 산림 0.05 · 자재 규격 0.5 · 뜻 낱말 거름 `near :103-105` · (이름 · 규격 · 단위) 같으면 하나로 접음 :320-325) → `Basis.merge :238-256`(로직 키로 점수 합 · 낱말 안 겹친 근거 후보 뒤로) → `Basis.pick :259-266`(이름 1순위 한 자리) → 후보 5.
- 출처 읽기 — `Basis.sources :60-71`(「…품셈」 뒤 20 글자 안 절 번호 · 앞 글에 산림 · 임도 = 산림품셈 · 그 밖 건설품셈 — 부문 · 연도 버림) · 우리 쪽 조항 `M02_Labor_Inputs.sources :68-85` · `_NUMBER :20`.
- 고르기 · 확정 — `View choose :435-505` → `guessHopyo`(Api :183-193) → `Router :263-281` → `Match.guess :417-428`(입력값 · 환산) → [확정] `saveHopyoMemory` → `Router :283-304` → `Match.confirm :495-526`(층 기억). 단추 [같음 모두 확정] `View :276-282` · [근거 맞음 모두 확정] `View :284-291`(basisOk :122-126 · basisPick :153-158).
## 부록 C. 실제 자료
- `resources/knowledge/original/실무문서/2026_실무일위대가 모음/` — `2026년_북부지방산림청_임도사업_설계내역서(공통단가).xls`(호표 68 · 줄 415 · 로직 줄 고유 52) · `2026년_북부지방산림청_사방사업(계류+댐)_설계내역서.xls`(호표 27 · 줄 201 · 고유 48) — 시험 `resources/tester/test_m02_hopyo_excel.py:26-30` 이 씀. 같은 폴더 `2016_상반_…사방사업표준품셈(안).xls` · `2026_상반_…수목진료.xls` = 일위대가 시트 없음(400).
- `resources/knowledge/original/실무문서/05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx` — STmate 꼴(일위대가표 211행 · 단가산출근거 5,604행) · D 코드 줄 고유 36(줄 91). 같은 꼴 = 간선임도 기번3 · 울진 소광 · 계류보전 영덕 · 봉화 현동 내역 xlsx(시트 이름 확인) · 거창은 또 다른 꼴.
- 우리 로직 = `resources/master_data/로직_*.json`(M01 `loaded()` 읽기만) · 씨앗 = `resources/master_data/일위대가조합.json` 메모 80.
- tmp 안 실무 엑셀 없음.
## 부록 D. 정답 표시 근거(고유 줄 136 · 전수)
칸 「지금」 = 지금 맞춤 상태 · 1순위 키 · 표시(✓ 엄격 맞음 · △ 허용 맞음 · 5안 = 다섯 안에만 · ✗). N = 북부청 임도 · S = 사방 · Y = 영월. 「짐작」 = 블록에 출처가 없거나 우리 로직이 일부만 덮어 사람 판단이 섞임. 영월 「씨앗」 줄은 사용자 실무 이름 → 로직(일위대가조합.json)을 정답으로 봄.
| id | 엑셀 품명 · 규격 [단위] ×줄 | 정답(엄격) · 허용 | 근거 | 지금 |
|---|---|---|---|---|
{chr(10).join(app)}
## 부록 E. 스크립트 · 결과 파일
폴더 = 스크래치패드 `req225/C1/`. 모두 읽기만(서버 · DB · storage · 저장소 안 씀 · 함수 바꿔 끼움은 같은 프로세스 안).
- `s01_extract.py` — 두 xls 를 `M02_Hopyo_Excel.read_book` 으로 읽어 줄마다 `match_line`(기억 없음) → `out_lines.json`(616 줄 · 후보 · 점수 · 단산 블록 글) · 우리 로직 표 `out_ours.json`.
- `s02_view.py` · `s03_find.py` — 고유 로직 줄 보기(`out_items.json`) · 우리 로직 찾기.
- `labels.py` · `labels_y.py` — 정답 표시(부록 C 원천) · `cats.py` — 실패 갈래.
- `s04_eval.py` · `s04b_diff.py` — 지표 · ①②④⑥ 흉내 · 줄별 바뀜.
- `s05_more.py` · `s05b_diff.py` — 책 맞춤 · ⑤⑦⑧⑪ · 금액 진단(`out_money.json`).
- `s06_memory.py` — ⑨ 기억 재사용 · 점수 문턱.
- `s09_stmate.py` — 영월 STmate 읽기(흉내) → `out_stmate.json`.
- `s10_all.py` · `s10b_diff.py` — 세 파일 지표 · ⑫⑬⑭ · 씨앗 끔 → `out_all.json`.
- `s11_buttons.py` — 단추 정밀 · `s12_units.py` — 단위 별칭 · `s13_money_flag.py` — 금액 확인 표시 정밀.
- `s14_fails_all.py` · `s15_cats.py` — 실패 목록 · 갈래 표(`out_fails_all.json` · `out_cats.json`).
- `s16_guard.py` — 작은 고침 묶음 · ⑪ 판 지킴 → `out_guard.json` · `s18_name_all.py` — 이름 쪽 방법 세 파일 → `out_name_all.json`.
- `s17_report.py` — 이 글.
- 실행 = 저장소 뿌리 `venv/Scripts/python.exe -B <스크립트>`(스크래치패드 폴더에서 · 각 120 초 안).
## 부록 F. 정의 · 한계
- 엄격 = 엑셀 블록 출처 조항 · 직종 · 품과 같은 우리 로직(복합이면 주된 몫 · 여럿이면 어느 하나). 느슨 = 엄격 + 같은 일 다른 책 · 복합의 곁 몫. 「우리에 없음」은 분모에서 빼고 따로 셈.
- 짐작 표시 31 줄(출처 없음 · 부분 덮음) — 고임돌 · 조약돌 = 산림 13-2-1 인력 채집 몫 · 소운반 = 산림 10-6 인력운반 · 철골설치 = 철골세우기 장비 손료 등은 사람 판단.
- 사전(②)은 임도 파일에서만 뽑아 사방 · 영월을 처음 보는 셈으로 잼 — 같은 파일 점수는 부풀어 있음.
- 금액(⑧)은 자동값 + 규격에서 읽은 입력값으로 우리 로직을 서버 엔진 그대로 셈(`M02_Labor_Doc.run_line`) · 엑셀 노임(2026)과 우리 노임 차이 몇 % 섞임.
- 영월은 지금 화면이 못 올리는 꼴 — 지표는 「읽기를 붙였다고 가정」 · 노임 코드 → 직종 이름이 덜 이어져 근거 직종이 거의 빔.
"""
OUT.write_text(TEXT, encoding="utf-8")
print("쓴 곳", OUT, "글자", len(TEXT))
@@ -0,0 +1,25 @@
"""이름만 쪽 방법(①②③④ · ⑤ · 책 맞춤)을 세 파일 · 씨앗 끔으로 다시 — out_name_all.json."""
import json
from s04_eval import SYN_NORTH, grams_123, name_only, patched, tidy_keep, with_syn, hm
from s05_more import candidates_book, name_difflib, name_words, unit_filter_line
from s10_all import E, no_seeds, run, show, table
rows = []
with no_seeds():
rows.append(table("이름만(씨앗 끔)", run(name_only)))
with patched(hm, tidy=tidy_keep):
rows.append(table("이름만+①다듬기", run(name_only)))
with patched(hm, tidy=with_syn(hm.tidy, SYN_NORTH)):
rows.append(table("이름만+②사전(임도에서 뽑음)", run(name_only)))
rows.append(table("이름만+③낱말 차례 무시", run(name_words)))
with patched(hm, _grams=grams_123):
rows.append(table("이름만+④1·2·3그램", run(name_only)))
rows.append(table("이름만+④difflib", run(name_difflib)))
rows.append(table("지금+⑤단위 거름망", run(unit_filter_line)))
with patched(hm, candidates=candidates_book):
rows.append(table("지금+책 맞춤 덤", run()))
with patched(hm, tidy=with_syn(hm.tidy, SYN_NORTH)):
rows.append(table("지금+②사전", run()))
for r in rows:
show(r)
(E.HERE / "out_name_all.json").write_text(json.dumps(rows, ensure_ascii=False, indent=1), "utf-8")
@@ -0,0 +1,256 @@
"""req226 C4 — 로직 지문 공용: 경로 · 저장소 모듈 · 식 풀기 · 장비 갈래 · 조건 낱말 · 닮음 셈.
저장소 안 고침(읽기만 · 같은 프로세스 안에서만 씀)."""
import math
import re
import sys
import unicodedata
from pathlib import Path
ROOT = Path(r"C:\Program_coding\임도설계 및 견적자동화 프로그램 개발")
HERE = Path(__file__).resolve().parent
C1 = HERE.parent.parent / "req225" / "C1"
C3 = HERE.parent.parent / "req225" / "C3"
for p in (str(ROOT), str(C1), str(C3)):
if p not in sys.path:
sys.path.insert(0, p)
# ── 장비 갈래(엑셀 글 · 우리 EQ 상세구분 공통) ─────────────────────────────
MACH = [
("굴착기", ("굴착기", "굴삭기", "백호", "b/h", "backhoe", "back-hoe", "포크레인")),
("덤프트럭", ("덤프",)),
("크레인", ("크레인",)),
("브레이커", ("브레이커", "브레이카")),
("콘크리트펌프", ("펌프차", "펌프카", "콘크리트펌프", "콘크리트 펌프")),
("롤러", ("롤러", "로울러")),
("불도저", ("불도저", "불도우저")),
("집게", ("집게",)),
("살수차", ("살수", "물탱크")),
("모터그레이더", ("그레이더",)),
("래머", ("래머", "램머", "래 머")),
("콤팩터", ("콤팩터", "컴팩터")),
("진동기", ("진동기",)),
("발전기", ("발전기",)),
("공기압축기", ("공기압축기", "컴프레셔", "콤프레샤")),
("드릴", ("드릴", "천공기")),
("커터", ("커터",)),
("화물차", ("화물자동차", "화물차", "카고")),
("로더", ("로더",)),
("지게차", ("지게차",)),
("체인톱", ("체인톱",)),
("트랙터", ("트랙터",)),
]
def mtypes(text) -> set:
t = unicodedata.normalize("NFKC", str(text or "")).lower()
return {k for k, ws in MACH if any(w in t for w in ws)}
_SPECNUM = re.compile(r"(\d+(?:\.\d+)?)\s*(㎥|m3|톤|ton|t\b|m\b|㎾|kw|ℓ|mm|hp)", re.I)
def specnums(text) -> set:
t = unicodedata.normalize("NFKC", str(text or ""))
return {float(a) for a, _ in _SPECNUM.findall(t)}
# ── 조건 낱말(축) ──────────────────────────────────────────────────────
SOIL = ("보통토사", "경질토사", "혼합토사", "자갈섞인토사", "사질토", "점질토", "토사")
ROCK = ("리핑암", "발파암", "풍화암", "연암", "보통암", "경암", "극경암")
AXES = {
"토질": SOIL + ROCK,
"메찰": ("메쌓기", "찰쌓기", "메붙임", "찰붙임", "메붙이기", "찰붙이기"),
"형": ("원형", "사각", "타원형"),
"근": ("무근", "철근"),
"난이": ("제물치장", "매우복잡", "복잡", "보통", "간단"),
"규모": ("소량", "소형", "소규모", "중규모", "대규모", "대형"),
"방법": ("인력운반", "인력", "기계", "장비", "펌프"),
}
CLASH = ("토질", "메찰", "형") # 이름에 박힌 값이 엑셀과 어긋나면 깎는 축(방법 · 난이는 안 깎음 — C3 #8)
def soil_family(w):
return "암" if w in ROCK or w == "암" else "토사" if w in SOIL else None
def conds(text) -> dict:
"""{축: {낱말}} + 횟수 · 개월 · Type."""
t = re.sub(r"\s+", "", unicodedata.normalize("NFKC", str(text or "")))
out = {}
for ax, ws in AXES.items():
hit = set()
rest = t
for w in sorted(ws, key=len, reverse=True):
if w in rest:
hit.add(w)
rest = rest.replace(w, " ")
if ax == "토질" and re.search(r"(\(암\)|암\)|[^가-힣]암$|^암$|암절취|\b암\b)", t):
hit.add("암")
if hit:
out[ax] = hit
n = re.findall(r"(\d)회", t)
if n:
out["횟수"] = {f"{x}회" for x in n}
m = re.findall(r"(\d+)개월", t)
if m:
out["개월"] = {f"{x}개월" for x in m}
ty = re.findall(r"type[-\s]*(ⅰ|ⅱ|ii|i)\b", t.lower())
if ty:
out["type"] = {"Ⅱ" if x in ("ⅱ", "ii") else "Ⅰ" for x in ty}
return out
def cond_score(excel: dict, name_c: dict, opt_c: dict) -> tuple:
"""(맞음 비율, 어긋남) — 엑셀 조건 낱말이 우리 이름 · 고르기 글에 있나 · 이름 축 어긋남."""
words = [(ax, w) for ax, ws in excel.items() for w in ws]
if not words:
return 0.0, 0
hit = 0
for ax, w in words:
pool = name_c.get(ax, set()) | opt_c.get(ax, set())
if w in pool or (ax == "토질" and soil_family(w) in {soil_family(x) for x in pool}):
hit += 1
clash = 0
for ax in CLASH:
if ax in excel and ax in name_c:
if ax == "토질":
ef = {soil_family(w) for w in excel[ax]}
of = {soil_family(w) for w in name_c[ax]}
if ef and of and not (ef & of):
clash = 1
elif not (excel[ax] & name_c[ax]):
clash = 1
return hit / len(words), clash
# ── 식 풀기 — 「단가 × 식 = 금액」 → 품(단가로 나눈 몫 · 단가는 버림) ──────────────────
_UNITFRAC = re.compile(
r"(인|개|본|공|EA|ea|hr|ton|Ton|TON|kg|㎥|㎡|m3|m2|m|M|조|회)\s*/\s*(EA|ea|㎥|㎡|m3|m2|m|M|ton|Ton|TON|t|hr|일|개|본|조|공㎥|㎥)"
)
_UNITTAIL = re.compile(r"(?<=[\d.)])\s*(hr|HR|EA|ea|kg|km|ton|Ton|TON|sec|m3|m2|mm|cm|M|m|t)(?![A-Za-z])")
_NOTE = re.compile(r"\(([^()]*[가-힣][^()]*)\)")
_LEAD = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*([*/])\s*(.+)$")
_SAFE = re.compile(r"^[\d.+\-*/() ]+$")
def _norm(s: str) -> str:
s = unicodedata.normalize("NFKC", str(s))
s = s.replace("×", "*").replace("✕", "*").replace("÷", "/").replace("x", "*").replace("X", "*")
s = re.sub(r"(?<=\d),(?=\d{3})", "", s)
return _UNITFRAC.sub("", s)
def _split(rest: str, first_op: str):
"""맨 바깥 * / 로 쪼갬 → [(연산, 조각)]."""
out, depth, cur, op = [], 0, "", first_op
for ch in rest:
if ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if depth == 0 and ch in "*/":
out.append((op, cur))
op, cur = ch, ""
continue
cur += ch
out.append((op, cur))
return out
def _factor(text: str):
"""조각 → (값, 율 표시, 메모) · 변수(P · Q …)면 None."""
notes = _NOTE.findall(text)
t = _NOTE.sub("", text)
pct = "%" in t
if re.search(r"\d\s*회", t): # 「× 2회」 — 횟수 곱(율로 가름)
notes.append("회")
t = re.sub(r"[가-힣㎥㎡㎝㎜℃˚°'\"]", "", t).replace("%", "")
t = _UNITTAIL.sub("", t).strip()
if not t or re.search(r"[A-Za-z]", t) or not _SAFE.match(t):
return None
try:
v = eval(t, {"__builtins__": {}}, {}) # noqa: S307 — 숫자 · 연산 글만(_SAFE)
except Exception: # noqa: BLE001
return None
v = float(v) / (100 if pct else 1)
return v, pct or bool(notes), " ".join(notes)
def _rates_any(parts):
"""모든 조각의 율(메모 · %) — 변수 든 조각(P · Q)도 풀 수 있는 조각만."""
out = []
for seg in parts:
m = _LEAD.match(seg)
if not m:
continue
for op, piece in _split(m.group(3), m.group(2)):
f = _factor(piece)
if f and f[1] and f[0] and all(abs(f[0] - v) > 1e-9 for v, _ in out):
out.append((f[0] if op == "*" else 1 / f[0], f[2] or "%"))
return out
def parse_rhs(rhs: str):
"""「단가 × 식 [= 바꿔 쓴 식] = 금액」 → {단가, 품, 밑품, 율[]} · 못 읽으면 None."""
parts = _norm(rhs).split("=")
if len(parts) < 2:
return None
every = _rates_any(parts[:-1])
for seg in parts[:-1]:
m = _LEAD.match(seg)
if not m:
continue
price = float(m.group(1))
facs = []
ok = True
for op, piece in _split(m.group(3), m.group(2)):
f = _factor(piece)
if f is None:
ok = False
break
facs.append((op, *f))
if not ok or not facs:
continue
full = base = 1.0
rates = []
for op, v, rate, note in facs:
if v == 0 and op == "/":
ok = False
break
full = full * v if op == "*" else full / v
if rate:
rates.append([round(v if op == "*" else 1 / v, 6), note or "%"])
else:
base = base * v if op == "*" else base / v
if ok:
if not rates and every: # 바꿔 쓴 식엔 메모가 빠짐 — 앞 조각의 율로 밑품
for v, note in every:
if v:
base /= v
rates = [[round(v, 6), note] for v, note in every]
plain = [v if op == "*" else 1 / v for op, v, rate, _ in facs if not rate and v]
return {"단가": price, "품": full, "밑품": base, "율": rates, "인자": plain}
return None
# ── 닮음 ───────────────────────────────────────────────────────────────
def close(a, b, tol=0.15) -> float:
"""두 양수 가까움 0~1 — 비가 tol 안이면 1 쪽 · ln 거리로 떨어짐."""
if not a or not b or a <= 0 or b <= 0:
return 0.0
d = abs(math.log(a / b))
return 1.0 if d <= 0.03 else max(0.0, 1 - (d - 0.03) / (tol * 4))
def cosine(a: dict, b: dict) -> float:
keys = set(a) | set(b)
num = sum(a.get(k, 0) * b.get(k, 0) for k in keys)
da = math.sqrt(sum(v * v for v in a.values()))
db = math.sqrt(sum(v * v for v in b.values()))
return num / (da * db) if da and db else 0.0
def jacc(a, b) -> float:
a, b = set(a), set(b)
return len(a & b) / len(a | b) if a | b else 0.0
@@ -0,0 +1,10 @@
import json, sys
from pathlib import Path
ROOT = Path(r"C:\Program_coding\임도설계 및 견적자동화 프로그램 개발")
sys.path.insert(0, str(ROOT))
from M01_MasterData import M01_MasterData_Store as m01
files, whole = m01.loaded()
for code in sys.argv[1:]:
row = whole.logic(code)
print("=====", code)
print(json.dumps(row, ensure_ascii=False, indent=1, default=str)[:6000])
@@ -0,0 +1,17 @@
import json, sys
from pathlib import Path
ROOT = Path(r"C:\Program_coding\임도설계 및 견적자동화 프로그램 개발")
sys.path.insert(0, str(ROOT))
from M01_MasterData import M01_MasterData_Store as m01
files, whole = m01.loaded()
mf = m01.cm.mf
code = sys.argv[1]
given = json.loads(sys.argv[2]) if len(sys.argv) > 2 else {}
logic = whole.logic(code)
auto = {k: v for k, v in m01.mau.values(whole, logic).items() if v is not None}
print("auto", auto)
got = mf.run(whole, code, {**auto, **m01._dec(given)})
print("keys", list(got.keys()))
for ln in got.get("줄") or []:
print(json.dumps(ln, ensure_ascii=False, default=str)[:400])
print("중간", json.dumps(got.get("중간"), ensure_ascii=False, default=str)[:1500])
@@ -0,0 +1,33 @@
import json, sys, time, math
from collections import Counter
from pathlib import Path
ROOT = Path(r"C:\Program_coding\임도설계 및 견적자동화 프로그램 개발")
sys.path.insert(0, str(ROOT))
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Hopyo_Match as hm
files, whole = m01.loaded()
mf = m01.cm.mf
got = hm.targets()
kinds = Counter(); ok = 0; bad = Counter(); combos = []
t0 = time.time()
eq = whole.numbers.get("EQ", {})
print("EQ sample", list(eq.items())[:2])
for one in got["로직"]:
code = one["로직"]
row = whole.logic(code)
for h in row.get("호표") or []:
kinds[h.get("종류")] += 1
picks = [len(s.get("고르기") or []) for s in row.get("입력") or [] if s.get("고르기")]
combos.append(math.prod(picks) if picks else 1)
try:
auto = {k: v for k, v in m01.mau.values(whole, row).items() if v is not None}
r = mf.run(whole, code, auto)
ok += 1
except Exception as e:
bad[type(e).__name__] += 1
print("logics", len(got["로직"]), "kinds", kinds)
print("default ok", ok, "bad", bad, "sec", round(time.time()-t0, 1))
c = Counter()
for n in combos:
c["1" if n == 1 else "<=10" if n <= 10 else "<=100" if n <= 100 else "<=1000" if n <= 1000 else ">1000"] += 1
print("combo sizes", c)
@@ -0,0 +1,17 @@
import sys, warnings, re
from pathlib import Path
import openpyxl
warnings.filterwarnings("ignore")
ROOT = Path(r"C:\Program_coding\임도설계 및 견적자동화 프로그램 개발")
F = ROOT / "resources/knowledge/original/실무문서/05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx"
book = openpyxl.load_workbook(F, read_only=True, data_only=True)
print(book.sheetnames)
want = sys.argv[1] if len(sys.argv) > 1 else "단가산출근거"
lo, hi = (int(sys.argv[2]), int(sys.argv[3])) if len(sys.argv) > 3 else (1, 60)
for at, r in enumerate(book[want].iter_rows(values_only=True), 1):
if at < lo: continue
if at > hi: break
cells = [str(c) for c in r[:14]]
while cells and cells[-1] in ("None", ""): cells.pop()
print(at, " | ".join(cells))
book.close()
@@ -0,0 +1,16 @@
"""지문 실패 줄 뜯어 보기 — 엑셀 지문 · 1~3 위 채널 · 정답 로직의 차례 · 채널."""
import json, sys
import s3_match as S
ids = sys.argv[1:]
for i in ids:
e = S.EXP[i]
lab = S.A.LAB[i]
print(f"## {i} {e['이름']} | {e['규격']} | {e['단위']} 출처{e['출처']} 옛판{e['옛판']} 조건{ {k: sorted(v) for k, v in e['조건'].items()} }")
print(" 인력", {t: (round(o['품'], 4), round(o['밑품'], 4)) for t, o in e['인력'].items()}, "장비", [(m['갈래'], m['규격'], m['시간'] and round(m['시간'], 4)) for m in e['장비']], "자재", [m['이름'][:14] for m in e['자재']])
allr = S.rank(e, None, 1438)
pos = {k: n for n, (_, k, _, _) in enumerate(allr)}
for s, k, ch, v in allr[:3]:
print(f" {k} {S.TG[k]['이름'][:22]} [{S.TG[k]['단위']}] {s:.2f}", {a: (round(b, 2) if isinstance(b, float) else b) for a, b in ch.items()}, (v or {}).get('in'))
for k in lab['정답'][:2]:
s, ch, v = S.score_pair(e, next(o for o in S.P if o['로직'] == k))
print(f" 정답 {k} {S.TG[k]['이름'][:22]} [{S.TG[k]['단위']}] 차례 {pos.get(k)} {s:.2f}", {a: (round(b, 2) if isinstance(b, float) else b) for a, b in ch.items()}, (v or {}).get('in'), {t: round(q, 4) for t, q in ((v or {}).get('L') or {}).items() if q})
@@ -0,0 +1,339 @@
"""req226 C4 ① 바깥 단가산출 블록 → 로직 지문(직종 품 · 장비 · 자재 · 할증 · 계수 · 조건 · 조항).
북부청 = req225 C1 out_items.json 의 단산 글(산출근거 칸)을 줄마다 풂.
영월 = STmate 「단가산출근거」 코드(L · X · M · S) + QTY 칸 + 노무비 · 중기 · 재료비 · 경비 목록표로 이름 이음.
출력 = excel_fp.json (고유 줄 136 · id 는 앞 조사와 같음)
python s1_excel_fp.py [id …] → 지문 찍어 보기
"""
import json
import re
import sys
import warnings
import fp_common as F
from fp_common import HERE, conds, mtypes, parse_rhs, specnums
from M02_MasterTemplete import M02_Hopyo_Basis as hb # noqa: E402
warnings.filterwarnings("ignore")
NORTH = json.loads((F.C1 / "out_items.json").read_text(encoding="utf-8"))
YW_ITEMS = json.loads((F.C1 / "out_stmate.json").read_text(encoding="utf-8"))
YW_FILE = F.ROOT / "resources/knowledge/original/실무문서/05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx"
TRADES = hb.trade_names()
COST = {"노무비", "재료비", "경비"}
SKIPW = {
"소계", "계", "합계", "인부소계", "자재소계", "주자재소계", "운반", "상차", "기계", "다짐", "적사", "살수", "집토",
"암절취", "채집", "인력", "장비", "작업조건", "1일시공량", "작업면적", "1시간작업량", "기계채집", "구조물터파기",
}
_COLON = re.compile(r"^\s*(?:\d+\)|[0-9가-힣]\.)?\s*(?P<name>[^::=]{1,40}?)\s*[::]\s*(?P<rhs>.*)$")
_MAT = re.compile(r"^\s*(?P<name>[^\d\s::=][^::=]*?[A-Za-z가-힣)\]])\s+(?P<rhs>[\d,]+(?:\.\d+)?\s*[×xX*÷/].*=.*)$")
_PCT = re.compile(r"([가-힣]+)의\s*([\d.]+)\s*%")
_COEF = re.compile(r"(?<![A-Za-z가-힣])(q|qs|q1|K|k|f|f1|f2|E|Es|Cm|L|V1|V2|T|rt|n|t1|t2|t3|t4)\s*=\s*(\d+(?:\.\d+)?)(?![\d.]*\s*[×x*÷/])")
_HEAD = re.compile(r"^\s*(\d+)\.\s*(.*)$")
_SUB = re.compile(r"^\s*([가-하])\.\s*(.*)$")
_OLD = re.compile(r"((?<![\d,.])20(0\d|1\d|20)(?![\d,.])|(?<!\d)(1\d|20)년|임도품셈|임도표준품셈|사방품셈|사방사업표준품셈)")
_LASTNUM = re.compile(r"=\s*([\d,]+(?:\.\d+)?)\s*$")
def _flat(s):
return re.sub(r"\s+", "", str(s or ""))
def _ratio(rhs):
"""식을 못 풀 때만 — 금액 ÷ 단가(품 되찾기용 · 순위엔 안 씀)."""
m = re.match(r"^\s*([\d,]+(?:\.\d+)?)", rhs)
n = _LASTNUM.search(rhs)
if not m or not n:
return None
p, a = float(m.group(1).replace(",", "")), float(n.group(1).replace(",", ""))
return a / p if p else None
_HEAD_Y = re.compile(r"^\s*(\d+)[.)]\s*(.*)$")
_SUB_Y = re.compile(r"^\s*[①-⑩]")
def rates_in(text):
"""글 속 섞음 비율 후보 — (N%) · 8:2 · 9:1."""
t = str(text or "")
out = {round(float(n) / 100, 4) for n in re.findall(r"(\d+(?:\.\d+)?)\s*%", t) if 0 < float(n) < 100}
for a, b in re.findall(r"(\d+)\s*:\s*(\d+)", t):
a, b = float(a), float(b)
if a + b and a and b and a + b <= 20:
out |= {round(a / (a + b), 4), round(b / (a + b), 4)}
return out
def rate_factor(text, cands):
"""식 글에서 섞음 비율로 보이는 곱 조각 하나(없으면 None)."""
if not cands:
return None
nums = re.findall(r"[*×]\s*(\d*\.\d+|\d+)(?![\d,])", str(text or ""))
for n in nums:
v = round(float(n), 4)
if v in cands:
return v
return None
def empty(item):
return {
"id": item["id"],
"파일": item["파일"],
"이름": item["이름"],
"규격": item.get("규격") or "",
"단위": item.get("단위") or "",
"갈래": item.get("갈래"),
"출처": ((item.get("근거") or {}).get("출처")) or [],
"직종근거": ((item.get("근거") or {}).get("직종")) or [],
"인력": {}, # 직종: {품, 밑품, 율, 어떻게}
"인력절": [], # [절, 직종, 품, 밑품]
"장비": [], # {갈래, 규격, 시간, 밑시간, 절, 글}
"자재": [], # {이름, 품, 밑품}
"율": [], # [바탕, %]
"계수": {},
"조건": {},
"옛판": False,
"글": [],
"절": {}, # 절 번호: {이름, 출처}
}
def add_labor(fp, trade, q, base, rates, how, sec):
one = fp["인력"].setdefault(trade, {"품": 0.0, "밑품": 0.0, "율": [], "어떻게": []})
one["품"] += q
one["밑품"] += base
one["율"] += rates
one["어떻게"].append(how)
fp["인력절"].append([sec, trade, round(q, 6), round(base, 6)])
def north(item):
fp = empty(item)
text = item.get("단산글") or []
fp["글"] = text[:60]
head_text = " ".join([item["이름"], fp["규격"]] + [t for t in text[:5]] + [t for t in text if "※" in t or "작업조건" in t])
fp["조건"] = {k: sorted(v) for k, v in conds(head_text).items()}
fp["이름조건"] = {k: sorted(v) for k, v in conds(f"{item['이름']} {fp['규격']}").items()}
fp["옛판"] = bool(_OLD.search(" ".join(text[:6])))
if not text: # 기계 줄 · 블록 없음
fp["장비"] = [
{"갈래": sorted(mtypes(f"{item['이름']} {fp['규격']}")), "규격": sorted(specnums(f"{item['이름']} {fp['규격']}")),
"시간": 1.0 if F.re.search(r"(hr|시간)", fp["단위"], F.re.I) else None, "밑시간": None, "절": 0, "글": item["이름"]}
]
fp["장비"] = [m for m in fp["장비"] if m["갈래"]]
return fp
sec, sub, mach, mach_label = 0, "", None, ""
got_cost = {}
item_rates = rates_in(f"{item['이름']} {fp['규격']}")
sec_rates = set()
def unmix(got):
"""율 메모가 없는데 맨 인자가 섞음 비율 후보와 같으면 율로 가름."""
if got and not got["율"]:
for v in got.get("인자") or []:
if round(v, 4) in item_rates | sec_rates and 0 < v < 1:
return {**got, "밑품": got["밑품"] / v, "율": [[round(v, 4), "섞음"]]}
return got
for raw in text[1:]:
line = raw.rstrip()
h = _HEAD.match(line)
s = _SUB.match(line)
if h or s:
if h:
sec, sub = int(h.group(1)), ""
fp["절"][str(sec)] = {"이름": h.group(2).strip(), "출처": hb.sources(h.group(2))}
sec_rates = rates_in(h.group(2))
else:
sub = s.group(1)
sec_rates = sec_rates | rates_in(s.group(2))
top = fp["절"].get(str(sec)) or {"이름": "", "출처": []}
fp["절"][f"{sec}.{sub}"] = {"이름": f"{top['이름']} · {s.group(2).strip()}", "출처": hb.sources(s.group(2)) or top["출처"]}
title = (h or s).group(2)
ts = mtypes(title)
mach = (ts, specnums(title)) if ts else None
mach_label = title.strip() if ts else ""
# 머리 줄 자체가 「기 계 : 굴착기 …」 꼴이면 아래 비용 줄이 그 장비
for k, v in _PCT.findall(line):
fp["율"].append([k, float(v)])
cm = _COLON.match(line)
name = _flat(cm.group("name")) if cm else ""
name = re.sub(r"^[0-9가-힣]\.", "", name)
if cm and name in TRADES:
rhs = cm.group("rhs")
got = unmix(parse_rhs(rhs))
key = f"{sec}.{sub}" if sub else str(sec)
if got:
add_labor(fp, TRADES[name], got["품"], got["밑품"], got["율"], "식", key)
else:
r = _ratio(rhs)
if r:
add_labor(fp, TRADES[name], r, r, [], "역산", key)
continue
if cm and name in COST:
got = unmix(parse_rhs(cm.group("rhs")))
if got and mach:
key = (sec, sub, mach_label)
prev = got_cost.get(key)
prio = 0 if (name == "노무비" and got["단가"] > 0) else 1 if name == "경비" else 2
if prev is None or prio < prev[0]:
got_cost[key] = (prio, mach, got)
continue
if cm and name not in SKIPW and re.search(r"(별도|별산)", cm.group("rhs")):
fp["자재"].append({"이름": cm.group("name").strip(), "품": None, "밑품": None})
continue
mm_ = _MAT.match(line)
if mm_ and not cm:
got = parse_rhs(mm_.group("rhs"))
if got:
fp["자재"].append({"이름": mm_.group("name").strip(), "품": got["품"], "밑품": got["밑품"]})
continue
if not (h or s):
ts = mtypes(line)
if ts and "%" not in line and not cm:
mach, mach_label = (ts, specnums(line)), line.strip()
for k, v in _COEF.findall(line):
fp["계수"].setdefault(k, float(v))
for (sec_, sub_, label), (_, (ts, sp), got) in got_cost.items():
fp["장비"].append(
{"갈래": sorted(ts), "규격": sorted(sp), "시간": got["품"], "밑시간": got["밑품"], "율": got["율"], "절": f"{sec_}.{sub_}" if sub_ else str(sec_), "글": label}
)
return fp
# ── 영월(STmate) ───────────────────────────────────────────────────────
def yw_tables():
import openpyxl
book = openpyxl.load_workbook(YW_FILE, read_only=True, data_only=True)
def table(name, cols):
out = {}
for r in book[name].iter_rows(values_only=True):
code = str(r[0] or "").strip()
if re.match(r"^[A-Z]\d{5}$", code):
out[code] = [str(r[c] or "").strip() for c in cols]
return out
labor = table("노무비목록표", (1,))
mach = table("중기목록표", (1, 2))
mats = table("재료비목록표", (1, 2, 3))
cost = table("경비목록표", (1, 2))
calc = [list(r) for r in book["단가산출근거"].iter_rows(values_only=True)]
book.close()
blocks, cur = {}, None
for r in calc:
first = str(r[0] or "").strip()
second = str(r[1] or "").strip() if len(r) > 1 else ""
if re.match(r"^D\d{5}$", second) and len(r) > 2 and isinstance(r[2], (int, float)):
cur = blocks.setdefault(second, [])
continue
if cur is not None:
qty = r[7] if len(r) > 7 and isinstance(r[7], (int, float)) else None
cur.append((first, str(r[1] or ""), qty))
return labor, mach, mats, cost, blocks
def yeongwol(item, T):
labor, mach, mats, cost, blocks = T
fp = empty({**item, "파일": "동부청영월"})
rows = blocks.get(item.get("코드")) or []
text = [t for _, t, _ in rows if t.strip()]
fp["글"] = [t.strip() for t in text[:60]]
head_text = " ".join([item["이름"], fp["규격"]] + text[:4])
fp["조건"] = {k: sorted(v) for k, v in conds(head_text).items()}
fp["이름조건"] = {k: sorted(v) for k, v in conds(f"{item['이름']} {fp['규격']}").items()}
fp["옛판"] = bool(_OLD.search(" ".join(text[:4])))
if not rows:
ts = mtypes(f"{item['이름']} {fp['규격']}")
if ts:
fp["장비"] = [{"갈래": sorted(ts), "규격": sorted(specnums(f"{item['이름']} {fp['규격']}")), "시간": 1.0, "밑시간": None, "절": 0, "글": item["이름"]}]
return fp
sec, sub, seen_x = 0, "", {}
item_rates = rates_in(f"{item['이름']} {fp['규격']}")
sec_rates = set()
for code, t, qty in rows:
h = _HEAD_Y.match(t)
if h:
sec, sub = int(h.group(1)), ""
sec_rates = rates_in(h.group(2))
fp["절"][str(sec)] = {"이름": h.group(2).strip(), "출처": hb.sources(h.group(2))}
elif _SUB_Y.match(t):
sub = str("①②③④⑤⑥⑦⑧⑨⑩".index(t.strip()[0]) + 1)
top = fp["절"].get(str(sec)) or {"이름": "", "출처": []}
fp["절"][f"{sec}.{sub}"] = {"이름": f"{top['이름']} · {t.strip()[1:]}", "출처": hb.sources(t) or top["출처"]}
elif _SUB_Y.match(t):
sec_rates = sec_rates | rates_in(t)
mix = rate_factor(t, item_rates | sec_rates) if code else None
for k, v in _PCT.findall(t):
fp["율"].append([k, float(v)])
if not code:
if not h:
for k, v in _COEF.findall(t):
fp["계수"].setdefault(k, float(v))
continue
base = code[:6]
if code.startswith("L") and qty is not None:
nm = TRADES.get(_flat((labor.get(base) or [""])[0]))
if nm:
b = qty / mix if mix else qty
rates = [[mix, "섞음"]] if mix else []
add_labor(fp, nm, float(qty), float(b), rates, "QTY", f"{sec}.{sub}" if sub else str(sec))
elif code.startswith("X") and qty is not None:
nm, sp = mach.get(base) or ["", ""]
key = f"{sec}.{sub}" if sub else str(sec)
prev = seen_x.get((key, base))
prio = {"L": 0, "S": 1, "M": 2}.get(code[-1], 3)
if prev is None or prio < prev[0]:
seen_x[(key, base)] = (prio, nm, sp, float(qty), mix)
elif code.startswith("M") and qty is not None:
nm, sp, un = mats.get(base) or ["", "", ""]
fp["자재"].append({"이름": f"{nm} {sp}".strip(), "품": float(qty), "밑품": float(qty)})
elif code.startswith("S") and qty is not None:
nm, sp = cost.get(base) or ["", ""]
ts = mtypes(nm)
if ts:
seen_x.setdefault((f"{sec}.{sub}" if sub else str(sec), base), (5, nm, sp, float(qty), None))
else:
fp["자재"].append({"이름": f"{nm} {sp}".strip(), "품": float(qty), "밑품": float(qty)})
for (sec_, base), (_, nm, sp, q, mx) in seen_x.items():
fp["장비"].append(
{"갈래": sorted(mtypes(nm)), "규격": sorted(specnums(sp) | specnums(nm)), "시간": q, "밑시간": q / mx if mx else q,
"율": [[mx, "섞음"]] if mx else [], "절": sec_, "글": f"{nm} {sp}"}
)
return fp
def main():
out = [north(r) for r in NORTH]
T = yw_tables()
out += [yeongwol(r, T) for r in YW_ITEMS]
for fp in out:
for one in fp["인력"].values():
one["품"] = round(one["품"], 6)
one["밑품"] = round(one["밑품"], 6)
(HERE / "excel_fp.json").write_text(json.dumps(out, ensure_ascii=False, indent=1), "utf-8")
stat = {
"고유": len(out),
"인력 있음": sum(1 for f in out if f["인력"]),
"장비 있음": sum(1 for f in out if f["장비"]),
"자재 있음": sum(1 for f in out if f["자재"]),
"율 있음": sum(1 for f in out if f["율"]),
"계수 있음": sum(1 for f in out if f["계수"]),
"조건 있음": sum(1 for f in out if f["조건"]),
"조항 있음": sum(1 for f in out if f["출처"]),
"역산 직종": sum(1 for f in out for o in f["인력"].values() if "역산" in o["어떻게"]),
"아무것도 없음": [f["id"] for f in out if not (f["인력"] or f["장비"] or f["자재"])],
}
print(json.dumps(stat, ensure_ascii=False))
want = sys.argv[1:]
for fp in out:
if fp["id"] in want:
show = {k: fp[k] for k in ("id", "이름", "규격", "단위", "출처", "인력", "장비", "자재", "율", "계수", "조건", "옛판")}
print(json.dumps(show, ensure_ascii=False))
if __name__ == "__main__":
main()
@@ -0,0 +1,253 @@
"""req226 C4 ② 우리 돈 로직 1,438 지문 — 정적(직종 · 장비 · 자재 · 덧줄 % · 조항 · 조건 낱말) +
입력 조합마다 M01 엔진으로 단위당 품(인 · 장비 시간 · 자재 수량)을 셈(읽기만 · 원본 안 고침).
조합 = 기본값(자동값) + 고르기 입력(지역 · 조종원직종 · 보정작업 · 기종 목록 뺌) 곱이 64 이하면 모두 ·
넘으면 하나씩 바꿈. 기종 목록 입력은 고를 수 있는 장비 갈래 · 규격만 적음.
python s2_ours_fp.py <시작> <끝> → ours_fp_<시작>.json
python s2_ours_fp.py merge → ours_fp.json + 덮는 범위 찍음
"""
import itertools
import json
import math
import re
import sys
import time
from decimal import Decimal
import fp_common as F
from fp_common import HERE, conds, mtypes, specnums
from M01_MasterData import M01_MasterData_Store as m01 # noqa: E402
from M02_MasterTemplete import M02_Hopyo_Basis as hb # noqa: E402
from M02_MasterTemplete import M02_Hopyo_Match as hm # noqa: E402
FILES, WHOLE = m01.loaded()
MF = m01.cm.mf
LB = {str(r.get("키")): str(r.get("이름")) for r in WHOLE.numbers.get("LB", {}).values()}
EQ = WHOLE.numbers.get("EQ", {})
_PER = re.compile(r"원\s*/\s*(\d[\d,]*\.?\d*|\.\d+)?")
_SUB = re.compile(r"^\s*로직\(\s*(\w+)\s*(?:,(.*))?\)\s*$")
_EQCODE = re.compile(r"^\d{4}-\d{4}$")
_PCT = re.compile(r"\*\s*([\d.]+)\s*/\s*100")
SKIP_IN = ("지역", "조종원직종", "보정작업")
FIXED_MACH = {"GC000267": {"굴착기"}, "GC000270": {"굴착기", "집게"}, "GC000271": {"굴착기", "브레이커"}, "GF000145": {"덤프트럭"}}
def is_mach_logic(row) -> bool:
return "시간당" in str(row.get("이름") or "")
def eq_of(code):
one = EQ.get(str(code)) or {}
if not one:
return set(), set()
return mtypes(f"{one.get('상세구분')} {one.get('이름')}"), specnums(one.get("규격"))
def times_of(row) -> float:
per = _PER.findall(str(row.get("결과단위") or ""))
return float(per[0].replace(",", "")) if per and per[0] else 1.0
def _args(text):
out = {}
for part in re.split(r",(?![^()]*\))", text or ""):
if "=" in part:
k, v = part.split("=", 1)
out[k.strip()] = v.strip()
return out
def static(code, row, unit):
our = hb.ours().get(code, {})
picks = [s for s in row.get("입력") or [] if s.get("고르기")]
labels = []
opt_mach = []
for s in picks:
names = s.get("고르기이름") or {}
for v in s.get("고르기") or []:
if isinstance(v, str) and _EQCODE.match(v):
ts, sp = eq_of(v)
if ts:
opt_mach.append([sorted(ts), sorted(sp)])
else:
labels.append(str(names.get(v) or v))
trades, mach, mats = set(), [], []
for h in row.get("호표") or []:
kind = h.get("종류")
if kind == "인력":
trades.add(LB.get(str(h.get("요소")), str(h.get("이름"))))
elif kind == "로직":
m = _SUB.match(str(h.get("요소") or ""))
sub = m.group(1) if m else ""
ts = set(FIXED_MACH.get(sub, set())) | mtypes(h.get("이름"))
for v in _args(m.group(2) if m else "").values():
lit = v.strip("'\"")
if _EQCODE.match(lit):
a, b = eq_of(lit)
ts |= a
if ts:
mach.append([sorted(ts), sorted(specnums(h.get("이름")))])
elif kind == "재료":
el = h.get("요소")
words = el.get("검색어") or el.get("이름") or el.get("상세구분") if isinstance(el, dict) else ""
mats.append(f"{h.get('이름')} {words or ''}".strip())
pct = []
for d in row.get("덧줄") or []:
for v in _PCT.findall(str(d.get("식") or "")):
pct.append([str(d.get("이름")), float(v)])
if is_mach_logic(row):
mach.append([sorted(FIXED_MACH.get(code, set())), []])
return {
"로직": code,
"이름": str(row.get("이름") or ""),
"단위": unit,
"결과단위": str(row.get("결과단위") or ""),
"책": our.get("책"),
"부문": our.get("부문"),
"조항": sorted(our.get("조항") or []),
"원문번호": str(row.get("원문번호") or ""),
"상세구분": str(row.get("상세구분") or ""),
"직종": sorted(trades),
"장비": mach,
"장비고름": opt_mach,
"자재": mats,
"덧줄율": pct,
"이름조건": {k: sorted(v) for k, v in conds(row.get("이름")).items()},
"고름조건": {k: sorted(v) for k, v in conds(" ".join(labels + [str(s.get("이름")) for s in picks])).items()},
"기계경비": is_mach_logic(row),
}
def combos(row):
picks = []
for s in row.get("입력") or []:
opts = s.get("고르기") or []
if not opts or any(w in str(s.get("이름")) for w in SKIP_IN):
continue
if any(isinstance(v, str) and _EQCODE.match(v) for v in opts):
continue
picks.append((str(s["이름"]), list(opts)))
if not picks:
return [{}]
if math.prod(len(o) for _, o in picks) <= 64:
return [dict(zip([n for n, _ in picks], vals)) for vals in itertools.product(*[o for _, o in picks])]
out = [{}]
for n, opts in picks:
out += [{n: v} for v in opts]
return out
def run_one(code, row, given, times, by_name):
auto = {k: v for k, v in m01.mau.values(WHOLE, row).items() if v is not None}
got = MF.run(WHOLE, code, {**auto, **m01._dec(given)})
mid = got.get("중간") or {}
L, X, M, S = {}, [], {}, {}
for line in got.get("줄") or []:
h = by_name.get(line.get("이름"))
if h is None:
continue
try:
q = float(Decimal(str(line.get("수량") or 0))) / times
except ArithmeticError:
continue
kind = h.get("종류")
if kind == "인력":
t = LB.get(str(h.get("요소")), str(h.get("이름")))
L[t] = L.get(t, 0.0) + q
elif kind == "로직":
m = _SUB.match(str(h.get("요소") or ""))
sub = m.group(1) if m else ""
sub_row = WHOLE.logic(sub) if sub else {}
if sub_row and is_mach_logic(sub_row):
ts, sp = set(FIXED_MACH.get(sub, set())) | mtypes(h.get("이름")), specnums(h.get("이름"))
for v in _args(m.group(2)).values():
lit = v.strip("'\"")
if not _EQCODE.match(lit):
lit = str(mid.get(lit) or given.get(lit) or auto.get(lit) or "")
if _EQCODE.match(lit):
a, b = eq_of(lit)
ts |= a
sp |= b
if q:
X.append([sorted(ts), sorted(sp), q])
elif q:
S[sub] = S.get(sub, 0.0) + q
elif kind == "재료" and q:
M[str(h.get("이름"))] = M.get(str(h.get("이름")), 0.0) + q
mix = {}
for k in ("인력비율", "기계비율"):
try:
if mid.get(k) not in (None, ""):
mix[k] = float(Decimal(str(mid.get(k))))
except ArithmeticError:
pass
return {"L": L, "X": X, "M": M, "S": S, "mix": mix}
def _key(v):
r = lambda x: float(f"{x:.4g}") # noqa: E731
return json.dumps(
[sorted((k, r(q)) for k, q in v["L"].items()), sorted((tuple(a), tuple(b), r(q)) for a, b, q in v["X"]), sorted((k, r(q)) for k, q in v["M"].items())],
ensure_ascii=False,
)
def fingerprint(one):
code = one["로직"]
row = WHOLE.logic(code)
fp = static(code, row, one["단위"])
times = times_of(row)
by_name = {}
for h in row.get("호표") or []:
by_name.setdefault(h.get("이름"), h)
seen, variants, errors = set(), [], 0
for given in combos(row) if not fp["기계경비"] else [{}]:
try:
v = run_one(code, row, given, times, by_name)
except Exception: # noqa: BLE001 — 못 도는 조합은 셈만
errors += 1
continue
k = _key(v)
if k in seen:
continue
seen.add(k)
variants.append({"in": given, **v})
fp["갈래수"] = len(variants)
fp["오류"] = errors
fp["변형"] = variants
return fp
def main():
got = hm.targets()
if sys.argv[1:] == ["merge"]:
out = []
for p in sorted(HERE.glob("ours_fp_*.json"), key=lambda p: int(p.stem.split("_")[-1])):
out += json.loads(p.read_text(encoding="utf-8"))
(HERE / "ours_fp.json").write_text(json.dumps(out, ensure_ascii=False, default=str), "utf-8")
ran = [f for f in out if f["변형"]]
stat = {
"로직": len(out),
"셈 됨(변형 1+)": len(ran),
"못 셈": [f["로직"] for f in out if not f["변형"]][:40],
"인력 있음": sum(1 for f in ran if any(v["L"] for v in f["변형"])),
"장비 있음": sum(1 for f in out if f["장비"] or any(v["X"] for v in f["변형"])),
"자재 있음": sum(1 for f in out if f["자재"]),
"기계경비 로직": sum(1 for f in out if f["기계경비"]),
"변형 합": sum(f["갈래수"] for f in out),
"변형 2+": sum(1 for f in out if f["갈래수"] > 1),
"조항 있음": sum(1 for f in out if f["조항"]),
}
print(json.dumps(stat, ensure_ascii=False))
return
a, b = int(sys.argv[1]), int(sys.argv[2])
t0 = time.time()
out = [fingerprint(one) for one in got["로직"][a:b]]
(HERE / f"ours_fp_{a}.json").write_text(json.dumps(out, ensure_ascii=False, default=str), "utf-8")
print(a, b, len(out), "변형", sum(f["갈래수"] for f in out), "오류", sum(f["오류"] for f in out), round(time.time() - t0, 1), "s")
if __name__ == "__main__":
main()
@@ -0,0 +1,355 @@
"""req226 C4 ③ 지문 비교 맞춤 흉내 — 직종 · 품 벡터 · 장비 · 자재 · 단위 · 조건 · 조항(한 신호) · 이름(약한 덤).
금액 안 씀. 앞 조사와 같은 정답(labels · labels_y) · 같은 셈(s10_all.score)으로 지금 · 묶음 ① 과 맞댐.
python s3_match.py → 표 · out_match.json(줄마다 1~5 위 · 채널 · 고른 입력)
"""
import json
import sys
import fp_common as F
from fp_common import HERE, close, cond_score, cosine, jacc
import s10_all as A # noqa: E402 (C1 — 정답 · 셈 · 지금 돌리기)
from cats import NOW, ORDER # noqa: E402
from s04_eval import clause_prefix # noqa: E402
from s20_c3_extra import combo # noqa: E402
from M01_MasterData import M01_MasterData_Store as m01 # noqa: E402
from M02_MasterTemplete import M02_Hopyo_Basis as hb # noqa: E402
from M02_MasterTemplete import M02_Hopyo_Match as hm # noqa: E402
EX = {e["id"]: e for e in json.loads((HERE / "excel_fp.json").read_text(encoding="utf-8"))}
OURS = json.loads((HERE / "ours_fp.json").read_text(encoding="utf-8"))
TG = {x["로직"]: x for x in hm.targets()["로직"]}
OURC = hb.ours()
W = {"인력": 3.0, "장비": 2.0, "자재": 1.0, "율": 0.3, "단위": 0.5, "조건": 0.6, "조항": 1.5, "이름": 0.6}
WHOLE = m01.loaded()[1]
def prep(o):
"""맞춤용 정리 — 기계경비 로직은 조종원 뺌 · 모두 0 인 변형 뺌 · 고를 수 있는 장비."""
vs = []
for v in o["변형"]:
L = {} if o["기계경비"] else {t: q for t, q in v["L"].items() if q > 0}
X = [(set(a), set(b), q) for a, b, q in v["X"] if q > 0]
M = {k: q for k, q in v["M"].items() if q > 0}
if L or X or M or not (v["L"] or v["X"] or v["M"]):
vs.append({"in": v["in"], "L": L, "X": X, "M": M})
mix = v.get("mix") or {}
ri, rm = mix.get("인력비율"), mix.get("기계비율")
if (ri and 0 < ri < 100) or (rm and 0 < rm < 100): # 섞기 전 품(인력 · 기계 100%)
Lu = {t: q * 100 / ri for t, q in L.items()} if ri and 0 < ri < 100 else L
Xu = [(a, b, q * 100 / rm) for a, b, q in X] if rm and 0 < rm < 100 else X
vs.append({"in": {**v["in"], "_섞기전": f"인력 {ri:g}% · 기계 {rm:g}% → 100%"}, "L": Lu, "X": Xu, "M": M})
if not vs: # 엔진이 못 돈 로직 — 정적 지문(직종 이름만 · 품 모름)
vs = [{"in": None, "L": {t: None for t in o["직종"]}, "X": [(set(a), set(b), None) for a, b in o["장비"] if a], "M": {}}]
opt = []
if o["기계경비"]:
ts = set().union(*[set(a) for a, _ in o["장비고름"]], *[set(a) for a, _ in o["장비"]]) if (o["장비고름"] or o["장비"]) else set()
sp = set().union(*[set(b) for _, b in o["장비고름"]]) if o["장비고름"] else set()
opt = [(ts, sp)]
mats = set()
for name in o["자재"]:
mats |= mat_words(name)
for v in vs:
for name in v["M"]:
mats |= mat_words(name)
return {
**o,
"vs": vs,
"opt": opt,
"matw": mats,
"pctv": {round(p, 3) for _, p in o["덧줄율"]},
"nameg": TG[o["로직"]]["grams"],
"nc": {k: set(v) for k, v in o["이름조건"].items()},
"oc": {k: set(v) for k, v in o["고름조건"].items()},
"has_L": any(v["L"] for v in vs),
"has_X": any(v["X"] for v in vs) or bool(opt),
}
def mat_words(name):
t = F.unicodedata.normalize("NFKC", str(name or "")).lower().replace(".", "").replace(" ", "")
out = set(hm.words(name))
if "pvc" in t:
out.add("pvc")
return {w for w in out if len(w) >= 2}
P = [prep(o) for o in OURS]
def lab_sim(E, Eb, O, unit_ok):
if not O:
return 0.0, None
keys = set(E) | set(O)
w = lambda t: 0.5 if t == "보통인부" else 1.0 # noqa: E731
st = sum(w(t) for t in set(E) & set(O)) / sum(w(t) for t in keys)
if any(q is None for q in O.values()): # 품 모름(정적) — 꼴 = 직종 겹침으로 갈음 · 크기 덤 없음
return 0.6 * st, None
shape = max(cosine(E, O), cosine(Eb, O))
if not unit_ok: # 단위가 달라 크기를 못 견줌 — 크기 덤 없음
return 0.35 * st + 0.25 * shape, None
mag = sum(max(close(E.get(t), O.get(t)), close(Eb.get(t), O.get(t))) for t in keys) / len(keys)
return 0.35 * st + 0.25 * shape + 0.40 * mag, mag
def mach_sim(EM, OX, opt, unit_ok):
if not OX and not opt:
return 0.0
got = []
for m in EM:
mt, ms = set(m["갈래"]), set(m["규격"])
best = 0.0
for ot, osp, oh in OX:
j = jacc(mt, ot)
if not j:
continue
sp = (1.0 if ms & osp else 0.0) if ms and osp else 0.5
hc = max(close(m.get("시간"), oh), close(m.get("밑시간"), oh)) if unit_ok and oh else 0.0
best = max(best, 0.6 * j + 0.2 * sp + 0.2 * hc)
for ot, osp in opt:
if mt and mt <= ot:
sp = (1.0 if ms & osp else 0.0) if ms and osp else 0.5
best = max(best, 0.6 + 0.2 * sp + 0.2 * (1.0 if unit_ok else 0.0))
got.append(best)
s = sum(got) / len(got)
et = set().union(*[set(m["갈래"]) for m in EM])
ot_all = set().union(*[t for t, _, _ in OX]) if OX else set()
extra = len(ot_all - et) / max(1, len(ot_all | et)) if ot_all else 0.0
return s * (1 - 0.5 * extra)
def ex_prep(e):
E = {t: o["품"] for t, o in e["인력"].items() if o["품"]}
Eb = {t: o["밑품"] for t, o in e["인력"].items() if o["밑품"]}
mats = [mat_words(m["이름"]) for m in e["자재"]]
return {
**e,
"E": E,
"Eb": Eb,
"matl": [m for m in mats if m],
"pctv": {round(v, 3) for _, v in e["율"]},
"nameg": hm._grams(hm.tidy(e["이름"])),
"theirs": [list(x) for x in e["출처"]],
"조건": {k: set(v) for k, v in e["조건"].items()},
"이름조건": {k: set(v) for k, v in e["이름조건"].items()},
}
COUNT = {"개소", "개", "본", "ea", "조", "매", "대", "기", "공"}
HOUR = {"hr", "시간", "h"}
def unit_fit(a, b):
"""단위 맞음 + 별칭(개소 = 개 = 본 · hr = 시간 · 공/㎥ = 공㎥) — 품 크기 견줌에만."""
if hm.fits(a, b):
return True
na = F.re.sub(r"[\s/]", "", str(a or "")).lower()
nbs = [F.re.sub(r"[\s/]", "", x).lower() for x in str(b or "").split("·")]
for nb in nbs:
if na == nb or (na in COUNT and nb in COUNT) or (na in HOUR and nb in HOUR):
return True
return False
def score_pair(e, o, use=None):
"""(점수, 채널, 고른 변형) — use = 쓸 채널(None = 모두)."""
use = use or W
unit_ok = unit_fit(e["단위"], o["단위"])
ch = {"단위": 1.0 if unit_ok else 0.0}
g1, g2 = e["nameg"], o["nameg"]
ch["이름"] = 2 * len(g1 & g2) / (len(g1) + len(g2)) if g1 and g2 else 0.0
our = OURC.get(o["로직"])
c = clause_prefix(e["theirs"], our)[0] if (our and e["theirs"]) else 0.0
ch["조항"] = c * (0.5 if e["옛판"] else 1.0)
hit, _ = cond_score(e["조건"], o["nc"], o["oc"])
_, clash = cond_score(e["이름조건"], o["nc"], {})
ch["조건"] = hit - clash
if e["matl"]:
ch["자재"] = sum(1 for ws in e["matl"] if any(a in b or b in a for a in ws for b in o["matw"])) / len(e["matl"])
ch["율"] = 1.0 if (e["pctv"] and o["pctv"] and e["pctv"] & o["pctv"]) else 0.0
base = sum(use.get(k, 0) * v for k, v in ch.items())
best, bestv, bestch = None, None, None
for v in o["vs"]:
s, vch = base, {}
if e["E"]:
ls, mag = lab_sim(e["E"], e["Eb"], v["L"], unit_ok)
vch["인력"], vch["품"] = ls, mag
s += use.get("인력", 0) * ls
elif v["L"] and "인력" in use:
s -= 0.6
if e["장비"]:
ms = mach_sim(e["장비"], v["X"], o["opt"], unit_ok)
vch["장비"] = ms
s += use.get("장비", 0) * ms
elif (v["X"] or o["opt"]) and "장비" in use:
s -= 0.4
if best is None or s > best:
best, bestv, bestch = s, v, vch
return best, {**ch, **bestch}, bestv
def rank(e, use=None, n=5):
scored = []
for o in P:
s, ch, v = score_pair(e, o, use)
scored.append((s, o["로직"], ch, v))
scored.sort(key=lambda x: -x[0])
return scored[:n]
RB = {0: 1.0, 1: 0.6, 2: 0.45, 3: 0.35, 4: 0.25}
def sure(e, pool):
"""숫자가 딱 맞음 — 1위의 직종 · 품(3% 안)이 다 맞거나(인력 줄) 장비 갈래 · 시간이 맞음(장비만 줄) ·
2위와 점수 차 0.3 넘음."""
if not pool:
return False
s1, _, ch, _ = pool[0]
gap = s1 - pool[1][0] if len(pool) > 1 else 9
if e["E"]:
rich = len(e["E"]) >= 2 or set(e["E"]) != {"보통인부"} or bool(e["장비"]) # 보통인부 하나뿐이면 우연 같음
ok = rich and (ch.get("품") or 0) >= 0.97 and ch.get("인력", 0) >= 0.97
elif e["장비"]:
ok = ch.get("장비", 0) >= 0.95
else:
ok = False
return ok and gap > 0.3
def fuse(fp_top, base, beta=2.0, pool=None):
"""지문 점수 + β × 묶음 ① 차례 덤 → 다시 다섯."""
bonus = {k: RB.get(i, 0) for i, k in enumerate(base["top"][:5])}
cand = {k: s for s, k, _, _ in (pool or fp_top)}
for k in bonus:
if k not in cand:
o = next(x for x in P if x["로직"] == k)
cand[k] = score_pair(EXP[CUR["id"]], o)[0]
out = sorted(cand, key=lambda k: -(cand[k] + beta * bonus.get(k, 0)))
return out[:5]
CUR = {"id": None}
EXP = {i: ex_prep(e) for i, e in EX.items()}
def as_results(tops):
return {i: {"상태": "후보", "top": t} for i, t in tops.items()}
def fails_by_cat(res, base_fail):
"""지금 실패 36 의 갈래별 — 고쳐짐 수 / 갈래 수 · 새 실패."""
out = {}
for i in base_fail:
cat = (NOW.get(i) or ["(새)"])[0]
ok = bool(res[i]["top"]) and res[i]["top"][0] in set(A.LAB[i]["정답"])
a = out.setdefault(cat, [0, 0, []])
a[1] += 1
a[0] += ok
if not ok:
a[2].append(i)
return out
def main():
ids = list(A.BY)
have = [i for i in ids if A.LAB[i]["정답"]]
now = A.run()
rec = combo()
tops, detail, pools = {}, {}, {}
variants = {
"지문 전부": None,
"지문 − 조항": {k: v for k, v in W.items() if k != "조항"},
"지문 − 이름": {k: v for k, v in W.items() if k != "이름"},
"지문 − 조항 − 이름(속만)": {k: v for k, v in W.items() if k not in ("조항", "이름")},
"지문 − 품(직종 · 장비 갈래만)": "noqty",
}
rows, by_var = [], {}
for name, use in variants.items():
t = {}
for i in ids:
e = EXP[i]
if use == "noqty":
e = {**e, "E": {k: 1.0 for k in e["E"]}, "Eb": {k: 1.0 for k in e["E"]}, "장비": [{**m, "시간": None, "밑시간": None} for m in e["장비"]]}
top = rank(e, None if use == "noqty" else use, 40)
t[i] = [k for _, k, _, _ in top[:5]]
if name == "지문 전부":
pools[i] = top
detail[i] = [{"로직": k, "점수": round(s, 3), "채널": {a: (round(b, 3) if isinstance(b, float) else b) for a, b in ch.items()}, "입력": v["in"] if v else None} for s, k, ch, v in top[:5]]
by_var[name] = t
rows.append(A.table(name, as_results(t)))
base_rows = [A.table("지금", now), A.table("묶음 ①", rec)]
fused = {}
for beta in (1.0, 2.0, 3.0):
t = {}
for i in ids:
CUR["id"] = i
t[i] = fuse(None, rec[i], beta, pools[i])
fused[beta] = t
rows.append(A.table(f"지문 + 묶음 ① (β={beta:g})", as_results(t)))
t = {}
for i in ids:
CUR["id"] = i
t[i] = fuse(None, now[i], 2.0, pools[i])
rows.append(A.table("지문 + 지금 (β=2)", as_results(t)))
keep_same = {i: (rec[i]["top"] if rec[i]["상태"] == "같음" else by_var["지문 전부"][i]) for i in ids}
rows.append(A.table("같음은 묶음 ① · 나머지 지문", as_results(keep_same)))
keep_same2 = {i: (rec[i]["top"] if rec[i]["상태"] == "같음" else fused[1.0][i]) for i in ids}
rows.append(A.table("같음은 묶음 ① · 나머지 지문+① β=1", as_results(keep_same2)))
print("묶음 ① 같음 줄", sum(1 for i in ids if rec[i]["상태"] == "같음"))
gate = {}
for i in ids:
gate[i] = by_var["지문 전부"][i] if sure(EXP[i], pools[i]) else fused[2.0][i]
rows.append(A.table("지문 우선(딱 맞음) + 묶음 ①", as_results(gate)))
with A.no_seeds():
rows.append(A.table("지금 (씨앗 끔)", A.run()))
rec_off = combo()
rows.append(A.table("묶음 ① (씨앗 끔)", rec_off))
t2, g2 = {}, {}
for i in ids:
CUR["id"] = i
t2[i] = fuse(None, rec_off[i], 2.0, pools[i])
g2[i] = by_var["지문 전부"][i] if sure(EXP[i], pools[i]) else t2[i]
rows.append(A.table("지문 + 묶음 ① (씨앗 끔 β=2)", as_results(t2)))
rows.append(A.table("지문 우선 + 묶음 ① (씨앗 끔)", as_results(g2)))
k2 = {}
for i in ids:
CUR["id"] = i
k2[i] = rec_off[i]["top"] if rec_off[i]["상태"] == "같음" else fuse(None, rec_off[i], 1.0, pools[i])
rows.append(A.table("같음은 묶음 ① · 나머지 지문+① β=1 (씨앗 끔)", as_results(k2)))
print("딱 맞음 줄", sum(1 for i in ids if sure(EXP[i], pools[i])), "/", len(ids),
"· 그중 지문 1위 엄격 맞음", sum(1 for i in have if sure(EXP[i], pools[i]) and by_var["지문 전부"][i][:1] and by_var["지문 전부"][i][0] in set(A.LAB[i]["정답"])),
"/", sum(1 for i in have if sure(EXP[i], pools[i])))
for r in base_rows + rows:
A.show(r)
# 실패 갈래별
base_fail = [i for i in have if not now[i]["top"] or now[i]["top"][0] not in set(A.LAB[i]["정답"])]
cats = {
"지금": fails_by_cat(now, base_fail),
"묶음 ①": fails_by_cat(rec, base_fail),
"지문": fails_by_cat(as_results(by_var["지문 전부"]), base_fail),
"지문 + 묶음 ①": fails_by_cat(as_results(fused[2.0]), base_fail),
}
for k, v in cats.items():
print(k, {c: f"{a}/{b}" for c, (a, b, _) in v.items()})
newfail = {}
for k, res in (("지문", as_results(by_var["지문 전부"])), ("지문 + 묶음 ①", as_results(fused[2.0])), ("묶음 ①", rec)):
newfail[k] = [i for i in have if i not in base_fail and (not res[i]["top"] or res[i]["top"][0] not in set(A.LAB[i]["정답"]))]
print("새 실패", k, newfail[k])
out = {
"표": base_rows + rows,
"갈래": {k: {c: v for c, v in d.items()} for k, d in cats.items()},
"새 실패": newfail,
"추천": keep_same2,
"딱 맞음": [i for i in ids if sure(EXP[i], pools[i])],
"줄": {i: {"1~5": detail[i], "지문만": by_var["지문 전부"][i], "지문+묶음①": fused[2.0][i], "묶음①": rec[i]["top"][:5], "지금": now[i]["top"][:5], "정답": A.LAB[i]["정답"], "허용": A.LAB[i]["허용"]} for i in ids},
}
(HERE / "out_match.json").write_text(json.dumps(out, ensure_ascii=False, indent=1, default=str), "utf-8")
if __name__ == "__main__":
sys.setrecursionlimit(10000)
main()
@@ -0,0 +1,330 @@
"""req226 C4 ④⑤ — 절(몫)마다 맞춤 → 로직 조합 · 「편의대로 바꿈」 꼴 나눔 · 「로직 X · 계수 바뀜」 판정.
정답 있는 줄 전부(고유 126)에서 엑셀 지문 ↔ 정답 로직(가장 닮은 변형)을 견줌. 금액 안 씀.
python s4_changes.py → 표 찍음 · out_changes.json
"""
import json
from collections import Counter, defaultdict
import s3_match as S
from fp_common import HERE, close
A, P, EXP, TG, OURC = S.A, S.P, S.EXP, S.TG, S.OURC
BYCODE = {o["로직"]: o for o in P}
COMPOSITE = {
**{i: {"GC000206", "GC000207"} for i in ("N003", "N019", "N045", "S055", "S059", "Y010")},
"N050": {"GC000705", "GC000714", "GC000712"},
"S062": {"GC000559", "GC000560"},
}
ALL_CLAUSES = defaultdict(set)
for o in P:
for n in o["조항"]:
ALL_CLAUSES[o["책"]].add(n)
TOL = 0.03
def work_sections(e):
secs = {}
for sec, t, q, b in e["인력절"]:
one = secs.setdefault(str(sec), {"인력": {}, "장비": []})
a = one["인력"].setdefault(t, {"품": 0.0, "밑품": 0.0, "율": [], "어떻게": []})
a["품"] += q
a["밑품"] += b
for m in e["장비"]:
secs.setdefault(str(m["절"]), {"인력": {}, "장비": []})["장비"].append(m)
return {k: v for k, v in secs.items() if v["인력"] or v["장비"]}
def _order(key):
a, _, b = str(key).partition(".")
return (int(a) if a.isdigit() else 0, b)
def section_fp(e, sec, part):
head = e["절"].get(sec) or e["절"].get(str(sec).split(".")[0]) or {}
sub = {**{k: v for k, v in e.items() if k in S.EX[e["id"]]}, "인력": part["인력"], "장비": part["장비"], "자재": [], "율": []}
sub["이름"] = f"{e['이름']} {head.get('이름') or ''}".strip()
sub["출처"] = head.get("출처") or e["출처"]
sub["조건"] = {k: sorted(v) for k, v in e["조건"].items()}
sub["이름조건"] = {}
return S.ex_prep(sub)
def combination(i):
e = EXP[i]
secs = work_sections(e)
if len(secs) < 2:
return None
out = []
for sec, part in sorted(secs.items(), key=lambda x: _order(x[0])):
top = S.rank(section_fp(e, sec, part), None, 3)
out.append({"절": sec, "이름": (e["절"].get(sec) or {}).get("이름", ""), "1위": top[0][1], "점수": round(top[0][0], 2), "2위": top[1][1]})
return out
def compare(e, code):
"""엑셀 ↔ 우리 로직(가장 닮은 변형) — 직종마다 (엑셀 품, 밑품, 우리 품) · 판정."""
o = BYCODE[code]
s, ch, v = S.score_pair(e, o)
O = {t: q for t, q in ((v or {}).get("L") or {}).items() if q}
E = {t: x["품"] for t, x in e["인력"].items()}
B = {t: x["밑품"] for t, x in e["인력"].items()}
unit_ok = S.unit_fit(e["단위"], o["단위"])
rows, same, base_only, changed = [], True, False, []
for t in sorted(set(E) | set(O)):
a, b, c = E.get(t), B.get(t), O.get(t)
rows.append([t, a and round(a, 4), b and round(b, 4), c and round(c, 4)])
if a is None or c is None or not c:
same = False
continue
if abs(a / c - 1) <= TOL:
continue
if b and abs(b / c - 1) <= TOL:
base_only = True
continue
same = False
changed.append(f"{t} {c:.3g} → {a:.3g}")
trades_same = set(E) == set(O) and bool(E)
if not E and not O:
verdict = mach_verdict(e, v, o, unit_ok)
elif not unit_ok:
verdict = "단위 달라 크기 못 견줌"
elif any(q is None for q in ((v or {}).get("L") or {}).values()) or (v or {}).get("in") is None:
verdict = "우리 품 모름(엔진 못 돎)"
elif not trades_same:
verdict = "직종 다름"
elif same and not base_only:
verdict = "같음(3% 안)"
elif same and base_only:
verdict = "섞음 · 할증 붙임(밑품 같음)"
else:
verdict = "계수 바뀜"
return {"판정": verdict, "직종": rows, "바뀜": changed, "변형": (v or {}).get("in"), "점수": round(s, 2), "채널": {k: (round(x, 2) if isinstance(x, float) else x) for k, x in ch.items()}}
def mach_verdict(e, v, o, unit_ok):
"""인력 없는 줄 — 장비 갈래 · 시간으로."""
EM = e["장비"]
OX = list((v or {}).get("X") or [])
if not EM:
return "자재 · 그 밖(품 없음)"
if o["기계경비"]:
return "장비 시간 줄(기계경비 로직)"
et = set().union(*[set(m["갈래"]) for m in EM])
ot = set().union(*[set(a) for a, _, _ in OX]) if OX else set()
if et != ot:
return "장비 갈래 다름"
if not unit_ok:
return "단위 달라 크기 못 견줌"
ok = all(any(set(m["갈래"]) & a and (close(m.get("시간"), q) >= 0.99 or close(m.get("밑시간"), q) >= 0.99) for a, _, q in OX) for m in EM)
return "같음(3% 안)" if ok else "계수 바뀜"
def extra_work(e, code):
"""엑셀에 있는데 정답 로직(모든 변형 · 고를 수 있는 장비)엔 없는 장비 갈래 · 직종."""
o = BYCODE[code]
ot = set()
tr = set(o["직종"])
for v in o["vs"]:
for a, _, _ in v["X"]:
ot |= set(a)
tr |= set(v["L"])
for a, _ in o["opt"]:
ot |= set(a)
for a, _ in o["장비"]:
ot |= set(a)
et = set().union(*[set(m["갈래"]) for m in e["장비"]]) if e["장비"] else set()
return sorted(et - ot), sorted(set(e["인력"]) - tr)
def section_compare(i):
"""복합 정답 줄 — 절마다 가장 닮은 정답 몫과 견줌."""
e = EXP[i]
out = []
for sec, part in sorted(work_sections(e).items(), key=lambda x: _order(x[0])):
sub = section_fp(e, sec, part)
code = max(COMPOSITE[i], key=lambda k: S.score_pair(sub, BYCODE[k])[0])
c = compare(sub, code)
out.append({"절": sec, "로직": code, "판정": c["판정"], "바뀜": c["바뀜"]})
return out
def clause_kind(e, code):
if not e["출처"]:
return "출처 없음"
our = OURC.get(code)
c = S.clause_prefix(e["theirs"], our)[0] if our else 0
if c >= 0.9:
return "조항 같음"
books = {b for b, _ in e["출처"]}
if our and our["책"] not in books:
return "다른 책 조항"
missing = [n for b, n in e["출처"] if n not in ALL_CLAUSES.get(b, set()) and not any(x.startswith(n + "-") for x in ALL_CLAUSES.get(b, set()))]
if e["옛판"] or missing:
return "옛 판 · 없는 번호"
return "다른 번호(같은 책)"
def pick_answer(i):
e = EXP[i]
lab = A.LAB[i]["정답"]
return max(lab, key=lambda k: S.score_pair(e, BYCODE[k])[0])
def splits(ids):
"""쪼갬 — 같은 파일 · 같은 정답 로직 줄 둘의 품을 더하면 우리 한 변형의 꼴(비율)과 같음(코사인 0.98+) ·
한 줄만으로는 그 꼴이 아님."""
from fp_common import cosine
found = set()
by = defaultdict(list)
for i in ids:
if A.LAB[i]["정답"] and i not in COMPOSITE:
by[(A.BY[i]["파일"], pick_answer(i))].append(i)
for (f, code), group in by.items():
vs = [v["L"] for v in BYCODE[code]["vs"] if v["L"] and all(q for q in v["L"].values())]
for a in group:
for b in group:
if a >= b or not EXP[a]["E"] or not EXP[b]["E"]:
continue
if set(EXP[a]["E"]) == set(EXP[b]["E"]):
continue # 같은 일 되풀이(규격만 다름)
both = {t: EXP[a]["E"].get(t, 0) + EXP[b]["E"].get(t, 0) for t in set(EXP[a]["E"]) | set(EXP[b]["E"])}
whole = max((cosine(both, O) for O in vs), default=0)
alone = max((max(cosine(EXP[a]["E"], O), cosine(EXP[b]["E"], O)) for O in vs), default=0)
if whole >= 0.98 and alone < 0.95:
found |= {a, b}
return found
def main():
ids = [i for i in A.BY if A.LAB[i]["정답"]]
res = json.loads((HERE / "out_match.json").read_text(encoding="utf-8"))["줄"]
# ── 로직 조합(절마다) ──
combos = {i: combination(i) for i in A.BY}
multi = [i for i in A.BY if combos[i]]
comp_rows = []
for i, truth in COMPOSITE.items():
got = {c["1위"] for c in combos[i] or []}
top1 = res[i]["지문만"][:1]
comp_rows.append({"id": i, "이름": A.BY[i]["이름"], "정답 조합": sorted(truth), "절 1위": [c["1위"] for c in combos[i] or []], "조합 맞음": truth <= got, "줄 1위": top1})
print("절 둘 이상 줄", len(multi), "· 복합 정답 줄", len(COMPOSITE), "· 조합 맞음", sum(r["조합 맞음"] for r in comp_rows))
for r in comp_rows:
print(" ", r)
# 조합 맞음 지표 = 복합 줄은 절 1위 묶음 ⊇ 정답 조합 · 나머지는 줄 1위 엄격
def combo_ok(i, top1):
if i in COMPOSITE:
return COMPOSITE[i] <= {c["1위"] for c in combos[i] or []}
return bool(top1) and top1[0] in set(A.LAB[i]["정답"])
inside = sum(1 for i in ids if combos[i] and set(A.LAB[i]["정답"]) & {c["1위"] for c in combos[i]})
print("절 둘 이상 줄 중 정답 로직이 절 1위 묶음 안에 있음", inside, "/", sum(1 for i in ids if combos[i]))
for key in ("지금", "묶음①", "지문만", "지문+묶음①"):
ok = sum(combo_ok(i, res[i][key]) for i in ids)
comp_only = sum(1 for i in COMPOSITE if res[i][key][:1] and res[i][key][0] in COMPOSITE[i])
print(f"조합 맞음 {key}: {ok}/{len(ids)} = {100 * ok / len(ids):.1f}% (복합 8 줄 1위가 한 몫이라도 맞음 {comp_only})")
# ── 바꿈 꼴 ──
sp = splits(ids)
rows, kinds = [], Counter()
for i in ids:
e = EXP[i]
code = sorted(COMPOSITE[i])[0] if i in COMPOSITE else pick_answer(i)
if i in COMPOSITE:
secs = section_compare(i)
bad = [x for x in secs if x["판정"] not in ("같음(3% 안)", "섞음 · 할증 붙임(밑품 같음)")]
cmp_ = {"판정": "조합 · " + ("같음(3% 안)" if not bad else "계수 바뀜"), "바뀜": [f"{x['로직']} {b}" for x in bad for b in x["바뀜"]], "직종": [], "변형": None, "절": secs}
else:
cmp_ = compare(e, code)
ck = clause_kind(e, code)
rates = [r for x in e["인력"].values() for r in x["율"]] + [r for m in e["장비"] for r in (m.get("율") or [])]
mix = any(n in ("%", "섞음") and 0 < v < 1 for v, n in rates) and bool(e["장비"]) and bool(e["인력"])
bonus = any(n not in ("%", "섞음") for v, n in rates)
xm, xt = extra_work(e, code)
bundle = i in COMPOSITE or bool(xm)
tags = []
if S.hm.tidy(e["이름"]) != TG[code]["tidy"]:
tags.append("이름 다름")
if ck != "조항 같음":
tags.append(ck)
if bundle:
tags.append("묶음")
if i in sp:
tags.append("쪼갬")
if mix:
tags.append("섞음")
if bonus:
tags.append("할증 · 횟수 · 보정 붙임")
v = cmp_["판정"]
if v.endswith("계수 바뀜"):
tags.append("계수 바뀜")
if v == "직종 다름":
tags.append("직종 다름")
if v in ("단위 달라 크기 못 견줌",):
tags.append("단위 다름")
same = v in ("같음(3% 안)", "섞음 · 할증 붙임(밑품 같음)", "장비 시간 줄(기계경비 로직)")
if same and not bundle and not mix and not bonus and i not in sp:
tags.append("이름만" if ck == "조항 같음" else "인용 조항만" if ck != "출처 없음" else "출처 없음 · 품 같음")
for t in tags:
kinds[t] += 1
rows.append({"id": i, "이름": e["이름"], "규격": e["규격"], "정답": code, "정답 이름": TG[code]["이름"], "조항": ck, "판정": v, "바뀜": cmp_["바뀜"], "직종": cmp_["직종"], "변형": cmp_["변형"], "더 있는 일": xm + xt, "꼴": tags, "지문 1위": res[i]["지문만"][:1], "1위 맞음": bool(res[i]["지문만"]) and res[i]["지문만"][0] in set(A.LAB[i]["정답"]), "줄수": A.BY[i]["줄수"]})
print("바꿈 꼴(겹쳐 셈 · 고유 126)", dict(kinds.most_common()))
verdicts = Counter(r["판정"] for r in rows)
print("정답 로직 대 엑셀 품 판정", dict(verdicts))
hit = [r for r in rows if r["1위 맞음"]]
print("지문 1위 맞은 줄의 판정", dict(Counter(r["판정"] for r in hit)), "/", len(hit))
# ── 숫자는 어느 책 — 정답 · 허용에 두 책이 다 있는 줄 ──
nb = []
for i in ids:
e = EXP[i]
cands = [k for k in dict.fromkeys(A.LAB[i]["정답"] + A.LAB[i]["허용"]) if k in BYCODE]
books = {BYCODE[k]["책"] for k in cands}
if len(books) < 2 or i in COMPOSITE:
continue
good = {}
for k in cands:
c = compare(e, k)
if c["판정"] in ("같음(3% 안)", "섞음 · 할증 붙임(밑품 같음)"):
good.setdefault(BYCODE[k]["책"], []).append(k)
cited = sorted({b for b, _ in e["출처"]})
nb.append({"id": i, "이름": e["이름"], "인용 책": cited, "숫자 맞는 책": sorted(good), "로직": good})
tally = Counter()
for x in nb:
g, c = x["숫자 맞는 책"], x["인용 책"]
if not g:
tally["어느 책과도 3% 넘게 다름"] += 1
elif len(g) == 2:
tally["두 책 품 같음(숫자로 못 가름)"] += 1
elif not c:
tally[f"출처 없음 · 숫자 {g[0]}"] += 1
elif g[0] in c:
tally["숫자 = 인용 책"] += 1
else:
tally[f"인용 {'·'.join(c)} · 숫자 {g[0]}"] += 1
print("두 책 후보 줄", len(nb), dict(tally))
# ── 책 갈래 11 ──
book = []
for i in [k for k, v in S.NOW.items() if v[0] == "책 갈래(산림↔건설)"]:
e = EXP[i]
cands = list(dict.fromkeys(A.LAB[i]["정답"] + A.LAB[i]["허용"]))
line = {"id": i, "이름": e["이름"], "엑셀 출처": e["출처"], "엑셀 품": {t: round(x["품"], 4) for t, x in e["인력"].items()}}
for k in cands:
c = compare(e, k)
line[f"{BYCODE[k]['책']} {k}"] = {"판정": c["판정"], "품": {r[0]: r[3] for r in c["직종"]}, "변형": c["변형"]}
book.append(line)
for b in book:
print(" 책", json.dumps(b, ensure_ascii=False, default=str)[:400])
# ── 없음 줄 — 지문 1위 점수 나눔 ──
none = [i for i in A.BY if not A.LAB[i]["정답"]]
sc = {i: res[i]["1~5"][0]["점수"] if res[i]["1~5"] else 0 for i in A.BY}
ok_sc = sorted(sc[i] for i in ids if res[i]["지문만"][:1] and res[i]["지문만"][0] in set(A.LAB[i]["정답"]))
print("없음 줄 1위 점수", sorted((round(sc[i], 2), i) for i in none))
print("맞은 1위 점수 아래 10", [round(x, 2) for x in ok_sc[:10]], "· 중앙", round(ok_sc[len(ok_sc) // 2], 2))
for cut in (2.5, 3.0, 3.5):
print(f" 문턱 {cut}: 없음 줄 숨김 {sum(1 for i in none if sc[i] < cut)}/{len(none)} · 맞은 1위 잃음 {sum(1 for x in ok_sc if x < cut)}/{len(ok_sc)}")
out = {"조합": {i: combos[i] for i in multi}, "복합": comp_rows, "바꿈": rows, "꼴수": dict(kinds), "판정수": dict(verdicts), "책갈래": book, "두책": nb, "두책셈": dict(tally)}
(HERE / "out_changes.json").write_text(json.dumps(out, ensure_ascii=False, indent=1, default=str), "utf-8")
if __name__ == "__main__":
main()
@@ -0,0 +1,24 @@
"""req226 C4 — 보고서용 표 · 예 뽑기(out_match · out_changes · 지문 파일 읽기만)."""
import json
import sys
from fp_common import HERE
M = json.loads((HERE / "out_match.json").read_text(encoding="utf-8"))
C = json.loads((HERE / "out_changes.json").read_text(encoding="utf-8"))
TG = {x["로직"]: x for x in json.loads((HERE / "ours_fp.json").read_text(encoding="utf-8"))}
part = sys.argv[1] if len(sys.argv) > 1 else "table"
if part == "table":
for r in M["표"]:
a, n, s, y = r["전체"], r["북부청임도"], r["북부청사방"], r["동부청영월"]
print(f"| {r['이름']} | {a['1위 엄격']} / {a['1위 느슨']} | {a['5안 엄격']} / {a['5안 느슨']} | {n['1위 엄격']} | {s['1위 엄격']} | {y['1위 엄격']} | {a['줄 1위 느슨']} | {a['없음인데 후보']} |")
elif part == "combo":
for i in sys.argv[2:]:
print(i, [(c["절"], c["이름"][:18], c["1위"], TG[c["1위"]]["이름"][:22]) for c in C["조합"].get(i) or []])
elif part == "rows":
want = set(sys.argv[2:])
for r in C["바꿈"]:
if not want or r["id"] in want or r["판정"] in want or any(t in want for t in r["꼴"]):
print(r["id"], r["이름"][:16], "|", r["규격"][:18], "→", r["정답"], r["정답 이름"][:18], "|", r["판정"], r["바뀜"], "| 조항", r["조항"], "| 꼴", r["꼴"], "| 더", r["더 있는 일"], "| 1위맞음" if r["1위 맞음"] else "")
elif part == "twobook":
for x in C["두책"]:
print(x["id"], x["이름"][:16], "인용", x["인용 책"], "숫자", x["숫자 맞는 책"], x["로직"])
@@ -0,0 +1,41 @@
"""req226 C4 — 엑셀이 쓴 조건값(지금 M02 규격 읽기 `spec_inputs`)으로 정답 로직을 돌린 품 ↔ 엑셀 품.
가장 닮은 변형(s4 판정)과 견줘 「규격 읽기만으로 같은 품이 나오나」를 셈. 읽기만."""
import json
from collections import Counter
import s2_ours_fp as O
import s4_changes as C
from fp_common import HERE, close
rows = json.loads((HERE / "out_changes.json").read_text(encoding="utf-8"))["바꿈"]
cnt, ex = Counter(), []
for r in rows:
if r["판정"] not in ("같음(3% 안)", "섞음 · 할증 붙임(밑품 같음)"):
continue
i, code = r["id"], r["정답"]
line = {k: C.A.BY[i].get(k) for k in ("이름", "규격", "단위")}
row = O.WHOLE.logic(code)
try:
values, unread = O.hm.spec_inputs(line, O.hm._logic_specs(code))
by_name = {}
for h in row.get("호표") or []:
by_name.setdefault(h.get("이름"), h)
v = O.run_one(code, row, values, O.times_of(row), by_name)
except Exception as e: # noqa: BLE001
cnt["못 돎"] += 1
continue
E = C.EXP[i]
L = v["L"]
mix = v.get("mix") or {}
if mix.get("인력비율") and 0 < mix["인력비율"] < 100:
L = {t: q * 100 / mix["인력비율"] for t, q in L.items()}
if not E["E"]:
cnt["인력 없음(건넘)"] += 1
continue
ok = set(E["E"]) == {t for t, q in L.items() if q} and all(max(close(E["E"][t], L.get(t)), close(E["Eb"].get(t), L.get(t))) >= 0.99 for t in E["E"])
cnt["규격 읽기로 같은 품" if ok else "규격 읽기로는 다른 품(다른 변형이 맞음)"] += 1
if not ok and len(ex) < 8:
ex.append((i, r["이름"], r["규격"], code, values, r["변형"]))
print(dict(cnt))
for x in ex:
print(" ", x)
@@ -0,0 +1,489 @@
"""req226 C5 — 바깥 호표(일위대가) 나무 세우기(읽기만 · 저장소 안 고침).
호표마다 줄 → 갈래(단산 · 호표 참조 · 노임 · 자재 · 장비 · % · 별도계상 · 근거글 · 기타) +
단산 블록 · STmate D 블록의 몫(절)별 벡터(노임 인 · 장비 시간 · 자재 수).
꼴 셋 = 북부청 xls(지금 읽기 함수) · 거창 xlsx(지금 읽기 함수 + 블록 행 글 이어 붙임) ·
STmate xlsx 다섯(새 흉내 읽기 — 줄 코드 = 14 열 「D00002_1」).
python c01_trees.py → trees.json · 표
"""
from __future__ import annotations
import ast
import json
import operator
import re
import sys
import warnings
from pathlib import Path
ROOT = Path(r"C:\Program_coding\임도설계 및 견적자동화 프로그램 개발")
sys.path.insert(0, str(ROOT))
HERE = Path(__file__).resolve().parent
C1 = HERE.parent.parent / "req225" / "C1"
warnings.filterwarnings("ignore")
from M02_MasterTemplete import M02_Hopyo_Basis as hb # noqa: E402
from M02_MasterTemplete import M02_Hopyo_Excel as hx # noqa: E402
DOCS = ROOT / "resources/knowledge/original/실무문서"
NORTH = {
"북부청임도": DOCS / "2026_실무일위대가 모음/2026년_북부지방산림청_임도사업_설계내역서(공통단가).xls",
"북부청사방": DOCS / "2026_실무일위대가 모음/2026년_북부지방산림청_사방사업(계류+댐)_설계내역서.xls",
}
GEO = {"거창": DOCS / "01.산불거창(거창북상병곡)/01.내역서/00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx"}
STM = {
"봉화": DOCS / "04.산불 봉화/01. 내역서/2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.xlsx",
"동부청영월": DOCS / "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx",
"울진대흥": DOCS
/ "2024년 간선임도(기번3-울진.대흥)(공통)/2024년 간선임도 신설사업(기번3 울진.울진.대흥.산65외2).xlsx",
"영덕계류": DOCS
/ "20250527 2025년 계류보전사업(기번1-영덕.병곡.영리.산214-1)(변경)/01.내역자료/2025년 계류보전사업(기번1 영덕.병곡.영.산214)(변경).xlsx",
"울진소광": DOCS / "25년 산불진화임도(기번8)(울진소광)/2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).xlsx",
}
# ── 장비 열쇠 ─────────────────────────────────────────────────────────
EQ_TYPES = (
("굴착기", ("굴착기", "굴삭기", "백호", "b/h", "backhoe", "back-hoe")),
("덤프", ("덤프",)),
("크레인", ("크레인",)),
("브레이커", ("브레이커", "브레이카")),
("롤러", ("롤러", "로울러")),
("펌프차", ("펌프차", "펌프카", "콘크리트펌프")),
("믹서", ("믹서",)),
("불도저", ("불도저", "불도우저")),
("살수", ("살수", "물탱크")),
("콤팩터", ("콤팩터", "컴팩터", "플레이트")),
("화물차", ("화물",)),
("공기압축기", ("공기압축기", "컴프레서")),
("천공기", ("천공", "드릴")),
("로더", ("로더",)),
("그레이더", ("그레이더",)),
("트레일러", ("트레일러",)),
)
_SIZE = re.compile(r"(\d+(?:\.\d+)?)\s*(㎥|m3|톤|ton|t\b|kw|hp)", re.I)
def eq_key(text: str) -> str | None:
"""장비 이름 → 「굴착기|0.6」 꼴(크기 없으면 「굴착기|」) · 장비 아니면 None."""
t = str(text or "").lower().replace("㎥", "㎥")
kind = next((k for k, ws in EQ_TYPES if any(w in t for w in ws)), None)
if kind is None:
return None
if "집게" in t:
kind += "+집게"
m = _SIZE.search(t.replace(",", ""))
size = m.group(1).rstrip("0").rstrip(".") if m else ""
return f"{kind}|{size}"
def trade_of(name: str) -> str | None:
"""노임 이름 → 우리 인력 이름(빈칸 · 옛이름 풀이) · 아니면 None."""
flat = re.sub(r"\s+", "", str(name or ""))
flat = re.sub(r"\(.*?\)", "", flat) if flat not in hb.trade_names() else flat
return hb.trade_names().get(flat)
# ── 식 풀이(품) — 첫 수(노임 · 장비 단가)를 1 로 두고 나머지 식만 셈 ───────────────────────
_OPS = {ast.Mult: operator.mul, ast.Div: operator.truediv, ast.Add: operator.add, ast.Sub: operator.sub}
def _eval(node):
if isinstance(node, ast.Expression):
return _eval(node.body)
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return float(node.value)
if isinstance(node, ast.BinOp) and type(node.op) in _OPS:
return _OPS[type(node.op)](_eval(node.left), _eval(node.right))
if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub):
return -_eval(node.operand)
raise ValueError("식 아님")
def qty_of(expr: str) -> float | None:
"""「273,540 × 3인 ÷ 55㎥ × 2」 → 3/55×2 · 「59,020 ÷ 23.1 × 90%」 → 1/23.1×0.9."""
s = re.sub(r"\([^()0-9]*\)", "", str(expr)) # (보정계수) 같은 글 괄호
s = s.replace(",", "")
s = re.sub(r"(\d+(?:\.\d+)?)\s*%", r"(\1/100)", s)
s = s.replace("×", "*").replace("÷", "/").replace("X", "*").replace("x", "*")
s = re.sub(r"/\s*(?=[^\d(\s.])", " ", s) # 「인/㎥」 의 / 는 단위
s = re.sub(r"[^\d.+\-*/() ]", " ", s)
s = re.sub(r"(?<=\d)\.(?!\d)", "", s) # 「4.」 → 4
s = re.sub(r"\(\s*\)", " ", s).strip()
m = re.match(r"\s*\d+(?:\.\d+)?", s)
if not m:
return None
s = "1" + s[m.end() :]
s = re.sub(r"(?<=[\d)])\s+(?=[\d(])", "*", s) # 빈칸으로 붙은 수는 곱
try:
v = _eval(ast.parse(s, mode="eval"))
except (SyntaxError, ValueError, ZeroDivisionError):
return None
return v if 0 < v < 1e4 else None
_TRADE = re.compile(r"^[\s*·\-○◇]*([가-힣][가-힣\s]*?)\s*:\s*([\d,]+(?:\.\d+)?)\s*(.*?)(?:=\s*([\d,]+(?:\.\d+)?))?\s*$")
_SECTION = re.compile(r"^\s*(\d+)\s*\.\s*(.*)$")
_EQ_COST = re.compile(r"^\s*노\s*무\s*비\s*:\s*([\d,]+(?:\.\d+)?)\s*(.*?)(?:=\s*[\d,.]+)?\s*$")
_SKIP_SEC = re.compile(r"^(합\s*계|소\s*계|계)")
_SUBLABEL = re.compile(r"^\s*[가-하]\s*\.\s*(.*)$")
_COSTLINE = re.compile(r"^\s*(노\s*무\s*비|재\s*료\s*비|경\s*비)\s*:")
def sections(texts: list[str]) -> list[dict]:
"""블록 글 → 몫(맨 윗 번호 절)마다 {제목, 노임, 장비} — 노임 = 「직종 : 노임 × 식 = 금액」 ·
장비 = 장비 이름 줄 뒤 「노무비 : 단가 ÷ Q × 몫」(시간 = 식)."""
out, cur, eq, parent = [], None, None, ""
for raw in texts:
line = str(raw).rstrip()
sec = _SECTION.match(line)
if sec and not re.match(r"^\s*\d+\.\d", line):
title = sec.group(2).strip()
if _SKIP_SEC.match(title.replace(" ", "")):
cur = None
continue
parent = title
cur = {"제목": title, "절": title, "노임": {}, "장비": {}, "글": []}
out.append(cur)
eq = eq_key(title)
continue
sub = _SUBLABEL.match(line) if cur is not None else None
if sub and not _SKIP_SEC.match(sub.group(1).replace(" ", "")):
cur = {"제목": f"{parent} · {sub.group(1).strip()}", "절": parent, "노임": {}, "장비": {}, "글": []}
out.append(cur)
if cur is None:
if out or not line.strip():
continue
cur = {"제목": "", "절": "", "노임": {}, "장비": {}, "글": []}
out.append(cur)
cur["글"].append(line.strip())
flat = line.replace(" ", "")
if re.search(r"(소계|합계|인부소계)", flat):
eq = None
continue
sub = _SUBLABEL.match(line)
if sub:
eq = eq_key(sub.group(1))
elif not _COSTLINE.match(line) and (k := eq_key(line)):
eq = k
m = _EQ_COST.match(line)
if m and eq:
q = qty_of(f"{m.group(1)} {m.group(2)}")
if q and q < 50:
cur["장비"][eq] = cur["장비"].get(eq, 0) + q
continue
t = _TRADE.match(line)
if t:
trade = trade_of(t.group(1))
if trade:
q = qty_of(f"{t.group(2)} {t.group(3)}")
if q is None and t.group(4): # 식을 못 풀면 금액 ÷ 노임(품 되찾기만)
q = float(t.group(4).replace(",", "")) / float(t.group(2).replace(",", ""))
if q:
cur["노임"][trade] = cur["노임"].get(trade, 0) + q
return [s for s in out if s["노임"] or s["장비"]]
# ── 북부청 · 거창 — 지금 읽기 함수 ─────────────────────────────────────────
def _row_text(row: list, start: int = 1) -> str:
cells = []
for v in row[start:140]:
if v is None or v == "":
continue
if isinstance(v, float) and v == int(v):
v = int(v)
cells.append(str(v).strip())
return " ".join(cells)
def _sub(line: dict) -> str:
kind = line["갈래"]
if kind == "단가산출":
return "단산"
if kind == "호표":
return "호표"
if kind == "기계":
return "장비"
if kind in ("%", "별도계상", "자재"):
return kind
if str(line.get("단위") or "").strip() == "인" and trade_of(line["이름"]):
return "노임"
return "기타"
def _ids_north() -> dict:
items = json.loads((C1 / "out_items.json").read_text(encoding="utf-8"))
out = {}
for r in items:
ref = r.get("참조") or {}
out[(r["파일"], r["이름"], r["규격"], r["단위"], ref.get("번") if ref.get("종류") == "단산" else None)] = r["id"]
return out
def read_excel(tag: str, path: Path, ids: dict, join_rows: bool) -> dict:
data = path.read_bytes()
book = hx.read_book(path.name, data)
calc = hx._sheets(path.name, data).get(hb.SHEET) or []
start, col, _ = hb._columns(calc)
heads = sorted(b["행"][0] for b in book["단산"])
blocks = {}
for b in book["단산"]:
a = b["행"][0]
end = next((h for h in heads if h > a), len(calc) + 1) - 1
texts = []
for at in range(a, end + 1):
row = calc[at - 1]
text = _row_text(row) if join_rows else (row[col] if col < len(row) else "")
if isinstance(text, str) and text.strip():
texts.append(text.rstrip())
joined = " ".join(texts)
trades = []
for t in texts:
m = _TRADE.match(t)
if m and (nm := trade_of(m.group(1))) and nm not in trades:
trades.append(nm)
blocks[b["번"]] = {
"이름": b["이름"],
"단위": b["단위"],
"출처": hb.sources(joined),
"직종": trades,
"글": texts[:60],
"몫": sections(texts[1:]),
}
hopyos = []
for h in book["호표"]:
lines = []
for one in h["줄"]:
ref = one.get("참조") or {}
kind = _sub(one)
rec = {k: one.get(k) for k in ("이름", "규격", "수량", "단위", "단가", "참조")}
rec["갈래"] = kind
key = (tag, one["이름"], one["규격"], one["단위"], ref.get("번") if ref.get("종류") == "단산" else None)
if key in ids:
rec["id"] = ids[key]
if kind == "단산" and ref.get("번") in blocks:
b = blocks[ref["번"]]
rec["근거"] = one.get("근거") if not join_rows else {"출처": b["출처"], "직종": b["직종"]}
rec["몫"] = b["몫"]
if kind == "노임":
rec["노임"] = {trade_of(one["이름"]): one["수량"]}
if kind == "장비":
rec["장비"] = {eq_key(f"{one['이름']} {one['규격']}"): one["수량"]}
lines.append(rec)
hopyos.append({"번": h["번"], "이름": h["이름"], "규격": h["규격"], "단위": h["단위"], "줄": lines})
return {"파일": tag, "꼴": "거창" if join_rows else "북부청", "호표": hopyos, "단산": len(blocks)}
# ── STmate ───────────────────────────────────────────────────────────
_HOPYO = re.compile(r"^\s*제\s*(\d+)\s*호표")
_RES = re.compile(r"^([LMXS]\d{5})([LMS])$")
def _num(v):
try:
return float(str(v).replace(",", ""))
except (TypeError, ValueError):
return None
def _code(row: list) -> str:
raw = str(row[14] if len(row) > 14 and row[14] is not None else "").strip()
return raw.rsplit("_", 1)[0] if "_" in raw else raw
def _ids_y() -> dict:
y = json.loads((C1 / "out_stmate.json").read_text(encoding="utf-8"))
return {(r["이름"], r["규격"], r["단위"], r["코드"]): r["id"] for r in y}
def _d_blocks(rows: list) -> dict:
"""단가산출근거 → {D코드: {이름, 단위, 출처, 직종, 글, 몫}} — 자원 줄 = 0 열 「L00002L」 · 7 열 QTY ·
23 열 이름."""
out, cur, texts = {}, None, []
def close():
if cur is not None:
cur["글"] = texts[:60]
joined = " ".join(texts)
cur["출처"] = hb.sources(joined)
secs = sections_stm(cur.pop("_res"), texts)
cur["몫"] = secs
cur["직종"] = sorted({t for s in secs for t in s["노임"]})
for r in rows:
c0 = str(r[0] or "").strip()
c1 = str(r[1] or "").strip() if len(r) > 1 else ""
meta = re.search(r"BDCOD\|(D\d{5})", str(r[6] if len(r) > 6 and r[6] else ""))
code = meta.group(1) if meta else None
if code:
close()
cur, texts = {"코드": code, "_res": []}, []
out[code] = cur
continue
if cur is None:
continue
if c0.startswith("총") and "계" in c0:
continue
m = _RES.match(c0) or re.search(r"&([LMXS]\d{5})([LMS])&", str(r[6] if len(r) > 6 and r[6] else ""))
if m:
full = str(r[23] if len(r) > 23 and r[23] else "")
name = full.split("/")[0].strip()
cur["_res"].append((m.group(1), m.group(2), _num(r[7]) if len(r) > 7 else None, name, len(texts), full))
continue
if c1 and not c1.startswith("소계") and c1 != "계":
if "이름" not in cur:
cur["이름"] = c1
texts.append(c1)
close()
return out
def sections_stm(res: list, texts: list[str]) -> list[dict]:
"""STmate 자원 줄 → 몫별 벡터(맨 윗 번호 절 · 없으면 한 몫)."""
marks = []
for at, t in enumerate(texts):
sec = _SECTION.match(t)
if sec and not re.match(r"^\s*\d+\.\d", t) and not _SKIP_SEC.match(sec.group(2).replace(" ", "")):
marks.append((at, sec.group(2).strip()))
def which(pos):
got = [m for m in marks if m[0] <= pos]
return got[-1][1] if got else ""
secs: dict[str, dict] = {}
for code, part, q, name, pos, full in res:
if q is None:
continue
title = which(pos)
s = secs.setdefault(title, {"제목": title, "노임": {}, "장비": {}, "자재": {}})
if code[0] == "L" and part == "L":
trade = trade_of(name)
if trade:
s["노임"][trade] = s["노임"].get(trade, 0) + q
elif code[0] == "X" and part == "L":
k = eq_key(full) or f"?{code}"
s["장비"][k] = s["장비"].get(k, 0) + q
elif code[0] == "M":
s["자재"][name or code] = s["자재"].get(name or code, 0) + q
return [s for s in secs.values() if s["노임"] or s["장비"]]
def read_stmate(tag: str, path: Path, ids_y: dict) -> dict:
import openpyxl
book = openpyxl.load_workbook(path, read_only=True, data_only=True)
rows = [list(r) for r in book["일위대가표"].iter_rows(values_only=True)]
calc = [list(r) for r in book["단가산출근거"].iter_rows(values_only=True)]
book.close()
blocks = _d_blocks(calc)
hopyos, cur, want_head = [], None, False
for r in rows:
head = str(r[0] or "")
hit = _HOPYO.match(head)
if hit:
cur = {"번": int(hit.group(1)), "줄": []}
hopyos.append(cur)
want_head = True
continue
if cur is None:
continue
if want_head:
cur.update(이름=str(r[0] or "").strip(), 규격=str(r[1] or "").strip(), 단위=str(r[3] or "").strip(), 코드=_code(r))
want_head = False
continue
name = str(r[0] or "").strip()
if not name or name.replace(" ", "").startswith("합계"):
continue
code = _code(r)
if code[:1] in ("T", "C") and not code[1:2].isalpha():
continue # 계 · 계약단가 · 소계 줄
note = str(r[12] or "").strip() if len(r) > 12 else ""
spec = str(r[1] or "").strip()
price = {"계": _num(r[4]), "노무비": _num(r[6]), "재료비": _num(r[8]), "경비": _num(r[10])}
rec = {"이름": name, "규격": spec, "수량": _num(r[2]), "단위": str(r[3] or "").strip(), "단가": price, "코드": code}
k = code[:1]
if not code:
rec["갈래"] = "근거글"
elif code.startswith("PR") or rec["단위"] == "%":
rec["갈래"] = "%"
elif k == "B":
rec["갈래"], rec["참조"] = "호표", {"종류": "일위", "코드": code}
elif k == "D":
rec["갈래"], rec["참조"] = "단산", {"종류": "단산", "코드": code}
b = blocks.get(code)
if b:
rec["근거"] = {"출처": b["출처"], "직종": b["직종"]}
rec["몫"] = b["몫"]
rec["단산글"] = b["글"]
elif k == "L":
rec["갈래"], rec["노임"] = "노임", {trade_of(name) or name: rec["수량"]}
elif k == "X":
rec["갈래"], rec["장비"] = "장비", {eq_key(f"{name} {spec}") or f"?{code}": rec["수량"]}
elif k == "M":
apart = note.startswith("별산") or spec == "별도계상" or not any(price.values())
rec["갈래"] = "별도계상" if apart else "자재"
elif k in "SW":
rec["갈래"] = "별도계상"
else:
rec["갈래"] = "기타"
if tag == "동부청영월" and k in "DX":
yk = (name, spec, rec["단위"], str(r[12] or "").strip())
if yk in ids_y:
rec["id"] = ids_y[yk]
cur["줄"].append(rec)
by_code = {h.get("코드"): h["번"] for h in hopyos}
for h in hopyos:
for one in h["줄"]:
if one["갈래"] == "호표":
one["참조"]["번"] = by_code.get(one["코드"])
return {"파일": tag, "꼴": "STmate", "호표": hopyos, "단산": len(blocks)}
KINDS = ("단산", "호표", "노임", "장비", "자재", "%", "별도계상", "근거글", "기타")
def depth(f: dict) -> int:
by = {h["번"]: h for h in f["호표"]}
def d(no, seen=()):
h = by.get(no)
if not h or no in seen:
return 0
kids = [one["참조"].get("번") for one in h["줄"] if one["갈래"] == "호표" and one.get("참조")]
return 1 + max((d(k, (*seen, no)) for k in kids if k), default=0)
return max((d(h["번"]) for h in f["호표"]), default=0)
def main():
ids_n, ids_y = _ids_north(), _ids_y()
files = [read_excel(t, p, ids_n, False) for t, p in NORTH.items()]
files += [read_excel(t, p, {}, True) for t, p in GEO.items()]
files += [read_stmate(t, p, ids_y) for t, p in STM.items()]
(HERE / "trees.json").write_text(json.dumps(files, ensure_ascii=False, indent=1, default=str), "utf-8")
print("| 파일 | 꼴 | 호표 | 줄 | " + " | ".join(KINDS) + " | 직접 줄 호표 | 참조만 호표 | 단산 블록 | 몫 벡터 있는 단산 줄 | 깊이 |")
tot = {}
for f in files:
cnt = {k: 0 for k in KINDS}
direct = refs_only = with_vec = dline = 0
for h in f["호표"]:
kinds = [one["갈래"] for one in h["줄"]]
for k in kinds:
cnt[k] += 1
if any(k in ("노임", "장비") for k in kinds):
direct += 1
elif kinds and all(k in ("단산", "호표", "자재", "별도계상", "%", "근거글") for k in kinds):
refs_only += 1
for one in h["줄"]:
if one["갈래"] == "단산":
dline += 1
with_vec += bool(one.get("몫"))
n = sum(cnt.values())
row = [f["파일"], f["꼴"], len(f["호표"]), n, *cnt.values(), direct, refs_only, f["단산"], f"{with_vec}/{dline}", depth(f)]
for k, v in zip(["호표", "줄", *KINDS, "직접", "참조만", "단산블록"], row[2:13] + [direct, refs_only, f["단산"]]):
tot[k] = tot.get(k, 0) + v
print("| " + " | ".join(map(str, row)) + " |")
print("합", tot)
if __name__ == "__main__":
main()
@@ -0,0 +1,102 @@
"""req226 C5 — 우리 로직 단위당 벡터(노임 인 · 장비 시간) — 우리 계산 엔진(`mf.run`)으로 냄(읽기만).
auto = 자동값 한 벌(모든 돈 로직) · variants(code) = 고르기 입력 갈래(지역 뺌 · 곱이 cap 넘으면 하나씩 바꿈).
python c02_ourvec.py → ourvec_auto.json(돈 로직 1,438 자동값 벡터)
"""
from __future__ import annotations
import itertools
import json
import sys
from functools import lru_cache
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
from c01_trees import ROOT, eq_key, trade_of # noqa: E402,F401
from M01_MasterData import M01_MasterData_Store as m01 # noqa: E402
from M02_MasterTemplete import M02_Hopyo_Match as hm # noqa: E402
mf = m01.cm.mf
WHOLE = m01.loaded()[1]
SKIP_INPUTS = {"지역"}
def vec_of(lines: list[dict]) -> dict:
"""엔진 줄 → {노임: {직종: 인}, 장비: {열쇠: 시간}}."""
out = {"노임": {}, "장비": {}}
for one in lines:
unit = str(one.get("단위") or "")
try:
q = float(one.get("수량") or 0)
except (TypeError, ValueError):
continue
if q <= 0:
continue
name = str(one.get("이름") or "")
if unit == "인":
t = trade_of(name) or name
out["노임"][t] = out["노임"].get(t, 0) + q
elif unit in ("시간", "hr", "HR", "h"):
k = eq_key(name) or f"?{name}"
out["장비"][k] = out["장비"].get(k, 0) + q
return out
def run(code: str, given: dict | None = None) -> dict | None:
logic = WHOLE.logic(code)
auto = {k: v for k, v in m01.mau.values(WHOLE, logic).items() if v is not None}
try:
got = mf.run(WHOLE, code, {**auto, **(given or {})})
except Exception: # noqa: BLE001 — 못 도는 갈래는 뺌
return None
return vec_of(got.get("줄") or [])
@lru_cache(maxsize=4096)
def variants(code: str, cap: int = 48) -> tuple:
"""((입력 글, 벡터) …) — 자동값 + 고르기 갈래."""
logic = WHOLE.logic(code)
picks = [
(str(s["이름"]), list(s["고르기"]))
for s in logic.get("입력") or []
if s.get("고르기") and str(s.get("이름")) not in SKIP_INPUTS
]
combos = [{}]
total = 1
for _, opts in picks:
total *= len(opts)
if picks and total <= cap:
combos = [dict(zip([n for n, _ in picks], vals)) for vals in itertools.product(*[o for _, o in picks])]
elif picks:
combos += [{n: v} for n, opts in picks for v in opts]
out, seen = [], set()
for given in combos:
v = run(code, given)
if v is None or not (v["노임"] or v["장비"]):
continue
key = json.dumps(v, sort_keys=True, ensure_ascii=False)
if key in seen:
continue
seen.add(key)
out.append((json.dumps(given, ensure_ascii=False, default=str), v))
return tuple(out)
def main():
got = hm.targets()
out = {}
for one in got["로직"]:
v = run(one["로직"])
if v is not None:
out[one["로직"]] = v
(HERE / "ourvec_auto.json").write_text(json.dumps(out, ensure_ascii=False), "utf-8")
trades = sum(1 for v in out.values() if v["노임"])
eqs = sum(1 for v in out.values() if v["장비"])
print("로직", len(got["로직"]), "벡터", len(out), "노임 있음", trades, "장비 있음", eqs)
if __name__ == "__main__":
main()
@@ -0,0 +1,21 @@
"""갈래 벡터를 조각으로 미리 셈 → ourvec_var.json 에 더함 · python c02b_varcache.py <시작> <끝> [초 한도]."""
import json, sys, time
import c03_vec as V
import c01_trees # noqa: F401 — 저장소 경로
from M02_MasterTemplete import M02_Hopyo_Match as hm
a, b = int(sys.argv[1]), int(sys.argv[2])
limit = float(sys.argv[3]) if len(sys.argv) > 3 else 95
t0 = time.time()
codes = [x["로직"] for x in hm.targets()["로직"]][a:b]
done = 0
for c in codes:
if c in V.CACHE:
done += 1
continue
V.variants(c)
done += 1
if time.time() - t0 > limit:
break
V.save_cache()
print("done", a, a + done, "of", b, "cache", len(V.CACHE), "sec", round(time.time() - t0, 1))
@@ -0,0 +1,107 @@
"""req226 C5 — 벡터 맞춤 부품(몫 벡터 ↔ 우리 로직 단위당 벡터) · 갈래 벡터 디스크 캐시.
점수 = 차원(직종 · 장비 종류)마다 가까움(작은/큰) 가중 평균 · 보통인부 무게 0.5 ·
장비는 있음 0.5 + 시간 가까움 0.5(작성자가 효율 · 사이클을 바꿔도 종류는 남음) · 크기 다르면 × 0.8 ·
몫(10% · 90% 같은 나눔)은 x ∈ {0.1 … 0.9} 로 늘여 봄(× 0.95). 금액 안 씀.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
AUTO = json.loads((HERE / "ourvec_auto.json").read_text(encoding="utf-8"))
CACHE_FILE = HERE / "ourvec_var.json"
try:
CACHE = json.loads(CACHE_FILE.read_text(encoding="utf-8"))
except (OSError, ValueError):
CACHE = {}
COMMON = "보통인부"
SHARES = (0.1, 0.2, 0.3, 0.5, 0.7, 0.8, 0.9)
def variants(code: str) -> list:
"""[(입력 글, 벡터) …] — 디스크 캐시 · 없으면 엔진으로 셈."""
if code not in CACHE:
import c02_ourvec as ov
CACHE[code] = [list(x) for x in ov.variants(code)]
return CACHE[code] or ([["{}", AUTO[code]]] if code in AUTO else [])
def save_cache():
CACHE_FILE.write_text(json.dumps(CACHE, ensure_ascii=False), "utf-8")
def _eq_split(k: str):
kind, _, size = k.partition("|")
return kind, size
def sim(E: dict, O: dict, x: float = 1.0) -> float:
"""E · O = {노임: {..}, 장비: {..}} — 0..1."""
num = den = 0.0
et, ot = E.get("노임") or {}, O.get("노임") or {}
for t in set(et) | set(ot):
w = 0.5 if t == COMMON else 1.0
a, b = et.get(t, 0.0), ot.get(t, 0.0) * x
den += w
if a > 0 and b > 0:
num += w * min(a, b) / max(a, b)
ee, oe = E.get("장비") or {}, O.get("장비") or {}
used = set()
for k, a in ee.items():
kind, size = _eq_split(k)
best = 0.0
for k2, b in oe.items():
kind2, size2 = _eq_split(k2)
if kind2 != kind and kind2.split("+")[0] != kind.split("+")[0]:
continue
f = 1.0 if (not size or not size2 or size == size2) else 0.8
f *= 1.0 if kind2 == kind else 0.8 # 굴착기 ↔ 굴착기+집게(같은 기종 · 덧장치만 다름)
b *= x
s = f * (0.5 + 0.5 * (min(a, b) / max(a, b) if a > 0 and b > 0 else 0))
if s > best:
best, pick = s, k2
den += 1.0
num += best
if best:
used.add(pick)
den += sum(1.0 for k in oe if k not in used)
return num / den if den else 0.0
def fit(E: dict, code: str) -> tuple[float, str, float]:
"""(점수, 입력 글, 몫 x) — 갈래마다 x = 1 과 몫 x 중 가장 좋은 것."""
best = (0.0, "{}", 1.0)
for given, O in variants(code):
s = sim(E, O)
if s > best[0]:
best = (s, given, 1.0)
for x in SHARES:
s2 = 0.95 * sim(E, O, x)
if s2 > best[0]:
best = (s2, given, x)
return best
def dims(E: dict) -> set:
out = {t for t in (E.get("노임") or {}) if t != COMMON}
out |= {_eq_split(k)[0] for k in (E.get("장비") or {})}
return out
def index() -> dict:
"""{차원: {로직}} — 보통인부 뺀 직종 · 장비 종류(자동값 벡터 기준)."""
out: dict[str, set] = {}
for code, v in AUTO.items():
for d in dims(v):
out.setdefault(d, set()).add(code)
return out
IDX = index()
@@ -0,0 +1,304 @@
"""req226 C5 — 닫힌 세계 풀이 (가): 지금 맞춤 후보 다섯(그대로 부름)에 호표 묶음 조건을 걸어 다시 고름.
조건 = ① 같은 책(줄 근거 책 · 없으면 호표 안 근거 책 다수) ② 같은 조건(토질 — 호표 · 줄 이름) ·
③ 우리 조합(일위대가조합.json 37) 통째 같음 · 안에 다 듦 · ④ 아래에서 위로(호표 참조 = 그 호표 풀이) —
④는 줄 지표엔 안 듦(참조 줄은 로직 줄 아님) · 조합 지표(c05)에서 씀.
지표 = 앞 조사(C1 · C3)와 같은 정답 · 같은 셈(고유 줄 126 · 씨앗 켬 · 기억 없음).
python c04_closed.py → 표 · closed.json(줄마다 결과 — c05 가 씀)
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
C1 = HERE.parent.parent / "req225" / "C1"
C3 = HERE.parent.parent / "req225" / "C3"
for p in (HERE, C1, C3):
sys.path.insert(0, str(p))
import s10_all as S # noqa: E402
from s04_eval import clause_prefix, hb, hm, patched # noqa: E402
from s10_all import candidates_nofold, no_seeds # noqa: E402
from s16_guard import typo_guard # noqa: E402
from c01_trees import ROOT # noqa: E402
TREES = json.loads((HERE / "trees.json").read_text(encoding="utf-8"))
LABELED = ("북부청임도", "북부청사방", "동부청영월")
COMBOS = [
(r["키"], r["이름"], [x["로직"] for x in r["담은로직"]])
for r in json.loads((ROOT / "resources/master_data/일위대가조합.json").read_text(encoding="utf-8"))["줄"]
if r["키"] != "UA000001"
]
def hopyo_members() -> dict:
"""{(파일, 호표 번): [id …](차례 · 겹침 뺌)}."""
out = {}
for f in TREES:
if f["파일"] not in LABELED:
continue
for h in f["호표"]:
ids = list(dict.fromkeys(one["id"] for one in h["줄"] if one.get("id")))
out[(f["파일"], h["번"])] = ids
return out
MEMBERS = hopyo_members()
HOPYO_OF = {}
for key, ids in MEMBERS.items():
for i in ids:
HOPYO_OF.setdefault(i, []).append(key)
HEADS = {(f["파일"], h["번"]): f"{h['이름']} {h['규격']}" for f in TREES for h in f["호표"]}
def line_of(r: dict) -> dict:
return {k: r.get(k) for k in ("이름", "규격", "수량", "단위", "단가", "갈래", "참조", "근거")}
def book(code: str) -> str | None:
one = hb.ours().get(code)
return one["책"] if one else None
def own_books(r: dict) -> set:
return {b for b, _ in ((r.get("근거") or {}).get("출처") or [])}
def pools() -> dict:
"""줄마다 지금 합친 후보(근거 + 이름 · 다섯 자르기 전 · 15)."""
got = hm.targets()
out = {}
for r in S.ALL:
line = line_of(r)
basis = hb.candidates(line, got)
named = hm.candidates(line, "로직", got, None)
out[r["id"]] = [c["로직"] for c in hb.merge(basis, named) if c.get("로직")][:15]
return out
# ── 같은 일 · 조건 ───────────────────────────────────────────────────
_NAMES = {}
def name(code: str) -> str:
if not _NAMES:
_NAMES.update({x["로직"]: x["이름"] for x in hm.targets()["로직"]})
return _NAMES.get(code, "")
def same_work(a: str, b: str) -> bool:
"""두 로직이 같은 일(책만 다름) — 정리 이름 글자 두 개 묶음 겹침 0.5 이상 또는 뜻 낱말 겹침."""
ta, tb = hm.tidy(name(a)), hm.tidy(name(b))
ga, gb = hm._grams(ta), hm._grams(tb)
dice = 2 * len(ga & gb) / (len(ga) + len(gb)) if ga and gb else 0
return dice >= 0.5 or hm.near(hm.words(name(a)), hm.words(name(b))) and dice >= 0.34
SOIL = re.compile(r"(보통토사|혼합토사|경질토사|토사|사질토|점질토)")
ROCK = re.compile(r"(리핑암|발파암|연암|경암|풍화암|보통암|극경암|암절취|\(암\)| 암$|암\))")
def soil(text: str) -> str | None:
t = str(text or "")
s, r = bool(SOIL.search(t)), bool(ROCK.search(t))
return "토사" if s and not r else "암" if r and not s else None
# ── 조건 걸기 ─────────────────────────────────────────────────────────
def apply_book(res: dict, pool: dict, mode: str) -> dict:
"""mode 'line' = 줄 근거 책 먼저 · 없으면 호표 다수 / 'hopyo' = 근거 없는 줄만 호표 다수."""
out = {}
for i, got in res.items():
r = S.BY[i]
own = own_books(r)
votes: dict[str, int] = {}
for key in HOPYO_OF.get(i, []):
for j in MEMBERS[key]:
for b in own_books(S.BY[j]):
votes[b] = votes.get(b, 0) + 1
top_v = sorted(votes.items(), key=lambda x: -x[1])
major = top_v[0][0] if top_v and (len(top_v) == 1 or top_v[0][1] > top_v[1][1]) else None
if mode == "line":
want = next(iter(own)) if len(own) == 1 else (major if not own else None)
else:
want = major if not own else None
top = got["top"]
out[i] = got
if not top or want is None or book(top[0]) == want:
continue
alts = top[1:5] + [c for c in pool[i][:10] if c not in top]
pick = next((c for c in alts if book(c) == want and same_work(c, top[0])), None)
if pick:
out[i] = {"상태": "후보", "top": [pick] + [c for c in top if c != pick][:4]}
return out
def apply_soil(res: dict) -> dict:
out = {}
for i, got in res.items():
r = S.BY[i]
want = soil(f"{r['이름']} {r['규격']}") or next(
(s for key in HOPYO_OF.get(i, []) if (s := soil(HEADS.get(key)))), None
)
top = got["top"]
if not want or len(top) < 2 or got["상태"] == "같음":
out[i] = got
continue
clash = lambda c: (s := soil(name(c))) is not None and s != want # noqa: E731
out[i] = {**got, "top": sorted(top, key=clash)}
return out
def _match(slots: list[list[str]], keys: set) -> dict | None:
"""slot 마다 다른 열쇠 하나(slot 후보 안) — 다 맞으면 {slot 차례: 열쇠}."""
assign: dict[str, int] = {}
def try_(s, seen):
for k in slots[s]:
if k in keys and k not in seen:
seen.add(k)
if k not in assign or try_(assign[k], seen):
assign[k] = s
return True
return False
for s in range(len(slots)):
if not try_(s, set()):
return None
return {s: k for k, s in assign.items()}
def backed(i: str, code: str | None) -> bool:
"""1순위가 줄 근거 조항과 맞음(같은 책 조항 같음 0.9 이상)."""
theirs = (S.BY[i].get("근거") or {}).get("출처") or []
our = hb.ours().get(code) if code else None
return bool(theirs and our and hb._clause(theirs, our)[0] >= 0.9)
def apply_combo(res: dict, mode: str, guard: bool = False) -> tuple[dict, list]:
"""mode 'whole' = 호표 로직 줄 수 = 조합 로직 수 · 'inside' = 호표 줄이 모두 한 조합 안(줄 2 이상) ·
guard = 1순위가 줄 근거와 맞는 줄은 안 바꿈."""
out = dict(res)
hits = []
for key, ids in MEMBERS.items():
ids = [i for i in ids if res[i]["top"]]
if not ids or (mode == "inside" and len(ids) < 2):
continue
slots = [res[i]["top"][:5] for i in ids]
found = []
for ck, cname, codes in COMBOS:
keys = set(codes)
if mode == "whole" and len(keys) != len(ids):
continue
m = _match(slots, keys)
if m is not None:
rank = sum(slots[s].index(k) for s, k in m.items())
found.append((rank, ck, cname, m))
if not found:
continue
found.sort()
rank, ck, cname, m = found[0]
hits.append((key, ck, cname, len(found), rank))
for s, k in m.items():
i = ids[s]
top = res[i]["top"]
if top[0] != k and not (guard and backed(i, top[0])):
out[i] = {"상태": "후보", "top": [k] + [c for c in top if c != k][:4]}
return out, hits
def apply_name_vote(res: dict) -> dict:
"""한 문서 안 정리 이름이 같은 줄(규격만 다름)은 같은 로직 — 다수 1순위가 다섯 안에 있으면 그것."""
out = dict(res)
groups: dict = {}
for i in res:
groups.setdefault((S.BY[i]["파일"], hm.tidy(S.BY[i]["이름"])), []).append(i)
for ids in groups.values():
if len(ids) < 2:
continue
votes: dict = {}
for i in ids:
t = (res[i]["top"] or [None])[0]
if t:
votes[t] = votes.get(t, 0) + S.BY[i]["줄수"]
ranked = sorted(votes.items(), key=lambda x: -x[1])
if not ranked or (len(ranked) > 1 and ranked[0][1] == ranked[1][1]):
continue
major = ranked[0][0]
for i in ids:
top = res[i]["top"]
if top and top[0] != major and major in top[:5] and not backed(i, top[0]):
out[i] = {"상태": "후보", "top": [major] + [c for c in top if c != major][:4]}
return out
def base_runs() -> dict:
"""{이름: (결과, 후보 풀)} — 지금 · 묶음 ①(C3 추천 결합) · 씨앗 끔 둘."""
out = {}
out["지금"] = (S.run(), pools())
with patched(hb, _clause=clause_prefix), patched(hm, candidates=candidates_nofold):
out["묶음①"] = (S.run(typo_guard), pools())
with no_seeds():
out["지금(씨앗 끔)"] = (S.run(), pools())
with patched(hb, _clause=clause_prefix), patched(hm, candidates=candidates_nofold):
out["묶음①(씨앗 끔)"] = (S.run(typo_guard), pools())
return out
def changed(a: dict, b: dict) -> list:
rows = []
for i in a:
if not S.LAB[i]["정답"]:
continue
t1, t2 = (a[i]["top"] or [None])[0], (b[i]["top"] or [None])[0]
if t1 != t2:
s, lo = set(S.LAB[i]["정답"]), set(S.LAB[i]["정답"]) | set(S.LAB[i]["허용"])
mark = lambda t: "O" if t in s else "o" if t in lo else "x" # noqa: E731
rows.append((i, S.BY[i]["이름"][:18], f"{mark(t1)}→{mark(t2)}", name(t1)[:14], name(t2)[:14]))
return rows
def main():
runs = base_runs()
rows, keep = [], {}
for base, (res, pool) in runs.items():
steps = {"": res}
steps["+책(줄→호표)"] = apply_book(res, pool, "line")
steps["+책(호표만)"] = apply_book(res, pool, "hopyo")
steps["+토질"] = apply_soil(res)
steps["+조합 통째"], hits_w = apply_combo(res, "whole")
steps["+조합 안"], hits_i = apply_combo(res, "inside")
steps["+조합 통째(근거 지킴)"], _ = apply_combo(res, "whole", True)
steps["+조합 안(근거 지킴)"], _ = apply_combo(res, "inside", True)
steps["+같은 이름 다수(문서)"] = apply_name_vote(res)
good, _ = apply_combo(apply_name_vote(apply_soil(res)), "inside", True)
steps["+(가) 좋은 것(토질 · 이름 다수 · 조합 안 근거 지킴)"] = good
allc = apply_soil(apply_book(res, pool, "line"))
allc, _ = apply_combo(allc, "whole")
steps["+모두(책 · 토질 · 통째)"] = allc
for tag, r in steps.items():
rows.append(S.table(f"{base}{tag}", r))
keep[base] = {k: v for k, v in steps.items()}
if base in ("지금", "묶음①"):
print(f"== {base}: 조합 통째 맞음 호표 {len(hits_w)} · 안에 다 듦 {len(hits_i)}")
for key, ck, cname, n, rank in hits_w + hits_i:
print(f" {key} {HEADS.get(key)[:24]} → {ck} {cname} (맞는 조합 {n} · 순위합 {rank})")
for tag in ("+책(줄→호표)", "+책(호표만)", "+토질", "+조합 통째", "+조합 안", "+조합 통째(근거 지킴)", "+조합 안(근거 지킴)", "+같은 이름 다수(문서)"):
for row in changed(res, steps[tag]):
print(f" [{tag}] {row}")
for row in rows:
S.show(row)
(HERE / "closed.json").write_text(
json.dumps({b: {k: v for k, v in s.items()} for b, s in keep.items()}, ensure_ascii=False), "utf-8"
)
(HERE / "closed_table.json").write_text(json.dumps(rows, ensure_ascii=False, indent=1), "utf-8")
if __name__ == "__main__":
main()
@@ -0,0 +1,254 @@
"""req226 C5 — 조합 풀이 엔진(닫힌 세계 · 금액 안 씀).
블록(단산 · STmate D) 또는 직접 줄 호표의 노임 · 장비 벡터를 우리 로직 단위당 벡터 조합으로 풂:
① 통째 하나 — 블록 합을 로직 하나가 설명(맞음 ≥ 0.85)
② 절마다 하나 — 맨 윗 번호 절(1. 2. 3.)마다(맞음 ≥ 0.75)
③ 몫마다 — 절 안 가. 나. 몫마다 · 장비만 든 몫 = 「기계 시간당 기계경비」(기종 입력 · 닫힌 세계 규칙) ·
노임 몫 = 맞음 ≥ 0.6
후보 풀 = 지금 맞춤 합친 후보 15(그대로 부름) + 직종 색인(드문 직종 · 장비 40 개 이하) + 절 제목 이름 후보 8.
순위 = 벡터 맞음 + 앞선 믿음(지금 순위 0.3 · 줄 근거 조항 같음(윗마디 포함) 0.3 · 같은 책 0.1).
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
C1 = HERE.parent.parent / "req225" / "C1"
for p in (HERE, C1):
sys.path.insert(0, str(p))
import c03_vec as V # noqa: E402
from c01_trees import ROOT # noqa: E402,F401
from s04_eval import clause_prefix, hb, hm # noqa: E402
MACHINE = {"굴착기": "GC000267"} # 굴착기(무한궤도) 시간당 기계경비 · 그 밖 장비 = GC000268
MACHINE_ANY = "GC000268"
MACHINE_SET = {"GC000267", "GC000268", "GC000269", "GC000275"}
RARE = {d for d, codes in V.IDX.items() if len(codes) <= 40}
def merged(line: dict, got: dict, n: int = 15) -> list[str]:
basis = hb.candidates(line, got)
named = hm.candidates(line, "로직", got, None)
return [c["로직"] for c in hb.merge(basis, named) if c.get("로직")][:n]
def title_cands(title: str, unit: str, got: dict, n: int = 8) -> list[str]:
text = re.sub(r"[\d.·:()\[\]%]+", " ", str(title or ""))
text = re.sub(r"\s+", "", text)
if len(text) < 2:
return []
return [c["로직"] for c in hm.candidates({"이름": text, "단위": unit}, "로직", got, n) if c.get("로직")]
class Prior:
"""앞선 믿음 — 지금 순위 · 줄 근거 조항(윗마디 포함) · 같은 책."""
def __init__(self, line: dict, ranked: list[str]):
self.rank = {c: k for k, c in enumerate(ranked)}
self.n = max(len(ranked), 1)
self.theirs = [list(x) for x in ((line.get("근거") or {}).get("출처") or [])]
self.books = {b for b, _ in self.theirs}
self.table = hb.ours()
def __call__(self, code: str) -> float:
p = 0.0
if code in self.rank:
p += 0.3 * (1 - self.rank[code] / self.n)
our = self.table.get(code)
if our and self.theirs:
if clause_prefix(self.theirs, our)[0] >= 0.9:
p += 0.3
if our["책"] in self.books:
p += 0.1
return p
def _sum(parts: list[dict]) -> dict:
out = {"노임": {}, "장비": {}}
for s in parts:
for k in ("노임", "장비"):
for d, q in (s.get(k) or {}).items():
out[k][d] = out[k].get(d, 0) + q
return out
def best(E: dict, pool: list[str], prior: Prior, share: bool) -> list[tuple]:
"""[(순위 점수, 맞음, 몫 x, 로직, 입력)] — 순위 차례."""
out = []
for c in pool:
f, given, x = fit(E, c, share)
if f <= 0:
continue
out.append((f + prior(c), f, x, c, given))
out.sort(key=lambda z: -z[0])
return out
def fit(E: dict, code: str, share: bool) -> tuple:
top = (0.0, "{}", 1.0)
for given, O in V.variants(code):
s = V.sim(E, O)
if s > top[0]:
top = (s, given, 1.0)
if share:
for x in V.SHARES:
s2 = 0.95 * V.sim(E, O, x)
if s2 > top[0]:
top = (s2, given, x)
return top
def machine_of(E: dict) -> str:
kinds = {k.split("|")[0] for k in E.get("장비") or {}}
return MACHINE["굴착기"] if kinds == {"굴착기"} else MACHINE_ANY
def pool_for(E: dict, base: list[str], extra: list[str]) -> list[str]:
rare = set()
for d in V.dims(E):
if d in RARE:
rare |= V.IDX[d]
return list(dict.fromkeys([*base, *extra, *sorted(rare)]))
def solve(parts: list[dict], line: dict, got: dict, base: list[str] | None = None) -> dict:
"""parts(몫 목록 · 절 표시) → {고름: [(로직, 어디, 맞음, x, 단계)], 통째: …}."""
base = base if base is not None else merged(line, got)
prior = Prior(line, base)
unit = str(line.get("단위") or "")
if not parts:
return {"고름": [], "단계": "벡터 없음"}
whole = _sum(parts)
share = any("%" in str(p.get("제목")) for p in parts)
titles = list(dict.fromkeys(str(p.get("절") or p.get("제목") or "") for p in parts))
extra = [c for t in titles for c in title_cands(t, unit, got)]
pool = pool_for(whole, base, extra)
ranked = best(whole, pool, prior, share)
if ranked and ranked[0][1] >= 0.85:
_, f, x, c, given = ranked[0]
return {"고름": [(c, "통째", round(f, 3), x, "①")], "단계": "①"}
chosen = []
groups: dict[str, list] = {}
for p in parts:
groups.setdefault(str(p.get("절") or p.get("제목") or ""), []).append(p)
for title, ps in groups.items():
E = _sum(ps)
sh = share or "%" in title or any("%" in str(p.get("제목")) for p in ps)
cand = best(E, pool_for(E, base, title_cands(title, unit, got)), prior, sh)
if cand and cand[0][1] >= 0.75:
_, f, x, c, given = cand[0]
chosen.append((c, title, round(f, 3), x, "②"))
continue
for p in ps:
if p.get("장비") and not p.get("노임"):
chosen.append((machine_of(p), p.get("제목"), 0.0, 1.0, "③장비"))
continue
sh2 = sh or "%" in str(p.get("제목"))
cand = best(p, pool_for(p, base, title_cands(p.get("제목"), unit, got)), prior, sh2)
if cand and cand[0][1] >= 0.6:
_, f, x, c, given = cand[0]
chosen.append((c, p.get("제목"), round(f, 3), x, "③"))
return {"고름": chosen, "단계": "②③"}
def codes(result: dict) -> list[str]:
return list(dict.fromkeys(c for c, *_ in result.get("고름") or []))
# ── 1순위 + 설명 안 된 몫(조합 풀이) ──────────────────────────────────────
_SHARE = re.compile(r"(\d{1,3})\s*%")
_RATIO = re.compile(r"(\d)\s*:\s*(\d)")
def cover(E: dict, code: str) -> float:
"""E 의 차원을 로직 벡터가 얼마나 덮나(로직의 남는 차원은 안 셈) — 갈래 중 가장 좋은 것."""
top = 0.0
for _, O in V.variants(code):
num = den = 0.0
for t, a in (E.get("노임") or {}).items():
w = 0.5 if t == V.COMMON else 1.0
b = (O.get("노임") or {}).get(t, 0.0)
den += w
num += w * (min(a, b) / max(a, b) if a > 0 and b > 0 else 0)
for k, a in (E.get("장비") or {}).items():
kind = k.split("|")[0]
den += 1
if any(k2.split("|")[0] == kind for k2 in (O.get("장비") or {})):
num += 1
top = max(top, num / den if den else 0)
return top
def is_mixed(parts: list[dict], line: dict) -> bool:
"""몫 나눔(기계 90% + 인력 10% · 9 : 1) — 제목 · 이름 · 규격에 몫 표시 + 노임만 든 몫 + 장비만 든 몫."""
text = " ".join([str(p.get("제목")) for p in parts] + [str(line.get("이름")), str(line.get("규격"))])
marked = bool(_SHARE.search(text) or _RATIO.search(text))
man = any(p.get("노임") and not p.get("장비") for p in parts)
mach = any(p.get("장비") and not p.get("노임") for p in parts)
return marked and man and mach
def _hint(title: str) -> float | None:
m = _SHARE.search(str(title or ""))
if m and 0 < int(m.group(1)) < 100:
return int(m.group(1)) / 100
return None
def best_hint(E, pool, prior, share, hint):
out = []
for score, f, x, c, given in best(E, pool, prior, share):
if hint is not None:
score += 0.1 if abs(x - hint) < 1e-9 else -0.1
out.append((score, f, x, c, given))
out.sort(key=lambda z: -z[0])
return out
def combine(primary: str | None, parts: list[dict], line: dict, got: dict, base: list[str]) -> list[tuple]:
"""[(로직, 어디, 맞음, x, 단계)] — 1순위 + 1순위가 못 덮는 절 · 몫의 로직."""
chosen = [(primary, "1순위", None, 1.0, "1순위")] if primary else []
if not parts:
return chosen
prior = Prior(line, base)
unit = str(line.get("단위") or "")
mixed = is_mixed(parts, line)
groups: dict[str, list] = {}
for p in parts:
groups.setdefault(str(p.get("절") or p.get("제목") or ""), []).append(p)
have = {primary} if primary else set()
for title, ps in groups.items():
E = _sum(ps)
if primary and cover(E, primary) >= 0.6:
continue
if E["장비"] and not E["노임"]: # 장비만 든 절(상차 · 운반 · 기계 몫) = 기계 시간 로직(닫힌 세계 규칙)
if mixed or len(groups) > 1:
m = machine_of(E)
chosen.append((m, title, None, 1.0, "장비 몫"))
have.add(m)
continue
share = any(_hint(p.get("제목")) for p in ps) or _hint(title) is not None
cand = best_hint(E, pool_for(E, base, title_cands(title, unit, got)), prior, share, _hint(title))
if cand and cand[0][1] >= 0.75 and cand[0][3] not in have:
chosen.append((cand[0][3], title, round(cand[0][1], 3), cand[0][2], "절"))
have.add(cand[0][3])
continue
for p in ps:
if primary and cover(p, primary) >= 0.6:
continue
if p.get("장비") and not p.get("노임"):
if mixed or len(groups) > 1:
m = machine_of(p)
chosen.append((m, p.get("제목"), None, 1.0, "장비 몫"))
have.add(m)
continue
hint = _hint(p.get("제목"))
cand = best_hint(p, pool_for(p, base, title_cands(p.get("제목"), unit, got)), prior, hint is not None or share, hint)
if cand and cand[0][1] >= 0.6 and cand[0][3] not in have:
chosen.append((cand[0][3], p.get("제목"), round(cand[0][1], 3), cand[0][2], "몫"))
have.add(cand[0][3])
return chosen
@@ -0,0 +1,109 @@
"""req226 C5 — 정답 있는 세 파일 줄에 조합 풀이(c05) 걸기 → 줄마다 고른 로직 모음 · 갈래별 풂(4 번 물음).
python c06_lines.py → lines_solved.json · 갈래 표
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
C1 = HERE.parent.parent / "req225" / "C1"
for p in (HERE, C1):
sys.path.insert(0, str(p))
import c05_solve as SV # noqa: E402
import s10_all as S # noqa: E402
from s04_eval import clause_prefix, hb, hm, patched # noqa: E402
from s10_all import candidates_nofold # noqa: E402
TREES = json.loads((HERE / "trees.json").read_text(encoding="utf-8"))
PARTS = {}
for f in TREES:
for h in f["호표"]:
for one in h["줄"]:
if one.get("id") and one.get("몫") and one["id"] not in PARTS:
PARTS[one["id"]] = one["몫"]
FINER = ["N003", "N019", "N045", "S055", "S059", "Y010"] # 우리 로직이 더 잘게(철근 가공 + 조립)
COMPOSITE = { # 엑셀 복합 호표 · 복합 줄 — 모두 들어야 풂
"S062": {"GC000559", "GC000560"},
"N050": {"GC000705", "GC000714", "GC000712"},
"S095": {"GF000135", "GF000284"},
"S097": {"GF000135", "GF000284"},
**{i: {"GC000206", "GC000207"} for i in FINER},
}
BEST = "+(가) 좋은 것(토질 · 이름 다수 · 조합 안 근거 지킴)"
CLOSED = json.loads((HERE / "closed.json").read_text(encoding="utf-8"))
def mixed_ids() -> list[str]:
"""섞인 줄 = 몫 표시(10% · 9 : 1) + 노임만 든 몫 + 장비만 든 몫."""
return sorted(i for i, parts in PARTS.items() if S.LAB.get(i, {}).get("정답") and SV.is_mixed(parts, S.BY[i]))
def run(base_name: str = "묶음①", step: str = BEST) -> dict:
out = {}
tops = CLOSED[base_name][step]
with patched(hb, _clause=clause_prefix), patched(hm, candidates=candidates_nofold):
hm._targets.cache_clear()
hb._ours.cache_clear()
got = hm.targets()
for r in S.ALL:
i = r["id"]
line = {k: r.get(k) for k in ("이름", "규격", "수량", "단위", "단가", "갈래", "참조", "근거")}
primary = (tops[i]["top"] or [None])[0]
base = SV.merged(line, got)
chosen = SV.combine(primary, PARTS.get(i) or [], line, got, base)
out[i] = {"고름": chosen, "단계": "조합"}
return out
def main():
res = run()
SV.V.save_cache()
(HERE / "lines_solved.json").write_text(json.dumps(res, ensure_ascii=False, indent=1), "utf-8")
names = {x["로직"]: x["이름"] for x in hm.targets()["로직"]}
def show(i):
got = SV.codes(res[i])
lab = S.LAB[i]
print(f" {i} {S.BY[i]['이름'][:20]:<20} 정답 {lab['정답']} 허용 {lab['허용']}")
for c, where, f, x, step in res[i]["고름"]:
print(f" {step} {c} {names.get(c, '')[:22]:<22} 맞음 {f} x {x} ← {str(where)[:30]}")
return got
print("== 우리 로직이 더 잘게(철근 가공 + 조립)")
ok = sum(1 for i in FINER if COMPOSITE[i] <= set(show(i)))
print(f" 풂 {ok}/{len(FINER)}")
print("== 엑셀 복합 호표 · 복합 줄")
for i in ("S062", "N050", "S095", "S097"):
got = show(i)
print(f" → {'풂' if COMPOSITE[i] <= set(got) else '못 풂'}")
print("== 섞인 줄(기계 몫 + 인력 몫)")
mix = mixed_ids()
man_ok = both = 0
for i in mix:
got = set(show(i))
lab = set(S.LAB[i]["정답"]) | set(S.LAB[i]["허용"])
m = bool(got & lab)
k = bool(got & SV.MACHINE_SET)
man_ok += m
both += m and k
print(f" → 인력 몫(정답 · 허용) {'O' if m else 'X'} · 기계 몫(기계 시간 로직) {'O' if k else 'X'}")
print(f" 섞인 줄 {len(mix)} {mix} · 인력 몫 맞음 {man_ok} · 둘 다 {both}")
# 홑 정답 줄에 덧붙은 로직(잘못 쪼갬) 셈
single = [i for i in res if S.LAB[i]["정답"] and i not in COMPOSITE and i not in mix]
split = [i for i in single if len(SV.codes(res[i])) > 1]
hit1 = [i for i in single if set(SV.codes(res[i])) & (set(S.LAB[i]["정답"]) | set(S.LAB[i]["허용"]))]
print(f"== 홑 정답 줄 {len(single)} · 둘 넘게 고름 {len(split)} · 고른 것에 정답(느슨) 듦 {len(hit1)} · 벡터 없음 {sum(1 for i in single if not PARTS.get(i))}")
for i in split[:12]:
print(f" {i} {S.BY[i]['이름'][:18]} → {[names.get(c, c)[:12] for c in SV.codes(res[i])]} 정답 {S.LAB[i]['정답']}")
if __name__ == "__main__":
main()
@@ -0,0 +1,170 @@
"""req226 C5 — 새 지표 「조합 맞음」(호표 단위 · 정답 있는 세 파일).
정답 = 앞 정답(labels · labels_y)을 호표로 모음 — 줄마다 칸(slot) 하나(정답 여럿 = 그중 하나) ·
철근 가공 + 조립 · 도장 셋 · 잡석 운반 둘 = 모두(칸 여럿) · 정답 없는 줄 = 「없음」 칸(채울 수 없음) ·
호표 참조 줄 = 그 호표 칸을 아래에서 위로 더함.
T2(정할 것 8 다른 안) = 섞인 줄에 「기계 몫」 칸(기계 시간 로직) · 철골 = 가공조립 + 용접 두 칸.
우리 = 줄 1순위 모음(지금 · 묶음 ① · (가)) 또는 + 조합 풀이(c06 고른 것).
지표 = 통째 맞음(칸 다 참 · 남는 것 없음) · 칸 맞음 비율 · 고른 것 맞음 비율.
python c07_hopyo.py → 표 · hopyo_metric.json
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
C1 = HERE.parent.parent / "req225" / "C1"
for p in (HERE, C1):
sys.path.insert(0, str(p))
import c05_solve as SV # noqa: E402
import s10_all as S # noqa: E402
from c06_lines import COMPOSITE, FINER, PARTS # noqa: E402
TREES = json.loads((HERE / "trees.json").read_text(encoding="utf-8"))
CLOSED = json.loads((HERE / "closed.json").read_text(encoding="utf-8"))
SOLVED = json.loads((HERE / "lines_solved.json").read_text(encoding="utf-8"))
LABELED = ("북부청임도", "북부청사방", "동부청영월")
BEST = "+(가) 좋은 것(토질 · 이름 다수 · 조합 안 근거 지킴)"
ALL_OF = {i: sorted(v) for i, v in COMPOSITE.items() if i != "S062"}
MIXED = {i for i, parts in PARTS.items() if S.LAB.get(i, {}).get("정답") and SV.is_mixed(parts, S.BY[i])}
def slots_of(i: str, t2: bool) -> list[tuple[set, set]]:
"""[(엄격 칸, 느슨 칸)] — 정답 없으면 (set(), set())."""
lab = S.LAB[i]
loose_extra = set(lab["허용"])
if not lab["정답"]:
return [(set(), set())]
if i in ALL_OF:
out = [({x}, {x}) for x in ALL_OF[i]]
else:
out = [(set(lab["정답"]), set(lab["정답"]) | loose_extra)]
if t2 and i == "S062":
out.append(({"GC000560"}, {"GC000560"}))
if t2 and i in MIXED:
out.append((set(SV.MACHINE_SET), set(SV.MACHINE_SET)))
return out
def hopyo_index():
out = {}
for f in TREES:
if f["파일"] not in LABELED:
continue
for h in f["호표"]:
ids = list(dict.fromkeys(one["id"] for one in h["줄"] if one.get("id")))
refs = [one["참조"].get("번") for one in h["줄"] if one["갈래"] == "호표" and one.get("참조")]
out[(f["파일"], h["번"])] = {"ids": ids, "refs": [r for r in refs if r], "이름": h["이름"]}
return out
HOPYO = hopyo_index()
def gather(key, seen=()) -> list[str]:
"""호표 → 로직 줄 id(참조 호표 것까지 · 아래에서 위로)."""
h = HOPYO.get(key)
if not h or key in seen:
return []
out = list(h["ids"])
for no in h["refs"]:
out += gather((key[0], no), (*seen, key))
return list(dict.fromkeys(out))
FINAL = json.loads((HERE / "final.json").read_text(encoding="utf-8")) if (HERE / "final.json").exists() else {}
def chosen_of(i: str, pick: str) -> set:
if pick == "조합+뺌":
return set(FINAL["sets"][i]) - {None}
if pick == "조합+뺌-기계": # 정할 것 8 = 인력 몫 하나(기계 몫 로직은 안 넣음)
top = (FINAL["tops"][i]["top"] or [None])[0]
return {c for c in FINAL["sets"][i] if c and (c == top or c not in SV.MACHINE_SET)}
if pick.startswith("조합"):
return set(SV.codes({"고름": SOLVED[i]["고름"]})) - {None}
base, step = pick.split("|")
top = CLOSED[base][step][i]["top"]
return {top[0]} if top else set()
def judge(key, pick: str, t2: bool) -> dict | None:
ids = gather(key)
if not ids:
return None
slots, chosen = [], set()
for i in ids:
slots += slots_of(i, t2)
chosen |= chosen_of(i, pick)
out = {}
for mode, k in (("엄격", 0), ("느슨", 1)):
alts = set().union(*[s[k] for s in slots])
sat = sum(1 for s in slots if s[k] and chosen & s[k])
extra = chosen - alts
out[mode] = {
"통째": sat == len(slots) and not extra,
"칸": sat / len(slots),
"고름": (len(chosen & alts) / len(chosen)) if chosen else 0,
}
out["없음칸"] = any(not s[0] for s in slots)
out["칸수"] = len(slots)
return out
PICKS = {
"지금": "지금|",
"묶음 ①": "묶음①|",
"묶음 ① + (가)": f"묶음①|{BEST}",
"묶음 ① + (가) + 조합 풀이": "조합",
"… + 못 덮는 1순위 뺌(섞인 줄)": "조합+뺌",
"… 기계 몫 안 넣음(T1 쪽)": "조합+뺌-기계",
}
def table(t2: bool) -> list:
rows = []
for label, pick in PICKS.items():
res = {k: judge(k, pick, t2) for k in HOPYO}
res = {k: v for k, v in res.items() if v}
row = {"우리": label}
for scope, keys in (
("전체", list(res)),
("덮을 수 있음", [k for k, v in res.items() if not v["없음칸"]]),
):
for mode in ("엄격", "느슨"):
n = len(keys)
row[f"{scope} {mode} 통째"] = round(100 * sum(res[k][mode]["통째"] for k in keys) / n, 1)
row[f"{scope} {mode} 칸"] = round(100 * sum(res[k][mode]["칸"] for k in keys) / n, 1)
row[f"{scope} {mode} 고름"] = round(100 * sum(res[k][mode]["고름"] for k in keys) / n, 1)
row[f"{scope} 호표"] = len(keys)
for f in LABELED:
keys = [k for k in res if k[0] == f and not res[k]["없음칸"]]
row[f"{f} 엄격 통째"] = f"{sum(res[k]['엄격']['통째'] for k in keys)}/{len(keys)}"
rows.append(row)
return rows
def main():
out = {}
for t2 in (False, True):
name = "T2(섞인 줄 기계 몫 · 철골 두 칸)" if t2 else "T1(앞 정답 그대로)"
rows = table(t2)
out[name] = rows
print("==", name)
for r in rows:
print(
f" {r['우리']:<22} 호표 {r['전체 호표']}(덮을 수 있음 {r['덮을 수 있음 호표']}) | "
f"전체 통째 엄격 {r['전체 엄격 통째']} 느슨 {r['전체 느슨 통째']} · 칸 {r['전체 엄격 칸']}/{r['전체 느슨 칸']} · 고름 {r['전체 엄격 고름']}/{r['전체 느슨 고름']} | "
f"덮을 수 있음 통째 {r['덮을 수 있음 엄격 통째']}/{r['덮을 수 있음 느슨 통째']} · 칸 {r['덮을 수 있음 엄격 칸']}/{r['덮을 수 있음 느슨 칸']} | "
f"임도 {r['북부청임도 엄격 통째']} 사방 {r['북부청사방 엄격 통째']} 영월 {r['동부청영월 엄격 통째']}"
)
(HERE / "hopyo_metric.json").write_text(json.dumps(out, ensure_ascii=False, indent=1), "utf-8")
if __name__ == "__main__":
main()
@@ -0,0 +1,41 @@
"""조합 풀이 뒤 「줄 벡터를 못 덮는 1순위는 뺌」(닫힌 세계) — 줄 지표 · 호표 지표 다시 셈."""
import json, sys
from pathlib import Path
import c05_solve as SV
import s10_all as S
from c06_lines import PARTS, CLOSED, BEST
SOLVED = json.loads((Path(__file__).parent / "lines_solved.json").read_text(encoding="utf-8"))
def regroup(cut: float = 0.35) -> tuple[dict, dict]:
"""(줄 결과 {id: {상태, top}}, 고른 것 {id: [로직]})."""
base = CLOSED["묶음①"][BEST]
tops, sets, dropped = {}, {}, []
for i, got in base.items():
chosen = [c for c, *_ in SOLVED[i]["고름"] if c]
chosen = list(dict.fromkeys(chosen))
top = list(got["top"])
parts = PARTS.get(i) or []
if top and len(chosen) > 1 and parts and SV.is_mixed(parts, S.BY[i]): # 몫 나눈 줄만
whole = SV._sum(parts)
if SV.cover(whole, top[0]) < cut:
rest = [c for c in chosen if c != top[0]]
labor = [c for c, where, f, x, step in SOLVED[i]["고름"] if c in rest and step in ("몫", "절")]
if labor:
dropped.append((i, top[0], labor[0]))
chosen = rest
top = [labor[0]] + [c for c in top if c != labor[0]][:4]
tops[i] = {"상태": got["상태"], "top": top}
sets[i] = chosen
return tops, sets, dropped
if __name__ == "__main__":
tops, sets, dropped = regroup(0.35)
(Path(__file__).parent / "final.json").write_text(json.dumps({"tops": tops, "sets": sets}, ensure_ascii=False), "utf-8")
for cut in (0.2, 0.35, 0.5):
tops, sets, dropped = regroup(cut)
S.show(S.table(f"(가)+조합 1순위 뺌 {cut}", tops))
print(" 뺀 줄", [(i, a, b) for i, a, b in dropped])
S.show(S.table("(가) 그대로", CLOSED["묶음①"][BEST]))
@@ -0,0 +1,183 @@
"""req226 C5 — (나) 직접 줄 호표(노임 · 장비 줄이 호표에 바로) → 우리 로직 조합 풀이.
E = 호표의 노임 · 장비 줄 벡터(참조 줄 뺌). 셋을 견줌:
이름만 = 호표 머리(이름 · 규격 · 단위)를 지금 맞춤 함수에 줄로 넣음(330 근거 없음)
이름 + 인용 = STmate 근거글(「건설표준품셈 7-2-2(찰붙임)」)을 근거 출처로 함께
벡터 풀이 = c05 조합 풀이(이름 + 인용 후보 · 드문 직종 색인) — 둘 넘게면 음 아닌 최소제곱 짝까지
판정 = 인용 정답(인용 조항 · 직종이 맞는 우리 로직 · 짐작) · 거창 다섯 사람 판정(짐작).
python c09_direct.py → 표 · direct.json
"""
from __future__ import annotations
import itertools
import json
import sys
from pathlib import Path
import numpy as np
from scipy.optimize import nnls
HERE = Path(__file__).resolve().parent
C1 = HERE.parent.parent / "req225" / "C1"
for p in (HERE, C1):
sys.path.insert(0, str(p))
import c05_solve as SV # noqa: E402
from s04_eval import clause_prefix, hb, hm # noqa: E402
TREES = json.loads((HERE / "trees.json").read_text(encoding="utf-8"))
HUMAN = { # 사람 판정(짐작) — 거창 직접 줄 호표 · 일위대가조합 UA000014(거창 5 · 6 출처)와 맞음
("거창", 1): {"GC000198"},
("거창", 2): {"GC000198"},
("거창", 3): {"GC000211"},
("거창", 10): {"GC000198"},
("거창", 11): {"GC000015"},
}
def direct_hopyos():
for f in TREES:
for h in f["호표"]:
if not any(one["갈래"] in ("노임", "장비") for one in h["줄"]):
continue
E = {"노임": {}, "장비": {}}
for one in h["줄"]:
for k in ("노임", "장비"):
for d, q in (one.get(k) or {}).items():
if d and q:
E[k][d] = E[k].get(d, 0) + float(q)
cite = " ".join(f"{one['이름']} {one['규격']}" for one in h["줄"] if one["갈래"] == "근거글")
yield f["파일"], h, E, cite
def cite_truth(theirs: list, E: dict) -> set:
trades = {t for t in E["노임"] if t != "보통인부"}
out = set()
table = hb.ours()
for one in hm.targets()["로직"]:
our = table.get(one["로직"])
if not our or clause_prefix(theirs, our)[0] < 0.9:
continue
if trades and not (trades & our["직종"]):
continue
out.add(one["로직"])
return out
def pair_fit(E: dict, pool: list[str]) -> tuple:
"""둘 짝 음 아닌 최소제곱 — (맞음, (로직 · x) …)."""
dims = sorted({("노임", d) for d in E["노임"]} | {("장비", d.split("|")[0]) for d in E["장비"]})
if len(dims) < 2:
return (0.0, ())
def col(O):
out = []
for k, d in dims:
if k == "노임":
out.append((O.get("노임") or {}).get(d, 0.0))
else:
out.append(sum(v for kk, v in (O.get("장비") or {}).items() if kk.split("|")[0] == d))
return np.array(out)
target = col({"노임": E["노임"], "장비": E["장비"]})
scale = np.where(target > 0, 1 / np.maximum(target, 1e-9), 1.0)
best = (0.0, ())
ranked = sorted(pool, key=lambda c: -SV.fit(E, c, False)[0])[:6]
vecs = {c: [O for _, O in SV.V.variants(c)][:3] for c in ranked}
for a, b in itertools.combinations(ranked, 2):
for Oa in vecs[a]:
for Ob in vecs[b]:
A = np.stack([col(Oa) * scale, col(Ob) * scale], axis=1)
x, _ = nnls(A, target * scale)
if (x <= 0.05).any():
continue
mix = {"노임": {}, "장비": {}}
for O, w in ((Oa, x[0]), (Ob, x[1])):
for k in ("노임", "장비"):
for d, q in (O.get(k) or {}).items():
mix[k][d] = mix[k].get(d, 0) + q * w
s = SV.V.sim(E, mix)
if s > best[0]:
best = (s, ((a, round(x[0], 2)), (b, round(x[1], 2))))
return best
def main():
got = hm.targets()
names = {x["로직"]: x["이름"] for x in got["로직"]}
rows = []
for tag, h, E, cite in direct_hopyos():
head = {"이름": h["이름"], "규격": h["규격"], "단위": h["단위"], "갈래": "단가산출"}
theirs = hb.sources(cite) if cite else []
trades = sorted(t for t in E["노임"] if t)
with_cite = {**head, "근거": {"출처": theirs, "직종": trades}} if theirs else head
m0 = hm.match_line(head, [], {}, True, got)
m1 = hm.match_line(with_cite, [], {}, True, got)
def top(m):
if m["상태"] == "같음":
return m["대상"].get("로직")
return next((c.get("로직") for c in m.get("후보") or [] if c.get("로직")), None)
base = SV.merged(with_cite, got)
sol = SV.solve([{"제목": "", "노임": E["노임"], "장비": E["장비"]}], with_cite, got, base)
chosen = SV.codes(sol)
sol0 = SV.solve([{"제목": "", "노임": E["노임"], "장비": E["장비"]}], head, got, SV.merged(head, got))
chosen0 = SV.codes(sol0)
f1 = sol["고름"][0][2] if sol["고름"] else 0
pair = (0.0, ())
if not chosen or (f1 or 0) < 0.85:
pair = pair_fit(E, SV.pool_for(E, base, [])[:20])
truth = HUMAN.get((tag, h["번"])) or (cite_truth(theirs, E) if theirs else set())
rows.append(
{
"파일": tag,
"호표": h["번"],
"이름": f"{h['이름']} {h['규격']}".strip(),
"E": E,
"인용": cite,
"정답": sorted(truth),
"정답출처": "사람" if (tag, h["번"]) in HUMAN else ("인용" if theirs else ""),
"이름만": top(m0),
"이름+인용": top(m1),
"벡터": chosen,
"벡터(인용 없이)": chosen0,
"벡터맞음": f1,
"짝": pair,
}
)
SV.V.save_cache()
(HERE / "direct.json").write_text(json.dumps(rows, ensure_ascii=False, indent=1, default=str), "utf-8")
judged = [r for r in rows if r["정답"]]
for key in ("이름만", "이름+인용"):
ok = sum(1 for r in judged if r[key] in r["정답"])
print(f"{key}: {ok}/{len(judged)}")
ok = sum(1 for r in judged if r["벡터"] and r["벡터"][0] in r["정답"])
print(f"벡터 풀이 1순위: {ok}/{len(judged)}")
import c04_closed as CC # 느슨 = 같은 일 다른 책(정리 이름 글자 묶음 겹침)
def loose(c, truth):
return bool(c) and (c in truth or any(CC.same_work(c, t) for t in truth))
for key in ("이름만", "이름+인용"):
print(f"{key} 느슨: {sum(1 for r in judged if loose(r[key], r['정답']))}/{len(judged)}")
print(f"벡터 느슨: {sum(1 for r in judged if r['벡터'] and loose(r['벡터'][0], r['정답']))}/{len(judged)}")
v0 = [r for r in judged if r["벡터(인용 없이)"]]
print(f"벡터(인용 없이) 엄격: {sum(1 for r in judged if r['벡터(인용 없이)'] and r['벡터(인용 없이)'][0] in r['정답'])}/{len(judged)} · 느슨 {sum(1 for r in v0 if loose(r['벡터(인용 없이)'][0], r['정답']))}/{len(judged)}")
nojudge = [r for r in rows if not r["정답"]]
print(f"정답 없는 직접 줄 호표 {len(nojudge)} — 벡터 고른 것 있음 {sum(1 for r in nojudge if r['벡터'])} · 맞음 0.85 넘음 {sum(1 for r in nojudge if (r['벡터맞음'] or 0) >= 0.85)}")
both = sum(1 for r in judged if (r["벡터"] and r["벡터"][0] in r["정답"]) or r["이름+인용"] in r["정답"])
print(f"둘 중 하나: {both}/{len(judged)}")
for r in rows:
mark = lambda c: ("O" if c in r["정답"] else "x") if r["정답"] else "?" # noqa: E731
v = r["벡터"][0] if r["벡터"] else None
print(
f"{r['파일']} {r['호표']} {r['이름'][:22]:<22} [{r['정답출처']}] 이름만 {mark(r['이름만'])} {names.get(r['이름만'], '')[:12]:<12} | "
f"+인용 {mark(r['이름+인용'])} {names.get(r['이름+인용'], '')[:12]:<12} | 벡터 {mark(v)} {names.get(v, '')[:12]:<12} {r['벡터맞음']} | 짝 {r['짝'][1] if r['짝'][0] else ''}"
)
if __name__ == "__main__":
main()
@@ -0,0 +1,206 @@
"""req226 C5 — 정답 없는 꼴(거창 · STmate 다섯) 호표 조합을 아래에서 위로 풀고, 일위대가조합.json 37 조합의
「실무 호표 — 봉화 B00005 …」 출처 표시를 사람 판정(짐작)으로 삼아 견줌.
우리 = (지금) 줄마다 지금 맞춤 1순위 모음(노임 · 장비 줄도 지금처럼 로직 줄로) /
(조합 풀이) 단산 줄 = 묶음 ① 1순위 + 조합 풀이(기계 몫 뺌 · 섞인 줄 1순위 뺌) ·
직접 노임 · 장비 줄 = c05 벡터 풀이(호표 이름 + 인용) · 호표 참조 = 그 호표 풀이(아래에서 위로).
조합 정답 = 그 호표를 출처로 적은 조합의 로직(출처 하나뿐인 조합 = 통째 견줌 · 여럿 = 고른 것 맞음만).
python c10_others.py [끔] → 표 · others.json
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
C1 = HERE.parent.parent / "req225" / "C1"
for p in (HERE, C1):
sys.path.insert(0, str(p))
import c05_solve as SV # noqa: E402
from c01_trees import ROOT # noqa: E402
from s04_eval import clause_prefix, hb, hm, patched # noqa: E402
from s10_all import candidates_nofold, no_seeds # noqa: E402
from s16_guard import typo_guard # noqa: E402
TREES = json.loads((HERE / "trees.json").read_text(encoding="utf-8"))
FILES = ("거창", "봉화", "동부청영월", "울진대흥", "영덕계류", "울진소광")
ALIAS = {"봉화": "봉화", "울진대흥": "울진대흥", "영월": "동부청영월", "계류보전": "영덕계류", "울진소광": "울진소광", "거창": "거창"}
COMBOS = json.loads((ROOT / "resources/master_data/일위대가조합.json").read_text(encoding="utf-8"))["줄"]
def combo_truth() -> dict:
"""{(파일, 호표 열쇠): [(조합 키, {로직}, 출처 수)]} — 호표 열쇠 = STmate B 코드 · 거창 번."""
out: dict = {}
for r in COMBOS:
note = str(r.get("비고") or "")
m = re.search(r"실무 호표 — (.+)$", note, re.M)
if not m:
continue
srcs = [s.strip() for s in m.group(1).split("·")]
codes = {x["로직"] for x in r["담은로직"]}
skip = tuple(
re.sub(r"\s+", "", ln.split(" — ")[0])
for ln in note.splitlines()
if " — " in ln and ("우리 로직에 없음" in ln or "이름이 달라 뺌" in ln)
)
for s in srcs:
hit = re.match(r"(\S+)\s+(B\d{5}|제(\d+)호표)", s)
if not hit or hit.group(1) not in ALIAS:
continue
key = (ALIAS[hit.group(1)], int(hit.group(3)) if hit.group(3) else hit.group(2))
out.setdefault(key, []).append((r["키"], codes, len(srcs), skip))
return out
def line_dict(one: dict) -> dict:
return {k: one.get(k) for k in ("이름", "규격", "수량", "단위", "단가", "갈래", "참조", "근거")}
def as_logic_line(one: dict) -> dict:
"""지금 읽기 갈래로 — 노임 줄 = 기타 · 장비 = 기계 · 단산 = 단가산출."""
kind = {"단산": "단가산출", "노임": "기타", "장비": "기계"}.get(one["갈래"], one["갈래"])
return {**line_dict(one), "갈래": kind}
def first(m: dict) -> str | None:
if m["상태"] == "같음":
return (m.get("대상") or {}).get("로직")
return next((c.get("로직") for c in m.get("후보") or [] if c.get("로직")), None)
class Solver:
def __init__(self, mode: str, got: dict):
self.mode, self.got = mode, got
self.memo: dict = {}
def hopyo_set(self, f: dict, no, trail=(), skip: tuple = ()) -> set:
"""skip = 사람이 조합에서 뺀 줄 글(「우리 로직에 없음」 · 「이름이 달라 뺌」) — 그 줄은 안 셈."""
key = (f["파일"], no, skip)
if key in self.memo:
return self.memo[key]
h = next((x for x in f["호표"] if x["번"] == no), None)
if h is None or key in trail:
return set()
out: set = set()
direct = {"노임": {}, "장비": {}}
flat = lambda t: re.sub(r"\s+", "", str(t)) # noqa: E731
for one in h["줄"]:
kind = one["갈래"]
text = flat(f"{one['이름']}{one['규격']}")
if skip and any(text and (text in x or x in text) for x in skip):
continue
if kind == "호표":
out |= self.hopyo_set(f, (one.get("참조") or {}).get("번"), (*trail, key), skip)
elif kind in ("단산", "노임", "장비") and self.mode == "지금":
t = first(hm.match_line(as_logic_line(one), [], {}, True, self.got))
if t:
out.add(t)
elif kind == "단산":
line = as_logic_line(one)
primary = typo_guard(line, self.got, {"단산글": one.get("단산글") or []})["top"]
primary = primary[0] if primary else None
parts = one.get("몫") or []
chosen = SV.combine(primary, parts, line, self.got, SV.merged(line, self.got))
codes = [c for c, *_ in chosen if c and c not in SV.MACHINE_SET]
if parts and SV.is_mixed(parts, line) and primary and SV.cover(SV._sum(parts), primary) < 0.35:
rest = [c for c, w, ff, x, st in chosen if c != primary and st in ("몫", "절")]
if rest:
codes = [c for c in codes if c != primary]
out |= set(codes)
elif kind in ("노임", "장비"):
for k in ("노임", "장비"):
for d, q in (one.get(k) or {}).items():
if d and q:
direct[k][d] = direct[k].get(d, 0) + float(q)
if self.mode != "지금" and (direct["노임"] or direct["장비"]):
cite = " ".join(f"{o['이름']} {o['규격']}" for o in h["줄"] if o["갈래"] == "근거글")
head = {"이름": h["이름"], "규격": h["규격"], "단위": h["단위"], "갈래": "단가산출"}
theirs = hb.sources(cite) if cite else []
if theirs:
head["근거"] = {"출처": theirs, "직종": sorted(direct["노임"])}
sol = SV.solve([{"제목": "", **direct}], head, self.got, SV.merged(head, self.got))
out |= {c for c in SV.codes(sol) if c not in SV.MACHINE_SET}
self.memo[key] = out
return out
def run(mode: str) -> list:
truth = combo_truth()
rows = []
with patched(hb, _clause=clause_prefix), patched(hm, candidates=candidates_nofold):
hm._targets.cache_clear()
hb._ours.cache_clear()
got = hm.targets()
for f in TREES:
if f["파일"] not in FILES:
continue
sv = Solver(mode, got)
for h in f["호표"]:
hkey = h["번"] if f["파일"] == "거창" else h.get("코드")
for ck, codes, nsrc, skip in truth.get((f["파일"], hkey), []):
ours = sv.hopyo_set(f, h["번"])
kept = sv.hopyo_set(f, h["번"], (), skip)
rows.append(
{
"파일": f["파일"],
"호표": h["번"],
"이름": f"{h['이름']} {h['규격']}".strip(),
"조합": ck,
"출처수": nsrc,
"정답": sorted(codes),
"우리": sorted(ours),
"우리(사람이 뺀 줄 뺌)": sorted(kept),
}
)
return rows
def report(rows: list, title: str) -> dict:
one = [r for r in rows if r["출처수"] == 1]
many = [r for r in rows if r["출처수"] > 1]
def stats(rs, exact: bool, col: str = "우리"):
if not rs:
return "0"
ex = sum(1 for r in rs if set(r[col]) == set(r["정답"]))
rec = sum(len(set(r[col]) & set(r["정답"])) / len(r["정답"]) for r in rs) / len(rs)
pre = sum((len(set(r[col]) & set(r["정답"])) / len(r[col])) if r[col] else 0 for r in rs) / len(rs)
head = f"통째 {ex}/{len(rs)} · " if exact else ""
return f"{head}조합 로직 찾음 {round(100 * rec, 1)}% · 고른 것 맞음 {round(100 * pre, 1)}%"
print(f"== {title}")
print(f" 출처 하나 조합(호표 {len(one)}): {stats(one, True)}")
print(f" └ 사람이 뺀 줄 빼고: {stats(one, True, '우리(사람이 뺀 줄 뺌)')}")
print(f" 출처 여럿 조합(호표 {len(many)}): {stats(many, False)}")
print(f" └ 사람이 뺀 줄 빼고: {stats(many, False, '우리(사람이 뺀 줄 뺌)')}")
return {"하나": stats(one, True), "하나(뺀 줄 뺌)": stats(one, True, "우리(사람이 뺀 줄 뺌)"), "여럿": stats(many, False), "여럿(뺀 줄 뺌)": stats(many, False, "우리(사람이 뺀 줄 뺌)")}
def main():
seeds_off = sys.argv[1:] == ["끔"]
out = {}
for mode in ("지금", "조합 풀이"):
if seeds_off:
with no_seeds():
rows = run(mode)
else:
rows = run(mode)
out[mode] = {"줄": rows, "표": report(rows, f"{mode}{' (씨앗 끔)' if seeds_off else ''}")}
SV.V.save_cache()
name = "others_off.json" if seeds_off else "others.json"
(HERE / name).write_text(json.dumps(out, ensure_ascii=False, indent=1), "utf-8")
names = {x["로직"]: x["이름"] for x in hm.targets()["로직"]}
for r in out["조합 풀이"]["줄"]:
if r["출처수"] == 1:
miss = [names.get(c, c)[:10] for c in set(r["정답"]) - set(r["우리(사람이 뺀 줄 뺌)"])]
extra = [names.get(c, c)[:10] for c in set(r["우리(사람이 뺀 줄 뺌)"]) - set(r["정답"])]
print(f" {r['파일']} {r['호표']} {r['이름'][:16]:<16} {r['조합']} 빠짐 {miss} 남음 {extra}")
if __name__ == "__main__":
main()
@@ -0,0 +1,19 @@
import c07_hopyo as H
from c07_hopyo import SV, S, FINAL, CLOSED, BEST, SOLVED
def sets_B(i): # (가) 1순위 + 조합 덧(기계 몫 뺌) · 뺌 없음
top = (CLOSED["묶음①"][BEST][i]["top"] or [None])[0]
extra = {c for c, *_ in SOLVED[i]["고름"] if c and c not in SV.MACHINE_SET}
return ({top} | extra) - {None}
def sets_C(i): # 뺌만(섞인 줄 1순위 바꿈) · 덧 없음
top = (FINAL["tops"][i]["top"] or [None])[0]
return {top} - {None}
for name, fn in (("B 덧만", sets_B), ("C 뺌만", sets_C)):
H.chosen_of = lambda i, pick, fn=fn: fn(i)
res = {k: H.judge(k, "x", False) for k in H.HOPYO}
res = {k: v for k, v in res.items() if v}
keys = [k for k, v in res.items() if not v["없음칸"]]
ex = sum(res[k]["엄격"]["통째"] for k in keys)
print(name, f"덮을 수 있음 통째 엄격 {ex}/{len(keys)} = {round(100*ex/len(keys),1)}", "칸", round(100*sum(res[k]["엄격"]["칸"] for k in keys)/len(keys),1))
@@ -0,0 +1,26 @@
import sys, collections
from c07_hopyo import *
from M02_MasterTemplete import M02_Hopyo_Match as hm
names = {x["로직"]: x["이름"] for x in hm.targets()["로직"]}
pick = sys.argv[1] if len(sys.argv) > 1 else "조합"
t2 = len(sys.argv) > 2
miss_c, extra_c = collections.Counter(), collections.Counter()
n = 0
for key in HOPYO:
ids = gather(key)
if not ids: continue
r = judge(key, pick, t2)
if r["없음칸"] or r["엄격"]["통째"]: continue
n += 1
slots, chosen = [], set()
for i in ids:
for s in slots_of(i, t2): slots.append((i, s))
chosen |= chosen_of(i, pick)
alts = set().union(*[s[0] for _, s in slots])
miss = [(i, sorted(s[0])) for i, s in slots if not chosen & s[0]]
extra = chosen - alts
for i, _ in miss: miss_c[i] += 1
for e in extra: extra_c[e] += 1
print("실패 호표", n)
print("빠진 칸(줄 id) 많은 차례", [(i, S.BY[i]["이름"][:12], c) for i, c in miss_c.most_common(20)])
print("남는 것 많은 차례", [(e, names.get(e, "")[:14], c) for e, c in extra_c.most_common(20)])
@@ -0,0 +1,15 @@
"""품 식 풀이 실패(금액 ÷ 노임으로 되찾음) 셈 — 북부청 · 거창 블록."""
import c01_trees as T
cnt = {"식": 0, "되찾음": 0}
orig = T.qty_of
def wrapped(expr):
v = orig(expr)
cnt["식" if v is not None else "되찾음"] += 1
return v
T.qty_of = wrapped
ids = T._ids_north()
for t, p in T.NORTH.items():
T.read_excel(t, p, ids, False)
for t, p in T.GEO.items():
T.read_excel(t, p, {}, True)
print(cnt)
@@ -0,0 +1,33 @@
"""씨앗 끔 바탕으로 같은 셈(줄 지표 · 호표 조합 지표) — 저장 파일 안 덮음."""
import json
import c06_lines as L6
import c07_hopyo as H
from c07_hopyo import SV, S
from c06_lines import CLOSED, BEST, PARTS
off = L6.run("묶음①(씨앗 끔)", BEST)
base = CLOSED["묶음①(씨앗 끔)"][BEST]
tops, sets = {}, {}
for i, got in base.items():
chosen = list(dict.fromkeys(c for c, *_ in off[i]["고름"] if c))
top = list(got["top"])
parts = PARTS.get(i) or []
if top and len(chosen) > 1 and parts and SV.is_mixed(parts, S.BY[i]) and SV.cover(SV._sum(parts), top[0]) < 0.35:
labor = [c for c, w, f, x, st in off[i]["고름"] if c != top[0] and st in ("몫", "절")]
if labor:
chosen = [c for c in chosen if c != top[0]]
top = [labor[0]] + [c for c in top if c != labor[0]][:4]
tops[i] = {"상태": got["상태"], "top": top}
sets[i] = chosen
S.show(S.table("씨앗 끔 (가)", base))
S.show(S.table("씨앗 끔 (가)+조합 뺌", tops))
for name, fn in (
("(가)", lambda i: {(base[i]["top"] or [None])[0]} - {None}),
("(가)+조합 풀이+뺌-기계", lambda i: {c for c in sets[i] if c and (c == (tops[i]["top"] or [None])[0] or c not in SV.MACHINE_SET)}),
):
H.chosen_of = lambda i, pick, fn=fn: fn(i)
res = {k: H.judge(k, "x", False) for k in H.HOPYO}
res = {k: v for k, v in res.items() if v}
keys = [k for k, v in res.items() if not v["없음칸"]]
ex = sum(res[k]["엄격"]["통째"] for k in keys)
print(name, f"T1 덮을 수 있음 통째 엄격 {ex}/{len(keys)} = {round(100*ex/len(keys),1)} · 칸 {round(100*sum(res[k]['엄격']['칸'] for k in keys)/len(keys),1)}")
@@ -0,0 +1,18 @@
import time, itertools
import c09_direct as D
from c09_direct import SV, hm, hb
got = hm.targets()
t = time.time()
for k, (tag, h, E, cite) in enumerate(D.direct_hopyos()):
if time.time() - t > 90: break
t0 = time.time()
head = {"이름": h["이름"], "규격": h["규격"], "단위": h["단위"], "갈래": "단가산출"}
theirs = hb.sources(cite) if cite else []
with_cite = {**head, "근거": {"출처": theirs, "직종": sorted(E["노임"])}} if theirs else head
m0 = hm.match_line(head, [], {}, True, got); t1 = time.time()
base = SV.merged(with_cite, got); t2 = time.time()
sol = SV.solve([{"제목": "", "노임": E["노임"], "장비": E["장비"]}], with_cite, got, base); t3 = time.time()
pool = SV.pool_for(E, base, [])
pr = D.pair_fit(E, pool[:20]); t4 = time.time()
tr = D.cite_truth(theirs, E) if theirs else set(); t5 = time.time()
print(tag, h["번"], "match", round(t1-t0,2), "merged", round(t2-t1,2), "solve", round(t3-t2,2), "pool", len(pool), "pair", round(t4-t3,2), "truth", round(t5-t4,2))
@@ -0,0 +1,7 @@
import json
from c06_lines import CLOSED, BEST
import s10_all as S
F = json.load(open("final.json", encoding="utf-8"))
for name, res in (("지금", CLOSED["지금"][""]), ("묶음①", CLOSED["묶음①"][""]), ("(가)", CLOSED["묶음①"][BEST]), ("(가)+조합 뺌", F["tops"])):
t = S.table(name, res)["전체"]
print(name, "고유", t["1위 엄격"], t["1위 느슨"], t["5안 엄격"], t["5안 느슨"], "| 엑셀 줄 무게 1위 느슨", t["줄 1위 느슨"], "5안 느슨", t["줄 5안 느슨"])
@@ -0,0 +1,18 @@
import json
from c06_lines import PARTS, FINER, COMPOSITE
import s10_all as S
import c05_solve as SV
t = json.load(open("trees.json", encoding="utf-8"))
for f in t:
d = [l for h in f["호표"] for l in h["줄"] if l["갈래"] == "단산"]
src = sum(1 for l in d if (l.get("근거") or {}).get("출처"))
trd = sum(1 for l in d if (l.get("근거") or {}).get("직종"))
vec = sum(1 for l in d if l.get("몫"))
multi = sum(1 for l in d if len({p.get("절") or p.get("제목") for p in l.get("몫") or []}) > 1)
print(f["파일"], "단산 줄", len(d), "출처", src, "직종", trd, "몫 벡터", vec, "절 둘 넘음", multi)
rows = lambda ids: sum(S.BY[i]["줄수"] for i in ids)
mixed = sorted(i for i, p in PARTS.items() if S.LAB.get(i, {}).get("정답") and SV.is_mixed(p, S.BY[i]))
print("철근 줄", FINER, rows(FINER), "섞인", mixed, rows(mixed))
F = json.load(open("final.json", encoding="utf-8"))
ok = [i for i in FINER if COMPOSITE[i] <= set(F["sets"][i])]
print("철근 풂", ok, rows(ok))
@@ -0,0 +1,23 @@
import json, sys, time
import c03_vec as V
import c01_trees # noqa: F401
from c01_trees import ROOT
from M02_MasterTemplete import M02_Hopyo_Match as hm
t=json.load(open('trees.json',encoding='utf-8'))
names={x['로직']:x['이름'] for x in hm.targets()['로직']}
want={'N000','N003','N005','S062','S066','N039','N008','N047'}
t0=time.time()
for f in t:
for h in f['호표']:
for l in h['줄']:
if l.get('id') in want:
want.discard(l['id'])
print('##', l['id'], l['이름'], l['규격'])
for s in l.get('몫') or []:
pool=set().union(*[V.IDX.get(d,set()) for d in V.dims(s)]) if V.dims(s) else set()
sc=sorted(((V.fit(s,c),c) for c in pool), key=lambda z:-z[0][0])[:4]
print(' 몫', s['제목'][:20], {k:round(v,3) for k,v in s['노임'].items()}, {k:round(v,3) for k,v in s['장비'].items()}, 'pool', len(pool))
for (sc_,g,x),c in sc:
print(' ', round(sc_,3), c, names.get(c), g[:60], x)
V.save_cache()
print('sec', round(time.time()-t0,1))
@@ -0,0 +1,26 @@
"""시트 이름 · 크기만 훑음(읽기만)."""
import sys, warnings
from pathlib import Path
import openpyxl
warnings.filterwarnings("ignore")
ROOT = Path(r"C:\Program_coding\임도설계 및 견적자동화 프로그램 개발\resources\knowledge\original\실무문서")
FILES = [
"01.산불거창(거창북상병곡)/01.내역서/00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx",
"04.산불 봉화/01. 내역서/2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.xlsx",
"05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx",
"2024년 간선임도(기번3-울진.대흥)(공통)/2024년 간선임도 신설사업(기번3 울진.울진.대흥.산65외2).xlsx",
"20250527 2025년 계류보전사업(기번1-영덕.병곡.영리.산214-1)(변경)/01.내역자료/2025년 계류보전사업(기번1 영덕.병곡.영.산214)(변경).xlsx",
"25년 산불진화임도(기번8)(울진소광)/2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).xlsx",
]
if __name__ == "__main__":
for f in FILES:
p = ROOT / f
head = p.read_bytes()[:8]
print("##", f, head[:4])
try:
b = openpyxl.load_workbook(p, read_only=True, data_only=True)
except Exception as e:
print(" fail", type(e).__name__, str(e)[:80]); continue
for ws in b.worksheets:
print(" ", repr(ws.title), ws.max_row, ws.max_column, ws.sheet_state)
b.close()
@@ -0,0 +1,14 @@
"""시트 앞 몇 줄 보기(읽기만) — python x01_peek.py <파일 번호> <시트> <시작> <끝>"""
import sys, warnings
from pathlib import Path
import openpyxl
warnings.filterwarnings("ignore")
from x00_sheets import FILES, ROOT
i, sheet, a, b = int(sys.argv[1]), sys.argv[2], int(sys.argv[3]), int(sys.argv[4])
book = openpyxl.load_workbook(ROOT / FILES[i], read_only=True, data_only=True)
ws = book[sheet]
for at, row in enumerate(ws.iter_rows(values_only=True), 1):
if at < a: continue
if at > b: break
cells = [(c, v) for c, v in enumerate(row) if v not in (None, "")]
print(at, " | ".join(f"{c}:{str(v)[:40]}" for c, v in cells))
@@ -0,0 +1,21 @@
"""STmate 일위대가표 줄 코드 갈래 셈(읽기만)."""
import re, warnings, collections
import openpyxl
from x00_sheets import FILES, ROOT
warnings.filterwarnings("ignore")
for i in range(1, 6):
book = openpyxl.load_workbook(ROOT / FILES[i], read_only=True, data_only=True)
rows = [list(r) for r in book["일위대가표"].iter_rows(values_only=True)]
book.close()
c = collections.Counter(); ex = {}
for r in rows:
code = str(r[12] or "").strip() if len(r) > 12 else ""
if not code: continue
m = re.match(r"^(별산)?([A-Z])\d", code)
k = (m.group(1) or "") + m.group(2) if m else "?" + code[:6]
c[k] += 1
ex.setdefault(k, []).append((str(r[0])[:20], str(r[1])[:20], r[3], code))
print(FILES[i][:30], dict(c))
for k, v in ex.items():
if k[-1] in "B%S?" or k.startswith("?"):
print(" ", k, v[:4])
@@ -0,0 +1,20 @@
"""C6 — 실무 xlsx 시트 이름 · 크기 훑기(읽기만)."""
import sys, warnings, time
from pathlib import Path
import openpyxl
warnings.filterwarnings("ignore")
ROOT = Path(r"C:\Program_coding\임도설계 및 견적자동화 프로그램 개발")
BASE = ROOT / "resources/knowledge/original/실무문서"
for p in sorted(BASE.rglob("*.xlsx")):
if "수량" in p.name and "내역" not in p.name:
continue
t = time.time()
head = p.read_bytes()[:8]
try:
wb = openpyxl.load_workbook(p, read_only=True, data_only=True)
info = [(ws.title, ws.max_row, ws.max_column) for ws in wb.worksheets]
wb.close()
except Exception as e:
info = f"ERR {type(e).__name__}: {e} head={head!r}"
print(p.relative_to(BASE), round(time.time()-t,1))
print(" ", info)
@@ -0,0 +1,24 @@
"""C6 — STmate 꼴 시트 줄 꼴 엿보기(읽기만). python s01_peek.py <파일번호> <시트> <시작> <끝>"""
import sys, warnings
from pathlib import Path
import openpyxl
warnings.filterwarnings("ignore")
ROOT = Path(r"C:\Program_coding\임도설계 및 견적자동화 프로그램 개발")
B = ROOT / "resources/knowledge/original/실무문서"
FILES = {
"봉화": B / "04.산불 봉화/01. 내역서/2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.xlsx",
"영월": B / "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx",
"대흥설계": B / "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/2024년 간선임도사업(기번3).xlsx",
"대흥착공": B / "2024년 간선임도(기번3-울진.대흥)(공통)/2024년 간선임도 신설사업(기번3 울진.울진.대흥.산65외2).xlsx",
"계류보전": B / "20250527 2025년 계류보전사업(기번1-영덕.병곡.영리.산214-1)(변경)/01.내역자료/2025년 계류보전사업(기번1 영덕.병곡.영.산214)(변경).xlsx",
"소광": B / "25년 산불진화임도(기번8)(울진소광)/2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).xlsx",
"거창": B / "01.산불거창(거창북상병곡)/01.내역서/00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx",
}
if __name__ == "__main__":
tag, sheet, a, b = sys.argv[1], sys.argv[2], int(sys.argv[3]), int(sys.argv[4])
wb = openpyxl.load_workbook(FILES[tag], read_only=True, data_only=True)
for i, r in enumerate(wb[sheet].iter_rows(values_only=True), 1):
if i < a: continue
if i > b: break
cells = [(j, v) for j, v in enumerate(r) if v not in (None, "")]
print(i, cells)
@@ -0,0 +1,210 @@
"""C6 — 실무 내역 넓히기: STmate 꼴 6 파일 + 거창(엑셀꼴) 읽기 → data_<태그>.json (읽기만).
STmate: 일위대가표(호표 · 줄 코드 D L M X B S W) + 단가산출근거(블록 = BDCOD|D 코드 · 자원 줄 = 12열 「L00002_1」 ·
7열 = 단위당 품(계수) · 23열 = 「이름 / 규격」) + 노무비목록표(L 코드 → 직종 이름 · 23열과 맞대 봄).
거창: 저장소 읽기 함수(hx.read_book · hb 블록) 그대로 — 「단산N참조」 줄만 블록이 있음.
북부청 두 파일은 C1 결과(out_items · out_lines)를 씀(정답 L 과 같은 id).
python s02_read.py → 파일마다 호표 · 줄 · 블록 수 표 + data_<태그>.json
"""
import json
import re
import sys
import warnings
from pathlib import Path
import openpyxl
ROOT = Path(r"C:\Program_coding\임도설계 및 견적자동화 프로그램 개발")
sys.path.insert(0, str(ROOT))
HERE = Path(__file__).resolve().parent
sys.stdout.reconfigure(encoding="utf-8")
warnings.filterwarnings("ignore")
from M02_MasterTemplete import M02_Hopyo_Basis as hb # noqa: E402
from M02_MasterTemplete import M02_Hopyo_Excel as hx # noqa: E402
B = ROOT / "resources/knowledge/original/실무문서"
STMATE = {
"봉화": B / "04.산불 봉화/01. 내역서/2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.xlsx",
"영월": B / "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx",
"대흥설계": B / "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/2024년 간선임도사업(기번3).xlsx",
"대흥착공": B / "2024년 간선임도(기번3-울진.대흥)(공통)/2024년 간선임도 신설사업(기번3 울진.울진.대흥.산65외2).xlsx",
"계류보전": B / "20250527 2025년 계류보전사업(기번1-영덕.병곡.영리.산214-1)(변경)/01.내역자료/2025년 계류보전사업(기번1 영덕.병곡.영.산214)(변경).xlsx",
"소광": B / "25년 산불진화임도(기번8)(울진소광)/2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).xlsx",
}
EXCEL = {"거창": B / "01.산불거창(거창북상병곡)/01.내역서/00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx"}
KIND = {"D": "단가산출", "X": "기계", "L": "노임", "M": "자재", "B": "호표", "S": "별도계상", "W": "일식"}
_HOPYO = re.compile(r"제\s*(\d+)\s*호표")
_LINE = re.compile(r"^([A-Z])(\d{5})_\d+$")
_COD = re.compile(r"COD\|([A-Z]\d{5})")
_BLOCK = re.compile(r"BDCOD\|(D\d{5})")
_HEADB = re.compile(r"BDCOD\|(B\d{5})")
def _num(v):
try:
return float(str(v).replace(",", ""))
except (TypeError, ValueError):
return None
def _s(v):
return str(v if v is not None else "").strip()
def _cell(r, i):
return r[i] if i < len(r) else None
def read_stmate(tag, path):
book = openpyxl.load_workbook(path, read_only=True, data_only=True)
rows = [list(r) for r in book["일위대가표"].iter_rows(values_only=True)]
calc = [list(r) for r in book["단가산출근거"].iter_rows(values_only=True)]
labor_rows = [list(r) for r in book["노무비목록표"].iter_rows(values_only=True)]
list_rows = [list(r) for r in book["단가산출근거목록표"].iter_rows(values_only=True)]
book.close()
# 단가산출근거목록표 — D 코드 → 이름 · 규격 · 단위(블록 머리 글은 이름 · 규격이 붙어 있음)
heads = {}
for r in list_rows:
hit = re.search(r"COD\|(D\d{5})", " ".join(_s(v) for v in r))
if hit and _s(_cell(r, 1)):
heads[hit.group(1)] = {"이름": _s(_cell(r, 1)), "규격": _s(_cell(r, 2)), "단위": _s(_cell(r, 3))}
# 노무비목록표 — L 코드 → 이름(열 7 코드 · 열 1 이름)
labor = {}
for r in labor_rows:
hit, name = re.search(r"COD\|(L\d{5})", " ".join(_s(v) for v in r)), _s(_cell(r, 1))
if hit and name:
labor[hit.group(1)] = name
# 일위대가표
hopyos, cur, need_head = [], None, False
for r in rows:
first = _s(_cell(r, 0))
hit = _HOPYO.match(first)
if hit:
bcode = _HEADB.search(_s(_cell(r, 13)))
cur = {"번": int(hit.group(1)), "코드": bcode.group(1) if bcode else None, "줄": []}
hopyos.append(cur)
need_head = True
continue
if cur is None:
continue
if need_head and first:
cur.update(이름=first, 규격=_s(_cell(r, 1)), 단위=_s(_cell(r, 3)))
need_head = False
continue
if first.replace(" ", "") == "합계":
cur = None
continue
m = _LINE.match(_s(_cell(r, 14))) or _COD.search(_s(_cell(r, 13)))
if not m or not first:
continue
code = m.group(1) + m.group(2) if m.re is _LINE else m.group(1)
cur["줄"].append(
{
"차례": len(cur["줄"]) + 1,
"이름": first,
"규격": _s(_cell(r, 1)),
"수량": _num(_cell(r, 2)),
"단위": _s(_cell(r, 3)),
"단가": {"계": _num(_cell(r, 4)), "노무비": _num(_cell(r, 6)), "재료비": _num(_cell(r, 8)), "경비": _num(_cell(r, 10))},
"코드": code,
"갈래": KIND.get(code[0], "기타"),
}
)
# 단가산출근거 블록
blocks, blk, mismatch = {}, None, 0
for at, r in enumerate(calc, 1):
joined = " ".join(_s(v) for v in r[:8])
bm = _BLOCK.search(joined)
if bm:
blk = blocks.setdefault(bm.group(1), {"코드": bm.group(1), "머리": "", "글": [], "자원": [], "행": [at, at]})
continue
if blk is None:
continue
if _s(_cell(r, 0)).replace(" ", "") == "총계":
blk = None
continue
blk["행"][1] = at
text = _s(_cell(r, 1))
if _s(_cell(r, 12)) == blk["코드"] and not blk["머리"]:
blk["머리"] = text
continue
if text:
blk["글"].append(text)
lm = _LINE.match(_s(_cell(r, 12)))
if lm:
letter, code = lm.group(1), lm.group(1) + lm.group(2)
name, _, spec = _s(_cell(r, 23)).partition("/")
if letter == "L" and labor.get(code) and labor[code].replace(" ", "") != name.replace(" ", ""):
mismatch += 1
blk["자원"].append({"갈래": letter, "코드": code, "이름": name.strip() or labor.get(code, ""), "규격": spec.strip(), "품": _num(_cell(r, 7)) or 0.0})
for code, blk in blocks.items():
blk.update(heads.get(code) or {})
return {"파일": tag, "꼴": "STmate", "호표": hopyos, "블록": blocks, "노무목록": labor, "이름어긋남": mismatch}
def read_excel(tag, path):
data = path.read_bytes()
book = hx.read_book(path.name, data)
sheets = hx._sheets(path.name, data)
calc = sheets.get(hb.SHEET) or []
start, col, _ = hb._columns(calc)
blocks = {}
for b in book["단산"]:
text, spread = [], []
for at in range(b["행"][0], b["행"][1] + 1):
row = calc[at - 1]
t = row[col] if col < len(row) else ""
if isinstance(t, str) and t.strip():
text.append(t.rstrip())
# 거창 꼴 — 글이 여러 칸에 흩어짐(한 칸 한 덩이) · 블록 끝 = 다음 머리 앞까지 · 칸을 빈칸으로 이어 붙임
nxt = min([x["행"][0] for x in book["단산"] if x["행"][0] > b["행"][0]] or [len(calc) + 1])
for at in range(b["행"][0] + 1, nxt):
row = calc[at - 1]
cells = [str(v).strip() for v in row[col + 1 : 141] if v not in (None, "") and str(v).strip()]
if cells:
spread.append(" ".join(cells))
blocks[f"단산{b['번']}"] = {"코드": f"단산{b['번']}", "머리": f"{b['이름']} [{b['단위']}]", "글": text[1:] if len(text) > 1 else spread, "흩어짐": len(text) <= 1, "자원": [], "행": b["행"], "이름": b["이름"], "규격": "", "단위": b["단위"]}
hopyos = []
for h in book["호표"]:
lines = []
for line in h["줄"]:
ref = line.get("참조") or {}
code = f"단산{ref['번']}" if ref.get("종류") == "단산" else (f"일위{ref['번']}" if ref.get("종류") == "일위" else None)
lines.append({k: hx._json(line.get(k)) for k in ("차례", "이름", "규격", "수량", "단위", "단가", "갈래")} | {"코드": code})
hopyos.append({"번": h["번"], "코드": f"일위{h['번']}", "이름": h["이름"], "규격": h["규격"], "단위": h["단위"], "줄": lines})
return {"파일": tag, "꼴": "엑셀꼴", "호표": hopyos, "블록": blocks, "노무목록": {}, "이름어긋남": 0}
def summary(doc):
lines = [x for h in doc["호표"] for x in h["줄"]]
kinds = {}
for x in lines:
kinds[x["갈래"]] = kinds.get(x["갈래"], 0) + 1
logic = [x for x in lines if x["갈래"] in ("단가산출", "기계", "기타")]
uniq = {(x["이름"], x["규격"], x["단위"], x["코드"]) for x in logic}
used = {x["코드"] for x in lines if x["코드"] in doc["블록"]}
return {
"호표": len(doc["호표"]),
"줄": len(lines),
"로직 줄": len(logic),
"로직 고유": len(uniq),
"블록": len(doc["블록"]),
"쓰인 블록": len(used),
"갈래": kinds,
"노무목록": len(doc["노무목록"]),
"이름어긋남": doc["이름어긋남"],
}
if __name__ == "__main__":
for tag, path in {**STMATE, **EXCEL}.items():
try:
doc = read_stmate(tag, path) if tag in STMATE else read_excel(tag, path)
except Exception as e: # noqa: BLE001
print(tag, "못 읽음", type(e).__name__, e)
continue
(HERE / f"data_{tag}.json").write_text(json.dumps(doc, ensure_ascii=False, indent=0, default=str), "utf-8")
print(tag, summary(doc))
@@ -0,0 +1,202 @@
"""C6 — 평가 단위(고유 줄) 만들기 + 블록 속(출처 · 직종 · 품 · 장비) 뽑기 → items.json · hopyo.json.
단위 = 파일마다 「로직 줄」 고유(이름 · 규격 · 단위 · 코드) — STmate 는 호표에 안 쓰인 D 블록(내역서가 바로 씀)도 넣음(호표쓰임 표시) ·
북부청 두 파일 = C1 out_items(같은 id · 정답 L) · 영월 = C1 id(Y000~) 이어 씀.
품: STmate = 자원 L 줄 7열(단위당 품) 합 · 엑셀꼴 = 글 「직종 : 단가 × … = 금액」 → 금액 ÷ 단가(가장 큰 수) — 금액은 품을 되찾는 데만 씀.
장비: STmate = 자원 X 줄 이름 · 엑셀꼴 = 글 속 장비 낱말 → 같은 낱말로 모음.
"""
import json
import re
import sys
from pathlib import Path
ROOT = Path(r"C:\Program_coding\임도설계 및 견적자동화 프로그램 개발")
sys.path.insert(0, str(ROOT))
HERE = Path(__file__).resolve().parent
C1 = HERE.parent.parent / "req225/C1"
sys.stdout.reconfigure(encoding="utf-8")
from M02_MasterTemplete import M02_Hopyo_Basis as hb # noqa: E402
from M02_MasterTemplete import M02_Hopyo_Match as hm # noqa: E402
FILES = ["영월", "봉화", "대흥설계", "계류보전", "소광", "거창"]
PREFIX = {"영월": "Y", "봉화": "B", "대흥설계": "U", "계류보전": "K", "소광": "G", "거창": "C"}
SHOW = {"영월": "동부청영월", "봉화": "봉화", "대흥설계": "울진대흥", "계류보전": "영덕계류", "소광": "울진소광", "거창": "거창"}
LOGIC = ("단가산출", "기계", "기타")
MACH = [ # (찾을 낱말, 모을 이름) — 앞이 먼저
("굴삭기", "굴착기"), ("굴착기", "굴착기"), ("백호", "굴착기"), ("b/h", "굴착기"),
("덤프", "덤프트럭"), ("크레인", "크레인"), ("불도저", "불도저"), ("불도우저", "불도저"), ("도저", "불도저"),
("진동롤러", "롤러"), ("롤러", "롤러"), ("로울러", "롤러"), ("래머", "래머"), ("램머", "래머"),
("콤팩터", "콤팩터"), ("컴팩터", "콤팩터"), ("브레이커", "브레이커"), ("브레이카", "브레이커"),
("펌프카", "펌프차"), ("펌프차", "펌프차"), ("그레이더", "그레이더"), ("살수차", "살수차"),
("발전기", "발전기"), ("공기압축기", "공기압축기"), ("드릴", "드릴"), ("믹서", "믹서"), ("트레일러", "트레일러"),
]
_LABOR_LINE = re.compile(r"^[\s\d\.\)\(○◇·\-*①-⑳가-힣]*?([가-힣][가-힣\s]*?)\s*:\s*(.+?)=\s*([\d,]+(?:\.\d+)?)\s*$")
_NUMS = re.compile(r"\d[\d,]*(?:\.\d+)?")
def machines(text):
low = re.sub(r"\s+", "", str(text or "")).lower()
out = set()
for word, name in MACH:
if word in low:
out.add(name)
low = low.replace(word, " ")
return sorted(out)
def trade_name(raw):
return hb.trade_names().get(re.sub(r"\s+", "", str(raw or "")))
def text_labor(lines):
"""엑셀꼴 글 → {직종: 품} (금액 ÷ 단가 · 단가 = 왼쪽에서 가장 큰 수 ≥ 1000)."""
out = {}
for t in lines:
m = _LABOR_LINE.match(str(t))
if not m:
continue
name = trade_name(m.group(1))
if not name:
continue
nums = [float(x.replace(",", "")) for x in _NUMS.findall(m.group(2))]
amount = float(m.group(3).replace(",", ""))
wage = max(nums) if nums else 0
if wage >= 1000 and amount > 0:
out[name] = round(out.get(name, 0) + amount / wage, 6)
return out
def block_features(blk):
text = blk.get("글") or []
joined = " ".join(text)
src = hb.sources(joined)
if blk.get("자원"):
labor, mach = {}, set()
for r in blk["자원"]:
if r["갈래"] == "L" and r["품"]:
name = trade_name(r["이름"])
if name:
labor[name] = round(labor.get(name, 0) + r["품"], 6)
if r["갈래"] == "X":
mach |= set(machines(r["이름"]))
mach = sorted(mach)
else:
labor, mach = text_labor(text), machines(joined)
return {"출처": src, "직종": sorted(labor), "품": labor, "장비": mach}
def from_c1():
"""북부청 두 파일 — C1 고유 줄 그대로(id · 근거) + 품 · 장비."""
out = []
for r in json.loads((C1 / "out_items.json").read_text(encoding="utf-8")):
f = {"출처": (r.get("근거") or {}).get("출처") or [], "직종": [], "품": {}, "장비": []}
if r.get("단산글"):
g = r["단산글"][1:]
f = block_features({"글": g})
f["출처"] = (r.get("근거") or {}).get("출처") or f["출처"]
out.append(
{
"id": r["id"], "파일": r["파일"], "이름": r["이름"], "규격": r["규격"], "단위": r["단위"],
"갈래": r["갈래"], "코드": f"단산{r['참조']['번']}" if (r.get("참조") or {}).get("종류") == "단산" else None,
"호표쓰임": True, "줄수": r["줄수"], "호표들": sorted(set(r["호표들"])),
"근거": {"출처": f["출처"], "직종": (r.get("근거") or {}).get("직종") or f["직종"]},
"품": f["품"], "장비": f["장비"], "글": (r.get("단산글") or [])[1:40],
}
)
return out
def from_file(tag):
d = json.loads((HERE / f"data_{tag}.json").read_text(encoding="utf-8"))
old = {}
if tag == "영월":
for y in json.loads((C1 / "out_stmate.json").read_text(encoding="utf-8")):
old[(y["이름"], y["규격"], y["단위"], y["코드"])] = y["id"]
seen, out, n = {}, [], 0
feats = {code: block_features(b) for code, b in d["블록"].items()}
def add(name, spec, unit, kind, code, used, hno):
nonlocal n
key = (name, spec, unit, code)
if key in seen:
if used:
seen[key]["줄수"] += 1
seen[key]["호표쓰임"] = True
if hno not in seen[key]["호표들"]:
seen[key]["호표들"].append(hno)
return
f = feats.get(code) or {"출처": [], "직종": [], "품": {}, "장비": machines(f"{name} {spec}") if kind == "기계" else []}
rid = old.get(key)
if rid is None:
rid = f"{PREFIX[tag]}{100 + n if tag == '영월' else n:03d}"
n += 1
rec = {
"id": rid, "파일": SHOW[tag], "이름": name, "규격": spec, "단위": unit, "갈래": kind, "코드": code,
"호표쓰임": used, "줄수": 1 if used else 0, "호표들": [hno] if used else [],
"근거": {"출처": f["출처"], "직종": f["직종"]}, "품": f["품"], "장비": f["장비"],
"글": (d["블록"].get(code) or {}).get("글", [])[:40],
}
seen[key] = rec
out.append(rec)
for h in d["호표"]:
for x in h["줄"]:
if x["갈래"] in LOGIC:
add(x["이름"], x["규격"], x["단위"], x["갈래"], x["코드"], True, h["번"])
for code, b in d["블록"].items(): # 호표에 안 쓰인 블록(내역서가 바로 씀)
if not any(r["코드"] == code for r in out):
add(b.get("이름") or b["머리"], b.get("규격") or "", b.get("단위") or "", "단가산출", code, False, None)
return out
def hopyo_units(tag):
"""호표 = 로직(노임 줄을 바로 적은 호표) — 이름 · 단위 · 직종 품 · 장비 · 줄 코드."""
if tag in ("북부청임도", "북부청사방"):
return []
d = json.loads((HERE / f"data_{tag}.json").read_text(encoding="utf-8"))
out = []
for h in d["호표"]:
labor = {}
for x in h["줄"]:
is_labor = x["갈래"] == "노임" or (x["갈래"] == "기타" and hm.mm.plain_unit(x["단위"]) == "인")
name = trade_name(x["이름"]) if is_labor else None
if name and x.get("수량"):
labor[name] = round(labor.get(name, 0) + float(x["수량"]), 6)
if labor:
mach = sorted({m for x in h["줄"] if x["갈래"] == "기계" for m in machines(x["이름"])})
out.append({"파일": SHOW[tag], "호표": h["번"], "이름": h.get("이름"), "규격": h.get("규격"), "단위": h.get("단위"), "품": labor, "장비": mach})
return out
def hopyo_map():
"""파일 → 호표 번 → 그 호표 로직 줄 id 목록(호표 조합 지문용)."""
lines = json.loads((C1 / "out_lines.json").read_text(encoding="utf-8"))
out = {}
return out, lines
if __name__ == "__main__":
items = from_c1()
units = []
for tag in FILES:
items += from_file(tag)
units += hopyo_units(tag)
(HERE / "items.json").write_text(json.dumps(items, ensure_ascii=False, indent=0), "utf-8")
(HERE / "hopyo_units.json").write_text(json.dumps(units, ensure_ascii=False, indent=0), "utf-8")
by = {}
for r in items:
s = by.setdefault(r["파일"], {"고유": 0, "호표쓰임": 0, "엑셀줄": 0, "출처있음": 0, "직종있음": 0, "품있음": 0})
s["고유"] += 1
s["호표쓰임"] += r["호표쓰임"]
s["엑셀줄"] += r["줄수"]
s["출처있음"] += bool(r["근거"]["출처"])
s["직종있음"] += bool(r["근거"]["직종"])
s["품있음"] += bool(r["품"])
for k, v in by.items():
print(k, v)
hu = {}
for u in units:
hu[u["파일"]] = hu.get(u["파일"], 0) + 1
print("호표=로직", hu)
@@ -0,0 +1,202 @@
"""C6 — 자동 정답(엄격): 엑셀 블록 인용 조항(같은 책 · 번호 같음) + 직종 묶음 · 품(±3%)이 우리 돈 로직 하나와만 맞으면 그 로직.
우리 품 = 로직을 입력 조합마다 돌려(고르기 모두 · 범위 끝 둘 · 지역 · 기계 고르기는 견본값) 「인」 줄 수량 —
원본 로직 · 마스터는 읽기만(엔진 run 을 같은 프로세스에서 부름).
python s04_auto.py → auto.json(id → {정답, 까닭, 후보 수}) + 사람 정답(L · LY) · 씨앗과 맞대 본 표
"""
import itertools
import json
import re
import sys
from decimal import Decimal
from functools import lru_cache
from pathlib import Path
ROOT = Path(r"C:\Program_coding\임도설계 및 견적자동화 프로그램 개발")
sys.path.insert(0, str(ROOT))
HERE = Path(__file__).resolve().parent
C1 = HERE.parent.parent / "req225/C1"
sys.path.insert(0, str(C1))
sys.stdout.reconfigure(encoding="utf-8")
from M01_MasterData import M01_MasterData_Store as m01 # noqa: E402
from M02_MasterTemplete import M02_Hopyo_Basis as hb # noqa: E402
from M02_MasterTemplete import M02_Hopyo_Match as hm # noqa: E402
TOL = 0.03
LOOSE = "loose" in sys.argv
CAP = 600
mf, mau = m01.cm.mf, m01.mau
WHOLE = m01.loaded()[1]
def _choices(spec):
opts = spec.get("고르기")
if opts:
if spec["이름"] == "지역" or all(re.match(r"^\d{4}-\d{4}$", str(o)) for o in opts):
return None
return list(opts)
rng = spec.get("범위")
if isinstance(rng, list) and len(rng) == 2:
return [Decimal(str(rng[0])), Decimal(str(rng[1]))]
return None
_CACHE_PATH = HERE / "vec_cache.json"
try:
_CACHE = {k: tuple(v) for k, v in json.loads(_CACHE_PATH.read_text(encoding="utf-8")).items()}
except (OSError, ValueError):
_CACHE = {}
def vectors(key):
if key not in _CACHE:
_CACHE[key] = _vectors(key)
return _CACHE[key]
def save_cache():
_CACHE_PATH.write_text(json.dumps({k: list(v) for k, v in _CACHE.items()}, ensure_ascii=False), "utf-8")
def _vectors(key):
"""[{직종: 품}] — 입력 조합마다(0 빼고) · 같은 것 한 번."""
try:
row = WHOLE.logic(key)
base = mau.values(WHOLE, row)
except Exception: # noqa: BLE001
return ()
names, pools = [], []
for spec in row.get("입력") or []:
c = _choices(spec)
if c:
names.append(spec["이름"])
pools.append(c)
size = 1
for p in pools:
size *= len(p)
if size > CAP: # 범위 흔들기 뺌
keep = [(n, p) for n, p in zip(names, pools) if not isinstance(p[0], Decimal)]
names, pools = [n for n, _ in keep], [p for _, p in keep]
out, seen = [], set()
for combo in itertools.islice(itertools.product(*pools), CAP):
given = mf.fill_empty(row, {**base, **dict(zip(names, combo))})
try:
res = mf.run(WHOLE, key, given)
except Exception: # noqa: BLE001
continue
vec = {}
for line in res.get("줄") or []:
if hm.mm.plain_unit(line.get("단위")) == "인":
q = float(line.get("수량") or 0)
if q > 0:
vec[str(line["이름"])] = round(vec.get(str(line["이름"]), 0) + q, 6)
sig = tuple(sorted(vec.items()))
if vec and sig not in seen:
seen.add(sig)
out.append(vec)
return tuple(out)
def same_labor(ext, ours):
if set(ext) != set(ours):
return False
return all(abs(ext[t] - ours[t]) <= TOL * max(ext[t], ours[t]) for t in ext)
def auto_label(item, table, logics):
src = item["근거"]["출처"]
labor = item.get("품") or {}
if not src:
return None, "출처 없음", 0
if not labor:
return None, "직종 · 품 없음", 0
cands = [k for k in logics if any(b == table[k]["책"] and n in table[k]["조항"] for b, n in src)]
if not cands:
return None, "조항 같은 로직 없음", 0
hits = [k for k in cands if any(same_labor(labor, v) for v in vectors(k))]
if len(hits) == 1:
return hits[0], "하나", len(cands)
if LOOSE and not hits: # 준엄격 — 품 값은 안 보고 직종 묶음만
hits = [k for k in cands if any(set(labor) == set(v) for v in vectors(k))]
if len(hits) == 1:
return hits[0], "하나(직종만)", len(cands)
if not hits:
return None, "조항은 같으나 직종 · 품 다름", len(cands)
return None, f"둘 이상({len(hits)})", len(cands)
def seeds_of(item, got):
s = got["씨앗"]
return s.get(hm.mm.plain(f"{item['이름']}{item['규격']}")) or s.get(hm.mm.plain(item["이름"])) or set()
if __name__ == "__main__":
from labels import L # noqa: E402
from labels_y import LY # noqa: E402
hand = {**L, **LY}
items = json.loads((HERE / "items.json").read_text(encoding="utf-8"))
got = hm.targets()
table = hb.ours()
logics = [x["로직"] for x in got["로직"] if x["로직"] in table]
out, why = {}, {}
for r in items:
if r["갈래"] != "단가산출":
out[r["id"]] = {"정답": None, "까닭": "단가산출 줄 아님"}
continue
k, reason, n = auto_label(r, table, logics)
out[r["id"]] = {"정답": k, "까닭": reason, "조항후보": n}
save_cache()
(HERE / ("auto_loose.json" if LOOSE else "auto.json")).write_text(json.dumps(out, ensure_ascii=False, indent=0), "utf-8")
files = list(dict.fromkeys(r["파일"] for r in items))
print("파일 | 고유 | 단가산출 | 자동정답 | 호표쓰임 중 자동 | 까닭별")
for f in files:
rs = [r for r in items if r["파일"] == f]
d = [r for r in rs if r["갈래"] == "단가산출"]
a = [r for r in d if out[r["id"]]["정답"]]
au = [r for r in a if r["호표쓰임"]]
reasons = {}
for r in d:
if not out[r["id"]]["정답"]:
key = out[r["id"]]["까닭"].split("(")[0]
reasons[key] = reasons.get(key, 0) + 1
print(f, len(rs), len(d), len(a), f"{len(au)}/{sum(1 for r in d if r['호표쓰임'])}", reasons)
# 사람 정답과 맞대 봄
agree = {"엄격 같음": 0, "느슨만 같음": 0, "다름": 0, "사람 없음(우리에 없음)": 0}
bad = []
for r in items:
a = out[r["id"]]["정답"]
if not a or r["id"] not in hand:
continue
lab = hand[r["id"]]
if not lab["정답"]:
agree["사람 없음(우리에 없음)"] += 1
bad.append((r["id"], r["이름"], a, "사람 없음"))
elif a in lab["정답"]:
agree["엄격 같음"] += 1
elif a in lab.get("허용", []):
agree["느슨만 같음"] += 1
bad.append((r["id"], r["이름"], a, lab["정답"]))
else:
agree["다름"] += 1
bad.append((r["id"], r["이름"], a, lab["정답"], out[r["id"]]["까닭"]))
print("사람 정답 대 자동:", agree)
for b in bad:
print(" ", b)
# 씨앗과 맞대 봄(씨앗 = 같은 실무 파일에서 사람이 골라 둔 짝)
sa = {"같음": 0, "다름": 0}
sbad = []
for r in items:
a = out[r["id"]]["정답"]
s = seeds_of(r, got)
if a and s:
if a in s:
sa["같음"] += 1
else:
sa["다름"] += 1
sbad.append((r["id"], r["이름"], r["규격"], a, sorted(s)))
print("씨앗 대 자동:", sa)
for b in sbad:
print(" ", b)
@@ -0,0 +1,26 @@
"""C6 — 손 확인 표본: 사람 정답이 없는 줄의 자동 정답(엄격 먼저 · 모자라면 준엄격) 20 줄 보기."""
import json, sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent.parent / "req225/C1"))
from s04_auto import hb, hm, vectors, WHOLE # noqa: E402
from labels import L # noqa: E402
from labels_y import LY # noqa: E402
hand = {**L, **LY}
items = {r["id"]: r for r in json.loads((HERE / "items.json").read_text(encoding="utf-8"))}
strict = json.loads((HERE / "auto.json").read_text(encoding="utf-8"))
loose = json.loads((HERE / "auto_loose.json").read_text(encoding="utf-8"))
got = hm.targets(); names = {x["로직"]: x for x in got["로직"]}; table = hb.ours()
pick = [i for i, a in strict.items() if a["정답"] and i not in hand]
extra = [i for i, a in loose.items() if a["정답"] and i not in hand and i not in pick]
sample = (pick + extra)[:20]
print("엄격(사람 정답 없는 줄)", len(pick), "준엄격 더함", len(extra), "표본", len(sample))
for i in sample:
r = items[i]; a = (strict.get(i) if i in pick else loose[i])["정답"]
o = table[a]; row = WHOLE.logic(a)
print(f"## {i} [{r['파일']}] {'엄격' if i in pick else '준엄격'} 호표쓰임 {r['호표쓰임']} | {r['이름']} | {r['규격']} | {r['단위']}")
print(f" 엑셀 출처 {r['근거']['출처']} 품 {r['품']} 장비 {r['장비']}")
for t in r["글"][:9]:
print(" ·", " ".join(str(t).split())[:110])
print(f" → {a} {names[a]['이름']} [{names[a]['단위']}] {o['구분']} {sorted(o['조항'])} 입력 {[s['이름'] for s in row.get('입력') or []]}")
print(f" 우리 품 {[v for v in vectors(a)][:4]}")
@@ -0,0 +1,69 @@
"""C6 — 지금 맞춤(hm.match_line · 기억 없음)을 넓힌 자료 모든 줄에 돌림 → match.json.
갈래(근거를 얼마나 읽었다고 치나 — STmate · 거창만 바꿈 · 북부청은 저장소 읽기 그대로):
이름만 = 근거 없음(거창 지금 읽기 · STmate 를 이름만 읽는다면)
출처만 = 조항만(C1 영월 흉내와 같음 — 직종 이음 빠짐)
출처직종 = 조항 + 노무 목록으로 이은 직종(STmate 읽기 보강 뒤 · 거창 흩어진 칸 붙임 뒤)
씨앗 켬 · 끔 둘 다. 저장소 안 고침 — 씨앗 끔 = 같은 프로세스에서 hm._seeds 를 빈 것으로 바꿔 끼움.
"""
import json
import sys
from pathlib import Path
ROOT = Path(r"C:\Program_coding\임도설계 및 견적자동화 프로그램 개발")
sys.path.insert(0, str(ROOT))
HERE = Path(__file__).resolve().parent
sys.stdout.reconfigure(encoding="utf-8")
from M02_MasterTemplete import M02_Hopyo_Basis as hb # noqa: E402
from M02_MasterTemplete import M02_Hopyo_Match as hm # noqa: E402
ITEMS = json.loads((HERE / "items.json").read_text(encoding="utf-8"))
OLD = ("북부청임도", "북부청사방")
VARIANTS = ("이름만", "출처만", "출처직종")
def line_of(r, variant):
basis = dict(r["근거"])
if r["파일"] not in OLD:
if variant == "이름만":
basis = {}
elif variant == "출처만":
basis = {"출처": basis.get("출처") or [], "직종": []}
return {"이름": r["이름"], "규격": r["규격"], "단위": r["단위"], "단가": {}, "갈래": r["갈래"], "근거": basis or None}
def top_of(m):
if m["상태"] in ("같음", "기억"):
return [m["대상"].get("로직")] if m.get("대상") and m["대상"].get("로직") else []
return [c.get("로직") for c in m.get("후보") or [] if c.get("로직")]
def run(seed, variant, items=ITEMS):
real = hm._seeds
if not seed:
hm._seeds = lambda: {}
hm._targets.cache_clear()
hb._ours.cache_clear()
try:
got = hm.targets()
out = {}
for r in items:
m = hm.match_line(line_of(r, variant), [], {}, True, got)
out[r["id"]] = {"상태": m["상태"], "top": top_of(m)}
return out
finally:
hm._seeds = real
hm._targets.cache_clear()
if __name__ == "__main__":
want = sys.argv[1:] or [f"{v}|{s}" for v in VARIANTS for s in ("켬", "끔")]
path = HERE / "match.json"
allm = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
for w in want:
v, s = w.split("|")
allm[w] = run(s == "켬", v)
print(w, "done", len(allm[w]))
path.write_text(json.dumps(allm, ensure_ascii=False), "utf-8")
@@ -0,0 +1,96 @@
"""C6 — 지금 실력 표(정답 셋 · 갈래 · 씨앗 켬/끔).
정답 셋: 사람(C1 L · LY · 엄격/느슨) · 자동 엄격(새 파일 · 사람 정답 없는 줄) · 자동 준엄격 · 씨앗 짝(씨앗 끔에서만 · 이름이 씨앗 메모와 같은 줄).
python s07_score.py → 표 찍고 score.json
"""
import json
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
C1 = HERE.parent.parent / "req225/C1"
sys.path.insert(0, str(C1))
sys.path.insert(0, str(HERE))
sys.stdout.reconfigure(encoding="utf-8")
from labels import L # noqa: E402
from labels_y import LY # noqa: E402
from s04_auto import hm, seeds_of # noqa: E402
ITEMS = json.loads((HERE / "items.json").read_text(encoding="utf-8"))
BY = {r["id"]: r for r in ITEMS}
M = json.loads((HERE / "match.json").read_text(encoding="utf-8"))
AUTO = json.loads((HERE / "auto.json").read_text(encoding="utf-8"))
LOOSE = json.loads((HERE / "auto_loose.json").read_text(encoding="utf-8"))
HAND = {k: v for k, v in {**L, **LY}.items() if k in BY}
FILES = list(dict.fromkeys(r["파일"] for r in ITEMS))
def answers(source):
"""{id: (엄격 set, 느슨 set)} — 정답 있는 줄만."""
out = {}
if source == "사람":
for i, lab in HAND.items():
if lab["정답"]:
out[i] = (set(lab["정답"]), set(lab["정답"]) | set(lab.get("허용") or []))
elif source in ("자동", "준엄격"):
src = AUTO if source == "자동" else LOOSE
for i, a in src.items():
if a["정답"] and i not in HAND:
out[i] = ({a["정답"]}, {a["정답"]})
elif source == "씨앗":
got = hm.targets()
for r in ITEMS:
if r["파일"] in ("북부청임도", "북부청사방") or r["id"] in HAND:
continue
s = seeds_of(r, got)
if s:
out[r["id"]] = (set(s), set(s))
return out
def rate(ids, res, ans, k, loose=False, used_only=False):
ids = [i for i in ids if i in ans and (BY[i]["호표쓰임"] or not used_only)]
if not ids:
return None, 0
hit = 0
for i in ids:
top = res[i]["top"][:k]
hit += bool(set(top) & ans[i][1 if loose else 0])
return round(100 * hit / len(ids), 1), len(ids)
def table(source, key, used_only=False):
ans = answers(source)
res = M[key]
rows = {}
for f in FILES + ["모두"]:
ids = [r["id"] for r in ITEMS if (r["파일"] == f or f == "모두")]
a1, n = rate(ids, res, ans, 1, used_only=used_only)
if not n:
continue
rows[f] = {
"n": n,
"1위 엄격": a1,
"1위 느슨": rate(ids, res, ans, 1, True, used_only)[0],
"5안 엄격": rate(ids, res, ans, 5, used_only=used_only)[0],
"5안 느슨": rate(ids, res, ans, 5, True, used_only)[0],
}
return rows
if __name__ == "__main__":
out = {}
for source in ("사람", "자동", "준엄격", "씨앗"):
for key in M:
if source == "씨앗" and key.endswith("켬"):
continue
for used in (False, True):
t = table(source, key, used)
out[f"{source}|{key}|{'호표만' if used else '모두'}"] = t
(HERE / "score.json").write_text(json.dumps(out, ensure_ascii=False, indent=0), "utf-8")
for k, t in out.items():
if k.endswith("모두") and not k.startswith("씨앗") or k.startswith("씨앗") and k.endswith("모두"):
cells = " · ".join(f"{f} {v['n']}:{v['1위 엄격']}/{v['5안 엄격']}" for f, v in t.items())
print(k, "|", cells)
@@ -0,0 +1,290 @@
"""C6 — 기억 흉내: 앞 파일에서 사람이 확정했다고 치고(확정 = 정답) 다음 파일 1순위가 얼마나 오르나 · 틀린 기억 해로움.
열쇠: a 품명|규격|단위(지금 line_key) · b 품명|단위 · c 로직 속 지문(단위 · 직종:품 · 장비 · 조항 — 금액 안 씀) ·
c2 지문(단위 · 직종:품만 — 조항 · 장비 뺌) · d 호표 조합 지문(호표 단위 + 그 호표 로직 줄 지문 묶음 → 그 안 자리).
확정(기억 값) = 사람 정답(C1) ∪ 자동 정답(엄격) ∪ 씨앗 짝(씨앗을 만든 파일 줄 = 사람이 고른 것) · 판정 = 사람 ∪ 자동(엄격)만.
바탕(기억 없음) = 지금 맞춤 · 출처직종 · 씨앗 끔(씨앗도 「앞 파일 확정」 한 꼴로 기억 쪽에 넣음).
python s08_memory.py [호표만|모두] [준엄격] → 표 · memory.json
"""
import json
import sys
from collections import Counter
from pathlib import Path
HERE = Path(__file__).resolve().parent
C1 = HERE.parent.parent / "req225/C1"
sys.path.insert(0, str(C1))
sys.path.insert(0, str(HERE))
sys.stdout.reconfigure(encoding="utf-8")
from labels import L # noqa: E402
from labels_y import LY # noqa: E402
from s04_auto import hb, hm, seeds_of # noqa: E402
USED_ONLY = "모두" not in sys.argv
LOOSE = "준엄격" in sys.argv
ITEMS = json.loads((HERE / "items.json").read_text(encoding="utf-8"))
if USED_ONLY:
ITEMS = [r for r in ITEMS if r["호표쓰임"]]
BY = {r["id"]: r for r in ITEMS}
M = json.loads((HERE / "match.json").read_text(encoding="utf-8"))
BASE = M["출처직종|끔"]
AUTO = json.loads((HERE / ("auto_loose.json" if LOOSE else "auto.json")).read_text(encoding="utf-8"))
HAND = {k: v for k, v in {**L, **LY}.items() if k in BY}
META = { # 파일: (발주처, 해, 꼴)
"울진대흥": ("남부청", 2024, "STmate"),
"봉화": ("남부청", 2024, "STmate"),
"동부청영월": ("동부청", 2024, "STmate"),
"울진소광": ("남부청", 2025, "STmate"),
"영덕계류": ("남부청", 2025, "STmate"),
"거창": ("남부청", 2025, "엑셀꼴"),
"북부청임도": ("북부청", 2026, "엑셀꼴"),
"북부청사방": ("북부청", 2026, "엑셀꼴"),
}
ORDER = list(META)
GOT = hm.targets()
OURS = hb.ours()
# ── 정답 · 확정 ────────────────────────────────────────────────────────
def truth(i):
"""판정 정답(엄격 set) — 사람 · 자동만 · 없으면 None."""
if i in HAND:
return set(HAND[i]["정답"]) or None
a = AUTO.get(i, {}).get("정답")
return {a} if a else None
def confirmed(i):
"""사람이 확정한 값(기억으로 들어갈 것) — 사람 · 자동 · 씨앗 짝 · 없으면 None. 여럿이면 차례 첫째."""
t = truth(i)
if t:
return sorted(t)[0]
r = BY[i]
if r["파일"] in ("북부청임도", "북부청사방"):
return None
s = seeds_of(r, GOT)
return sorted(s)[0] if s else None
# ── 열쇠 ───────────────────────────────────────────────────────────────
def k_a(r):
return hm.line_key(r)
def k_b(r):
return f"{hm.mm.plain(r['이름'])}|{hm.mm.plain_unit(r['단위'])}"
def _labor(r):
return ",".join(f"{t}:{q:.3g}" for t, q in sorted((r.get("품") or {}).items()))
def k_c(r):
src = ",".join(sorted(f"{b[:2]}{n}" for b, n in r["근거"]["출처"]))
mach = ",".join(r.get("장비") or [])
if not r.get("품") and not (mach and src):
return None
return f"{hm.mm.plain_unit(r['단위'])}|{_labor(r)}|{mach}|{src}"
def k_c2(r):
if not r.get("품"):
return None
return f"{hm.mm.plain_unit(r['단위'])}|{_labor(r)}"
HOPYO = {} # (파일, 호표 번) → [id]
for r in ITEMS:
for h in r["호표들"]:
HOPYO.setdefault((r["파일"], h), []).append(r["id"])
def elem(r):
return k_c(r) or f"N:{k_a(r)}"
def k_d_all(r):
"""줄이 든 호표마다 (호표 조합 열쇠, 자리 열쇠)."""
out = []
for h in r["호표들"]:
ids = HOPYO[(r["파일"], h)]
combo = "+".join(sorted(elem(BY[i]) for i in ids))
out.append(f"{len(ids)}#{combo}§{elem(r)}")
return out
KEYS = {"a": k_a, "b": k_b, "c": k_c, "c2": k_c2, "d": k_d_all}
def keys_of(name, r):
got = KEYS[name](r)
if got is None:
return []
return got if isinstance(got, list) else [got]
# ── 기억 만들기 · 쓰기 ─────────────────────────────────────────────────
def build(files, name, all_items=False):
"""{열쇠: [(파일, 답)]} — files 차례대로 · all_items = 확정 없는 줄도(답 None · 「닿음」 셈용)."""
memo = {}
for f in files:
for r in ITEMS:
if r["파일"] != f:
continue
ans = confirmed(r["id"])
if ans is None and not all_items:
continue
for k in keys_of(name, r):
memo.setdefault(k, []).append((f, ans))
return memo
def recall(name, r, memo, policy="최근"):
"""(답, 출처 목록) — 최근 = 마지막에 확정한 답 · 다수 = 표 많은 답(같으면 최근)."""
for k in keys_of(name, r):
if k in memo:
got = [(f, a) for f, a in memo[k] if a]
if not got:
return None, []
if policy == "다수":
votes = Counter(a for _, a in got)
top = max(votes.values())
best = [a for _, a in reversed(got) if votes[a] == top]
return best[0], got
return got[-1][1], got
return None, []
def basis_of(r, ans):
"""기억 답의 우리 조항이 엑셀 인용 조항과 같은가 — 맞음 · 다름 · 인용 없음."""
src = r["근거"]["출처"]
if not src:
return "인용 없음"
our = OURS.get(ans) or {}
return "근거 맞음" if any(b == our.get("책") and n in our.get("조항", ()) for b, n in src) else "근거 다름"
def evaluate(target, sources, name, policy="최근", cond=None):
memo = build(sources, name)
reach_memo = build(sources, name, all_items=True)
rows = [r for r in ITEMS if r["파일"] == target]
reach = sum(1 for r in rows if any(k in reach_memo for k in keys_of(name, r)))
s = Counter()
for r in rows:
t = truth(r["id"])
if not t:
continue
base = BASE[r["id"]]["top"]
ans, prov = recall(name, r, memo, policy)
before = bool(base[:1]) and base[0] in t
after = before if ans is None else ans in t
five_before = bool(set(base[:5]) & t)
five_after = five_before if ans is None else bool(set([ans, *[x for x in base if x != ans]][:5]) & t)
who = "사람" if r["id"] in HAND else "자동"
s["판정줄"] += 1
s[f"판정줄_{who}"] += 1
s["전 1위"] += before
s["뒤 1위"] += after
s["전 5안"] += five_before
s["뒤 5안"] += five_after
if ans is None:
continue
ok = ans in t
s["기억 닿음"] += 1
s["기억 맞음"] += ok
s[f"닿음_{who}"] += 1
s[f"맞음_{who}"] += ok
conflict = len({a for _, a in prov}) > 1
s["충돌 열쇠"] += conflict
s["해로움"] += before and not after
s["도움"] += after and not before
if cond is not None:
same_org = any(META[f][0] == META[target][0] for f, _ in prov)
same_year = any(META[f][1] == META[target][1] for f, _ in prov)
files = len({f for f, a in prov if a == ans})
for c in (
f"열쇠 {name}",
f"{name} · {'같은 발주처' if same_org else '다른 발주처'}",
f"{name} · {'같은 해' if same_year else '다른 해'}",
f"{name} · {basis_of(r, ans)}",
f"{name} · {'두 파일 넘게 같은 답' if files >= 2 else '한 파일'}",
f"{name} · {'충돌 있음' if conflict else '충돌 없음'}",
f"{name} · 판정 {who}",
f"{name} · {'같은 발주처' if same_org else '다른 발주처'} · {basis_of(r, ans)}",
):
cond.setdefault(c, Counter())
cond[c]["닿음"] += 1
cond[c]["맞음"] += ok
cond[c]["해로움"] += before and not after
return {"줄": len(rows), "닿음(모두 확정 셈)": reach, **s}
def pct(a, b):
return f"{100 * a / b:.0f}%" if b else "—"
def show(title, res):
print(f"\n### {title}")
print("열쇠 | 줄 · 닿음(모두 확정) | 판정줄(사람) · 기억 닿음 · 맞음 (사람 닿음·맞음) | 1위 전→뒤 | 5안 전→뒤 | 도움 · 해로움 · 충돌")
for name, s in res.items():
n = s.get("판정줄", 0)
print(
f"{name} | {s['줄']} · {s['닿음(모두 확정 셈)']} ({pct(s['닿음(모두 확정 셈)'], s['줄'])}) "
f"| {n}({s.get('판정줄_사람', 0)}) · {s.get('기억 닿음', 0)} · {s.get('기억 맞음', 0)} ({s.get('닿음_사람', 0)}·{s.get('맞음_사람', 0)}) "
f"| {pct(s.get('전 1위', 0), n)}→{pct(s.get('뒤 1위', 0), n)} | {pct(s.get('전 5안', 0), n)}→{pct(s.get('뒤 5안', 0), n)} "
f"| {s.get('도움', 0)} · {s.get('해로움', 0)} · {s.get('충돌 열쇠', 0)}"
)
def add(acc, s):
for k, v in s.items():
acc[k] = acc.get(k, 0) + v
SOUTH24, SOUTH25 = ["울진대흥", "봉화"], ["울진소광", "영덕계류", "거창"]
SCEN = {
"북부청임도 → 북부청사방": (["북부청임도"], ["북부청사방"]),
"북부청사방 → 북부청임도": (["북부청사방"], ["북부청임도"]),
"울진대흥 → 봉화(남부 2024)": (["울진대흥"], ["봉화"]),
"봉화 → 울진대흥(남부 2024)": (["봉화"], ["울진대흥"]),
"남부 2024 → 남부 2025(해 바뀜)": (SOUTH24, SOUTH25),
"북부청 → 동부청영월(다른 발주처)": (["북부청임도", "북부청사방"], ["동부청영월"]),
"동부청영월 → 북부청(다른 발주처)": (["동부청영월"], ["북부청임도", "북부청사방"]),
"동부청영월 → 남부 2024(다른 발주처 · 같은 해)": (["동부청영월"], SOUTH24),
"남부 2024·2025 → 북부청(다른 발주처 · 해 바뀜)": (SOUTH24 + SOUTH25, ["북부청임도", "북부청사방"]),
}
if __name__ == "__main__":
out = {"설정": {"호표만": USED_ONLY, "준엄격": LOOSE}}
for title, (srcs, dsts) in SCEN.items():
res = {}
for name in KEYS:
acc = {}
for d in dsts:
add(acc, evaluate(d, srcs, name))
res[name] = acc
show(title, res)
out[title] = res
cond = {}
for policy in ("최근", "다수"):
seq = {name: {} for name in KEYS}
for at, target in enumerate(ORDER[1:], 1):
for name in KEYS:
add(seq[name], evaluate(target, ORDER[:at], name, policy, cond if policy == "최근" else None))
show(f"차례 누적(앞 파일 모두 기억 · {' → '.join(ORDER)}) · 충돌 때 {policy}", seq)
out[f"차례누적_{policy}"] = seq
# 짝 모두(한 파일 → 다른 한 파일) — 조건별 맞음 셈을 늘림
for src in ORDER:
for dst in ORDER:
if src != dst:
for name in KEYS:
evaluate(dst, [src], name, "최근", cond)
print("\n### 조건별 기억 맞음(차례 누적 + 모든 짝 · 판정줄만)")
for c, v in sorted(cond.items()):
print(f"{c} | 닿음 {v['닿음']} · 맞음 {v['맞음']} ({pct(v['맞음'], v['닿음'])}) · 해로움 {v['해로움']}")
out["조건"] = {c: dict(v) for c, v in cond.items()}
(HERE / f"memory{'_모두' if not USED_ONLY else ''}{'_준엄격' if LOOSE else ''}.json").write_text(
json.dumps(out, ensure_ascii=False, indent=0), "utf-8"
)
@@ -0,0 +1,26 @@
"""C6 — 기억 예: 해로움(틀린 기억이 맞던 1순위를 뒤집음) · 도움(c 지문이 다른 발주처에서 맞힘)."""
import sys
from s08_memory import BY, ITEMS, ORDER, META, BASE, build, recall, truth, keys_of, basis_of, GOT
names = {x["로직"]: x["이름"] for x in GOT["로직"]}
seen = set()
for name in ("b", "c2", "c", "a"):
print(f"\n## 열쇠 {name}")
for src in ORDER:
for dst in ORDER:
if src == dst or META[src][0] == META[dst][0]:
continue
memo = build([src], name)
for r in ITEMS:
if r["파일"] != dst or not truth(r["id"]):
continue
ans, prov = recall(name, r, memo)
if ans is None:
continue
t = truth(r["id"]); base = BASE[r["id"]]["top"]
before = bool(base[:1]) and base[0] in t
after = ans in t
tag = "해로움" if before and not after else ("도움" if after and not before else None)
if tag and (name, r["id"], tag) not in seen:
seen.add((name, r["id"], tag))
src_item = next(x for x in ITEMS if x["파일"] == src and any(k in keys_of(name, x) for k in keys_of(name, r)))
print(f"{tag} {src}→{dst} | {r['이름']} {r['규격']} [{r['단위']}] 엑셀출처 {r['근거']['출처']} | 기억 {ans} {names.get(ans)} ({basis_of(r, ans)}) | 정답 {sorted(t)} {[names.get(x) for x in t]} | 앞 파일 줄 {src_item['이름']} {src_item['규격']} 출처 {src_item['근거']['출처']}")
@@ -0,0 +1,87 @@
"""C6 — 「경우에 따라서는」 가름 흉내: 열쇠 차례(a → c → b → c2) + 근거 판정 · 발주처로 「바로 씀 / 1순위 후보 / 뒤 후보」.
차례 누적(앞 파일 모두 기억)에서 판정줄 1순위 · 다섯 안 · 바로 들어간 줄 맞음 · 해로움.
python s10_policy.py
"""
import sys
from collections import Counter
from s08_memory import BASE, BY, ITEMS, META, ORDER, basis_of, build, keys_of, recall, truth # noqa: F401
sys.stdout.reconfigure(encoding="utf-8")
CHAIN = ("a", "c", "b", "c2")
def first_hit(r, memos, chain=CHAIN):
for name in chain:
ans, prov = recall(name, r, memos[name])
if ans is not None:
return name, ans, prov
return None, None, []
def tier(rule, name, ans, prov, r, target):
"""바로 · 1순위 · 뒤 · 없음."""
if ans is None:
return "없음"
basis = basis_of(r, ans)
same_org = any(META[f][0] == META[target][0] for f, _ in prov)
if rule == "지금(a 바로)":
return "바로" if name == "a" else "없음"
if rule == "a·b 바로(넓히기만)":
return "바로" if name in ("a", "b") else "없음"
if rule == "모든 열쇠 바로":
return "바로"
if rule == "근거 가름":
if basis == "근거 다름":
return "뒤"
return "바로" if name in ("a", "c") else "1순위"
if rule == "근거 가름 + 발주처":
if same_org and name in ("a", "b", "c"):
return "바로"
if basis == "근거 다름":
return "뒤"
return "바로" if name in ("a", "c") else "1순위"
raise ValueError(rule)
RULES = ("지금(a 바로)", "a·b 바로(넓히기만)", "모든 열쇠 바로", "근거 가름", "근거 가름 + 발주처")
if __name__ == "__main__":
print("규칙 | 판정줄 | 1위 전→뒤 | 5안 전→뒤 | 바로 들어감(맞음) | 1순위 후보(맞음) | 뒤 후보(맞음) | 도움 · 해로움")
for rule in RULES:
s = Counter()
for at, target in enumerate(ORDER[1:], 1):
memos = {name: build(ORDER[:at], name) for name in CHAIN}
for r in ITEMS:
if r["파일"] != target or not truth(r["id"]):
continue
t = truth(r["id"])
base = BASE[r["id"]]["top"]
chain = {"지금(a 바로)": ("a",), "a·b 바로(넓히기만)": ("a", "b")}.get(rule, CHAIN)
name, ans, prov = first_hit(r, memos, chain)
tr = tier(rule, name, ans, prov, r, target)
if tr in ("바로", "1순위"):
top = [ans, *[x for x in base if x != ans]]
elif tr == "뒤":
top = [*base[:1], ans, *[x for x in base[1:] if x != ans]]
else:
top = base
before = bool(base[:1]) and base[0] in t
after = bool(top[:1]) and top[0] in t
s["n"] += 1
s["전1"] += before
s["뒤1"] += after
s["전5"] += bool(set(base[:5]) & t)
s["뒤5"] += bool(set(top[:5]) & t)
if tr != "없음":
s[tr] += 1
s[tr + "맞음"] += ans in t
s["도움"] += after and not before
s["해로움"] += before and not after
n = s["n"]
print(
f"{rule} | {n} | {100 * s['전1'] / n:.1f}→{100 * s['뒤1'] / n:.1f} | {100 * s['전5'] / n:.1f}→{100 * s['뒤5'] / n:.1f} "
f"| {s['바로']}({s['바로맞음']}) | {s['1순위']}({s['1순위맞음']}) | {s['뒤']}({s['뒤맞음']}) | {s['도움']} · {s['해로움']}"
)
@@ -0,0 +1,28 @@
"""C6 — 덧셈: ① 한 파일 안 같은 열쇠 다른 답(충돌) ② 차례 누적에서 열쇠 사슬(a→c→b→c2)이 닿는 엑셀 줄 무게."""
import sys
from collections import defaultdict
from s08_memory import ITEMS, ORDER, KEYS, keys_of, truth, build
sys.stdout.reconfigure(encoding="utf-8")
print("① 한 파일 안 — 정답 있는 줄 둘 넘게 같은 열쇠: 열쇠 묶음 수 · 답이 갈린 묶음")
for name in KEYS:
groups = defaultdict(list)
for r in ITEMS:
t = truth(r["id"])
if t:
for k in keys_of(name, r):
groups[(r["파일"], k)].append(frozenset(t))
multi = [v for v in groups.values() if len(v) > 1]
split = [v for v in multi if not frozenset.intersection(*v)]
print(f" {name}: 묶음 {len(multi)} · 갈림 {len(split)}")
print("② 차례 누적 — 줄(고유) · 엑셀 줄(되풀이 셈) 중 앞 파일 기억이 닿는 몫(모두 확정 셈)")
for chain in (("a",), ("a", "b"), ("a", "c"), ("a", "c", "b", "c2")):
u = w = nu = nw = 0
for at, target in enumerate(ORDER[1:], 1):
memos = {n: build(ORDER[:at], n, all_items=True) for n in chain}
for r in ITEMS:
if r["파일"] != target:
continue
nu += 1; nw += r["줄수"]
if any(k in memos[n] for n in chain for k in keys_of(n, r)):
u += 1; w += r["줄수"]
print(f" {'→'.join(chain)}: 고유 {u}/{nu} ({100*u/nu:.0f}%) · 엑셀 줄 {w}/{nw} ({100*w/nw:.0f}%)")
@@ -0,0 +1,55 @@
"""C6 — 새 지표 「로직 조합 맞음」: 호표 하나의 로직 줄(정답 있는 줄)이 모두 1순위 맞으면 그 호표 조합 맞음.
판정 = 사람 ∪ 자동(엄격) · 정답 없는 줄은 셈에서 뺌 · 판정 줄 하나도 없는 호표는 뺌.
바탕 = 지금 맞춤(출처직종 · 씨앗 켬/끔) · 기억 = 차례 누적 + 「근거 가름(+ 발주처)」 규칙(s10).
"""
import json
import sys
from collections import defaultdict
from pathlib import Path
from s08_memory import BY, HOPYO, ITEMS, ORDER, build, truth
from s10_policy import CHAIN, first_hit, tier
sys.stdout.reconfigure(encoding="utf-8")
HERE = Path(__file__).resolve().parent
M = json.loads((HERE / "match.json").read_text(encoding="utf-8"))
def tops(base_key, rule=None):
"""{id: 1순위} — rule 있으면 차례 누적 기억을 얹음."""
base = M[base_key]
out = {i: (v["top"][:1] or [None])[0] for i, v in base.items()}
if rule is None:
return out
for at, target in enumerate(ORDER[1:], 1):
memos = {name: build(ORDER[:at], name) for name in CHAIN}
for r in ITEMS:
if r["파일"] != target:
continue
name, ans, prov = first_hit(r, memos)
if tier(rule, name, ans, prov, r, target) in ("바로", "1순위"):
out[r["id"]] = ans
return out
def combo_rate(top):
by_file = defaultdict(lambda: [0, 0])
for (f, h), ids in HOPYO.items():
judged = [i for i in ids if i in BY and truth(i)]
if not judged:
continue
by_file[f][0] += 1
by_file[f][1] += all(top.get(i) in truth(i) for i in judged)
total = [sum(v[0] for v in by_file.values()), sum(v[1] for v in by_file.values())]
return by_file, total
if __name__ == "__main__":
print("바탕 · 기억 | 호표(판정 줄 있음) | 조합 맞음 | 파일별(맞음/호표)")
for key in ("출처만|켬", "출처직종|켬", "출처직종|끔"):
for rule in (None, "근거 가름", "근거 가름 + 발주처"):
by, tot = combo_rate(tops(key, rule))
cells = " · ".join(f"{f} {v[1]}/{v[0]}" for f, v in by.items())
print(f"{key} · {rule or '기억 없음'} | {tot[0]} | {tot[1]} ({100 * tot[1] / tot[0]:.1f}%) | {cells}")
@@ -0,0 +1,23 @@
"""C6 — 조건별 맞음을 「서로 다른 판정 줄」로 다시 셈(짝 · 누적에서 같은 줄을 여러 번 세지 않게)."""
import sys
from collections import defaultdict
from s08_memory import ITEMS, ORDER, META, KEYS, build, recall, truth, basis_of, HAND
sys.stdout.reconfigure(encoding="utf-8")
seen = defaultdict(dict) # (열쇠, 조건) → {(id, 답): 맞음}
for src_set in [[s] for s in ORDER] + [ORDER[:i] for i in range(1, len(ORDER))]:
for name in KEYS:
memo = build(src_set, name)
for r in ITEMS:
if r["파일"] in src_set or not truth(r["id"]):
continue
ans, prov = recall(name, r, memo)
if ans is None:
continue
org = "같은 발주처" if any(META[f][0] == META[r["파일"]][0] for f, _ in prov) else "다른 발주처"
ok = ans in truth(r["id"])
who = "사람" if r["id"] in HAND else "자동"
for cond in ("모두", org, basis_of(r, ans), f"{org} · {basis_of(r, ans)}", f"판정 {who}"):
seen[(name, cond)][(r["id"], ans)] = ok
for (name, cond), d in sorted(seen.items()):
n = len(d); ok = sum(d.values()); ids = len({i for i, _ in d})
print(f"{name} | {cond} | 서로 다른 (줄·답) {n} · 맞음 {ok} ({100*ok/n:.0f}%) · 줄 {ids}")
@@ -0,0 +1,205 @@
"""req226 C7 — C1 · C3 · C4 · C5 · C6 함수를 한 프로세스에 불러 겹쳐 잼(저장소 안 고침 · 읽기만).
단계 = 지금 → 묶음 ① → + 로직 지문(C4 추천) → + (가) · 조합 풀이(C5) → + 조건부 기억(C6 근거 가름) → + 읽기 넓힘(영월 직종 이음).
정답 · 셈 = C1 s10_all(고유 136 · 정답 126) · 조합 맞음 = C5 c07 T1(덮을 수 있는 83 호표 통째 엄격).
"""
import json
import sys
from contextlib import contextmanager
from pathlib import Path
ROOT = Path(r"C:\Program_coding\임도설계 및 견적자동화 프로그램 개발")
HERE = Path(__file__).resolve().parent
PAD = HERE.parent
C1, C3 = PAD.parent / "req225" / "C1", PAD.parent / "req225" / "C3"
C4, C5, C6 = PAD / "C4", PAD / "C5", PAD / "C6"
for p in (ROOT, C1, C3, C4, C5, C6):
if str(p) not in sys.path:
sys.path.insert(0, str(p))
sys.stdout.reconfigure(encoding="utf-8")
sys.setrecursionlimit(10000)
import s10_all as S # noqa: E402 C1 — 정답 · 셈 · 지금
from s04_eval import clause_prefix, hb, hm, patched # noqa: E402
from s10_all import candidates_nofold, no_seeds # noqa: E402
from s20_c3_extra import combo # noqa: E402 C3 — 묶음 ①
import s3_match as F4 # noqa: E402 C4 — 로직 지문
import c04_closed as CL # noqa: E402 C5 — (가)
import c05_solve as SV # noqa: E402 C5 — 조합 풀이
from c06_lines import PARTS # noqa: E402
import c07_hopyo as H # noqa: E402 C5 — 조합 맞음
import s08_memory as M8 # noqa: E402 C6 — 기억
from s10_policy import CHAIN, first_hit, tier # noqa: E402
LABELED = ("동부청영월", "북부청임도", "북부청사방")
C6BY = {r["id"]: r for r in M8.ITEMS}
IDS = list(S.BY)
# ── 단계 ──────────────────────────────────────────────────────────────
def now():
return S.run()
def bundle1():
return combo()
_POOLS = {}
def pools(ids=IDS):
for i in ids:
if i not in _POOLS:
_POOLS[i] = F4.rank(F4.EXP[i], None, 40)
return _POOLS
def finger(rec, beta=1.0, keep_same=True):
"""C4 추천 — 「같음」은 묶음 ① 그대로 · 나머지 = 지문 점수 + β × 묶음 ① 차례 덤."""
P = pools()
out = {}
for i in IDS:
if keep_same and rec[i]["상태"] == "같음":
out[i] = dict(rec[i])
continue
F4.CUR["id"] = i
out[i] = {"상태": "후보", "top": F4.fuse(None, rec[i], beta, P[i])}
return out
def ga(res):
"""C5 (가) 좋은 것 — 토질 · 같은 이름 다수 · 조합 안(근거 지킴)."""
return CL.apply_combo(CL.apply_name_vote(CL.apply_soil(res)), "inside", True)[0]
def solve(res, cut=0.35, seeds=True):
"""C5 조합 풀이 + 섞인 줄 1순위 바꿈 → (줄 결과, 고른 로직 모음)."""
tops, sets = {}, {}
ctx = no_seeds() if not seeds else _null()
with ctx, patched(hb, _clause=clause_prefix), patched(hm, candidates=candidates_nofold):
hm._targets.cache_clear()
hb._ours.cache_clear()
got = hm.targets()
for r in S.ALL:
i = r["id"]
line = {k: r.get(k) for k in ("이름", "규격", "수량", "단위", "단가", "갈래", "참조", "근거")}
top = list(res[i]["top"])
parts = PARTS.get(i) or []
chosen = SV.combine((top or [None])[0], parts, line, got, SV.merged(line, got))
codes = list(dict.fromkeys(c for c, *_ in chosen if c))
if top and len(codes) > 1 and parts and SV.is_mixed(parts, S.BY[i]) and SV.cover(SV._sum(parts), top[0]) < cut:
rest = [c for c in codes if c != top[0]]
labor = [c for c, _w, _f, _x, step in chosen if c in rest and step in ("몫", "절")]
if labor:
codes = rest
top = [labor[0]] + [c for c in top if c != labor[0]][:4]
tops[i] = {"상태": res[i]["상태"], "top": top}
sets[i] = codes
return tops, sets
@contextmanager
def _null():
yield
RULE_CHAIN = {"지금(a 바로)": ("a",), "a·b 바로(넓히기만)": ("a", "b")}
def memory(res, sets=None, rule="근거 가름", order=M8.ORDER):
"""C6 조건부 기억 — 차례 누적(앞 파일 모두 확정) · 열쇠 a → c → b → c2 · 근거 판정으로 바로 / 1순위 / 뒤."""
out = {i: dict(v) for i, v in res.items()}
sets = {i: list(v) for i, v in (sets or {i: (v["top"] or [])[:1] for i, v in res.items()}).items()}
log = {}
for at, target in enumerate(order[1:], 1):
if target not in LABELED:
continue
memos = {name: M8.build(order[:at], name) for name in CHAIN}
for r in M8.ITEMS:
i = r["id"]
if r["파일"] != target or i not in res:
continue
name, ans, prov = first_hit(r, memos, RULE_CHAIN.get(rule, CHAIN))
tr = tier(rule, name, ans, prov, r, target)
top = list(res[i]["top"])
if tr in ("바로", "1순위"):
old = top[0] if top else None
out[i] = {"상태": "기억", "top": [ans] + [c for c in top if c != ans][:4]}
sets[i] = [ans] + [c for c in sets.get(i, []) if c not in (old, ans)]
elif tr == "뒤":
out[i] = {**res[i], "top": top[:1] + [ans] + [c for c in top[1:] if c != ans][:3]}
if tr != "없음":
log[i] = (name, tr, ans)
return out, sets, log
@contextmanager
def read_wide():
"""읽기 넓힘 — 영월(STmate) 근거 직종을 노무비 목록으로 이음(C6 items 근거) · 끝나면 되돌림."""
keep = {}
for r in S.ALL:
if r["파일"] == "동부청영월":
keep[r["id"]] = r.get("근거")
r["근거"] = dict(C6BY[r["id"]]["근거"])
try:
yield
finally:
for r in S.ALL:
if r["id"] in keep:
r["근거"] = keep[r["id"]]
# ── 셈 ────────────────────────────────────────────────────────────────
def hopyo(chosen_fn):
"""조합 맞음 — C5 T1 · 덮을 수 있는 호표 통째 엄격 (맞음, 호표 수, 칸 %)."""
H.chosen_of = lambda i, pick: set(chosen_fn(i)) - {None}
res = {k: H.judge(k, "x", False) for k in H.HOPYO}
res = {k: v for k, v in res.items() if v}
keys = [k for k, v in res.items() if not v["없음칸"]]
ok = sum(res[k]["엄격"]["통째"] for k in keys)
cell = round(100 * sum(res[k]["엄격"]["칸"] for k in keys) / len(keys), 1)
return ok, len(keys), cell
def top1_set(res):
return lambda i: (res[i]["top"] or [None])[:1]
def combo_set(res, sets):
"""1순위 + 조합 풀이 덧(기계 시간 로직은 뺌 — 정할 것 8 기본안 T1)."""
def f(i):
top = (res[i]["top"] or [None])[0]
return [c for c in sets.get(i, []) if c and (c == top or c not in SV.MACHINE_SET)] or [top]
return f
def row(name, res, chosen_fn=None):
t = S.table(name, res)
a = t["전체"]
h = hopyo(chosen_fn or top1_set(res))
return {
"단계": name,
"1위": f"{a['1위 엄격']} / {a['1위 느슨']}",
"5안": f"{a['5안 엄격']} / {a['5안 느슨']}",
"임도·사방·영월 1위": f"{t['북부청임도']['1위 엄격']} · {t['북부청사방']['1위 엄격']} · {t['동부청영월']['1위 엄격']}",
"줄무게 1위 느슨": a["줄 1위 느슨"],
"조합 맞음": f"{round(100 * h[0] / h[1], 1)} ({h[0]}/{h[1]})",
"칸": h[2],
"없음 줄 후보": a["없음인데 후보"],
}
def show(r):
print(" | ".join(f"{k} {v}" for k, v in r.items()))
def save(name, obj):
(HERE / name).write_text(json.dumps(obj, ensure_ascii=False, indent=1, default=str), "utf-8")
def load(name):
return json.loads((HERE / name).read_text(encoding="utf-8"))
@@ -0,0 +1,82 @@
"""req226 C7 ① 누적 실력 — 같은 정답 · 같은 셈(C1 고유 126 줄 · 조합 맞음 = C5 T1 83 호표).
python k1_stack.py [켬|끔] → 표 · stack_<켬|끔>.json
"""
import sys
import k0_lib as K
from k0_lib import S
SEEDS = "끔" not in sys.argv[1:]
MIXED = {i for i in S.BY if K.PARTS.get(i) and K.SV.is_mixed(K.PARTS[i], S.BY[i])}
def mixfirst(fp, ga1):
"""섞인 줄(몫 표시 + 노임만 든 몫 + 장비만 든 몫)은 몫 풀이 먼저(C5 길) · 나머지는 지문 1순위."""
return {i: dict(ga1[i] if i in MIXED else fp[i]) for i in S.BY}
def stages(wide=False):
ctx = K.read_wide() if wide else K._null()
rows, keep = [], {}
with ctx:
if SEEDS:
now, b1 = K.now(), K.bundle1()
else:
with K.no_seeds():
now = K.now()
b1 = K.bundle1()
fp = K.finger(b1)
ga1 = K.ga(b1)
naive_t, naive_s = K.solve(fp, seeds=SEEDS)
t3, s3 = K.solve(mixfirst(fp, ga1), seeds=SEEDS)
c5_t, c5_s = K.solve(ga1, seeds=SEEDS)
t4, s4, log = K.memory(t3, s3)
tag = " · 읽기 넓힘" if wide else ""
rows.append(K.row("지금" + tag, now))
rows.append(K.row("묶음 ①" + tag, b1))
rows.append(K.row("+ 로직 지문" + tag, fp))
rows.append(K.row("(참고) 묶음 ① + (가) + 조합 풀이 = C5 길" + tag, c5_t, K.combo_set(c5_t, c5_s)))
rows.append(K.row("(참고) + 지문 + 조합 풀이 그냥 겹침" + tag, naive_t, K.combo_set(naive_t, naive_s)))
rows.append(K.row("+ 조합 풀이(섞인 줄 몫 먼저)" + tag, t3, K.combo_set(t3, s3)))
rows.append(K.row("+ 조건부 기억(근거 가름)" + tag, t4, K.combo_set(t4, s4)))
keep = {"지금": now, "묶음①": b1, "지문": fp, "조합": {"tops": t3, "sets": s3}, "기억": {"tops": t4, "sets": s4, "log": log}}
return rows, keep
def effect(before, after, ids=None):
"""1순위 엄격 — 도움 · 해로움 줄(정답 있는 줄)."""
ids = ids or [i for i in S.BY if S.LAB[i]["정답"]]
ok = lambda t, i: bool(t) and t[0] in set(S.LAB[i]["정답"]) # noqa: E731
help_ = [i for i in ids if ok(after[i]["top"], i) and not ok(before[i]["top"], i)]
harm = [i for i in ids if ok(before[i]["top"], i) and not ok(after[i]["top"], i)]
return help_, harm
if __name__ == "__main__":
rows, keep = stages(False)
rows_w, keep_w = stages(True)
rows.append({**rows_w[-1], "단계": "+ 읽기 넓힘(영월 직종 이음)"})
for r in rows + rows_w:
K.show(r)
out = {"표": rows, "넓힘 표": rows_w}
for a, b in (("묶음①", "지문"), ("지문", "조합"), ("조합", "기억")):
x = keep[a]["tops"] if "tops" in keep[a] else keep[a]
y = keep[b]["tops"] if "tops" in keep[b] else keep[b]
h, m = effect(x, y)
out[f"{a}→{b}"] = {"도움": h, "해로움": m}
print(f"{a} → {b}: 도움 {len(h)} {h} · 해로움 {len(m)} {m}")
h, m = effect(keep["기억"]["tops"], keep_w["기억"]["tops"])
out["기억→읽기"] = {"도움": h, "해로움": m}
print(f"기억 → 읽기 넓힘: 도움 {len(h)} {h} · 해로움 {len(m)} {m}")
log = keep["기억"]["log"]
from collections import Counter
c = Counter((v[0], v[1]) for v in log.values())
print("기억 닿음(열쇠 · 처리)", dict(c))
right = Counter(v[1] for i, v in log.items() if S.LAB[i]["정답"] and v[2] in set(S.LAB[i]["정답"]))
print("기억 답 맞음(처리별)", dict(right), "· 정답 없는 줄 닿음", sum(1 for i in log if not S.LAB[i]["정답"]))
out["기억 닿음"] = {f"{k[0]}|{k[1]}": v for k, v in c.items()}
out["기억 맞음"] = dict(right)
K.save(f"stack_{'켬' if SEEDS else '끔'}.json", {**out, "줄": {k: v for k, v in keep.items()}, "줄 넓힘": keep_w})
@@ -0,0 +1,31 @@
"""req226 C7 ② 겹침 — 기억을 단계마다 얹어 봄 · 맨 위 남는 실패 줄 목록.
python k2_detail.py [켬|끔]"""
import sys
import k0_lib as K
from k0_lib import S
from cats import NOW
tag = "끔" if "끔" in sys.argv[1:] else "켬"
st = K.load(f"stack_{tag}.json")["줄"]
names = {x["로직"]: x["이름"] for x in K.hm.targets()["로직"]}
have = [i for i in S.BY if S.LAB[i]["정답"]]
ok = lambda t, i: bool(t) and t[0] in set(S.LAB[i]["정답"]) # noqa: E731
for nm in ("지금", "묶음①", "지문"):
res = st[nm]
m, _, log = K.memory(res)
a = sum(ok(res[i]["top"], i) for i in have)
b = sum(ok(m[i]["top"], i) for i in have)
hp = sum(1 for i in have if ok(m[i]["top"], i) and not ok(res[i]["top"], i))
hm_ = sum(1 for i in have if ok(res[i]["top"], i) and not ok(m[i]["top"], i))
print(f"[{tag}] {nm} + 기억: 1위 엄격 {100*a/len(have):.1f} → {100*b/len(have):.1f} · 도움 {hp} · 해로움 {hm_}")
top = st["기억"]["tops"]
fails = [i for i in have if not ok(top[i]["top"], i)]
print(f"[{tag}] 맨 위 남는 1순위 실패 {len(fails)}")
for i in fails:
t = top[i]["top"]
lab = S.LAB[i]["정답"]
rank = next((k + 1 for k, c in enumerate(t[:5]) if c in set(lab)), "-")
loose = "느슨O" if t and t[0] in set(lab) | set(S.LAB[i]["허용"]) else ""
print(f" {i} {S.BY[i]['이름'][:18]:<18} 줄{S.BY[i]['줄수']:>3} | 1위 {names.get(t[0] if t else None, '')[:16]:<16} | 정답 {names.get(lab[0], '')[:14]} (다섯 안 {rank}) {loose} | {(NOW.get(i) or ['-'])[0]}")
none = [i for i in S.BY if not S.LAB[i]["정답"]]
print(f"[{tag}] 우리에 없음 {len(none)} — 1위 후보:", [(i, S.BY[i]['이름'][:10], names.get((top[i]['top'] or [None])[0], '')[:12]) for i in none])
@@ -0,0 +1,139 @@
"""req226 C7 ③ C4 지문 점수를 무게 바꿔 끼울 수 있게 미리 셈(채널 · 변형별) → 가림 시험용.
점수 = Σ 무게 × 채널(단위 · 이름 · 조항 · 조건 · 자재 · 율) + max_변형(무게 인력 × 인력 − 0.6 · 인력 벌 + 무게 장비 × 장비 − 0.4 · 장비 벌)
= C4 s3_match.score_pair 와 같은 식(무게만 바꿈).
python k3_vec.py 지문|가벼움 → vec_<…>.npz
"""
import re
import sys
import numpy as np
import k0_lib as K
from fp_common import conds, mtypes, specnums # C4
F4 = K.F4
P = F4.P
CODES = [o["로직"] for o in P]
IDX = {c: k for k, c in enumerate(CODES)}
SEG = np.cumsum([0] + [len(o["vs"]) for o in P])[:-1]
CH = ("단위", "이름", "조항", "조건", "자재", "율")
W0 = dict(F4.W)
def chan(e, o):
unit_ok = F4.unit_fit(e["단위"], o["단위"])
g1, g2 = e["nameg"], o["nameg"]
our = F4.OURC.get(o["로직"])
c = F4.clause_prefix(e["theirs"], our)[0] if (our and e["theirs"]) else 0.0
hit, _ = F4.cond_score(e["조건"], o["nc"], o["oc"])
_, clash = F4.cond_score(e["이름조건"], o["nc"], {})
mat = 0.0
if e["matl"]:
mat = sum(1 for ws in e["matl"] if any(a in b or b in a for a in ws for b in o["matw"])) / len(e["matl"])
base = (
1.0 if unit_ok else 0.0,
2 * len(g1 & g2) / (len(g1) + len(g2)) if g1 and g2 else 0.0,
c * (0.5 if e["옛판"] else 1.0),
hit - clash,
mat,
1.0 if (e["pctv"] and o["pctv"] and e["pctv"] & o["pctv"]) else 0.0,
)
vs = []
for v in o["vs"]:
if e["E"]:
ls, pl = F4.lab_sim(e["E"], e["Eb"], v["L"], unit_ok)[0], 0.0
else:
ls, pl = 0.0, 1.0 if v["L"] else 0.0
if e["장비"]:
ms, px = F4.mach_sim(e["장비"], v["X"], o["opt"], unit_ok), 0.0
else:
ms, px = 0.0, 1.0 if (v["X"] or o["opt"]) else 0.0
vs.append((ls, pl, ms, px))
return base, vs
def build(elist):
n, L, V = len(elist), len(P), int(SEG[-1] + len(P[-1]["vs"]))
base = np.zeros((n, L, len(CH)))
var = np.zeros((4, n, V))
for a, e in enumerate(elist):
col = 0
for b, o in enumerate(P):
bs, vs = chan(e, o)
base[a, b] = bs
for t in vs:
var[:, a, col] = t
col += 1
return base, var
def scores(base, var, W=None):
W = W or W0
v = W["인력"] * var[0] - 0.6 * var[1] + W["장비"] * var[2] - 0.4 * var[3]
vmax = np.maximum.reduceat(v, SEG, axis=1)
w6 = np.array([W.get(k, 0.0) for k in CH])
return base @ w6 + vmax
RB = F4.RB
def fused_top(srow, base_top, beta, n=40):
"""F4.fuse 와 같은 차례 — 지문 상위 40(점수 차례) + 묶음 ① 다섯 · 점수 + β × 차례 덤 · 안정 정렬."""
pool = np.argsort(-srow, kind="stable")[:n]
cand = [int(k) for k in pool]
for c in base_top[:5]:
k = IDX.get(c)
if k is not None and k not in cand:
cand.append(k)
bonus = {IDX[c]: RB.get(r, 0) for r, c in enumerate(base_top[:5]) if c in IDX}
order = sorted(cand, key=lambda k: -(srow[k] + beta * bonus.get(k, 0)))
return [CODES[k] for k in order[:5]]
# ── 넓힌 파일 줄 — C6 items 로 가벼운 엑셀 지문(인력 품 · 장비 갈래 · 조항 · 조건 · 율 — 자재 · 장비 시간 · 섞음 밑품 없음) ──
_PCT = re.compile(r"([가-힣]+)의\s*([\d.]+)\s*%")
_OLD = re.compile(r"((?<![\d,.])20(0\d|1\d|20)(?![\d,.])|(?<!\d)(1\d|20)년|임도품셈|임도표준품셈|사방품셈|사방사업표준품셈)")
def lite(r):
text = [str(t) for t in (r.get("글") or [])]
nm, sp, unit = r["이름"], r.get("규격") or "", r.get("단위") or ""
head = " ".join([nm, sp] + text[:4])
mach = []
for k in r.get("장비") or []:
kinds = sorted(mtypes(k) or {k})
hours = 1.0 if (not text and re.search(r"(hr|시간)", unit, re.I)) else None
mach.append({"갈래": kinds, "규격": sorted(specnums(f"{nm} {sp}")) if not text else [], "시간": hours, "밑시간": None})
e = {
"id": r["id"], "파일": r["파일"], "이름": nm, "규격": sp, "단위": unit, "갈래": r.get("갈래"),
"출처": r["근거"]["출처"], "직종근거": r["근거"]["직종"],
"인력": {t: {"품": q, "밑품": q} for t, q in (r.get("품") or {}).items()},
"장비": mach, "자재": [], "율": [[k, float(v)] for t in text for k, v in _PCT.findall(t)],
"조건": {k: sorted(v) for k, v in conds(head).items()},
"이름조건": {k: sorted(v) for k, v in conds(f"{nm} {sp}").items()},
"옛판": bool(_OLD.search(" ".join(text[:6]))),
}
return F4.ex_prep(e)
if __name__ == "__main__":
kind = sys.argv[1] if len(sys.argv) > 1 else "지문"
if kind == "지문": # C4 엑셀 지문 136
ids = list(K.S.BY)
elist = [F4.EXP[i] for i in ids]
else: # 가벼운 지문 — C6 items(호표 쓰임 · 북부청 · 영월 136 도 견줌용으로)
import json
items = json.loads((K.C6 / "items.json").read_text(encoding="utf-8"))
w = K.load("wide.json")
want = set(K.S.BY) if sys.argv[2] == "사람" else set(w["자동"]) | set(w["씨앗"])
rows = [r for r in items if r["id"] in want]
ids = [r["id"] for r in rows]
elist = [lite(r) for r in rows]
base, var = build(elist)
tag = kind if kind == "지문" else f"가벼움_{sys.argv[2]}"
np.savez_compressed(K.HERE / f"vec_{tag}.npz", base=base, var=var, ids=np.array(ids))
print(tag, base.shape, var.shape)
@@ -0,0 +1,68 @@
"""req226 C7 ④ 넓힌 파일 줄(STmate 5 · 거창) — 정답 셋 · 지금 · 묶음 ① (읽기 출처만 / 출처직종 · 씨앗 끔 = 처음 보는 셈).
정답 = C6 그대로: 자동 엄격(사람 정답 없는 새 줄) · 씨앗 짝(씨앗 끔에서만 · 북부청 · 사람 정답 줄 뺌).
python k4_wide.py → wide.json
"""
import json
import k0_lib as K
from k0_lib import hb, hm, patched, clause_prefix, candidates_nofold, no_seeds
from s04_eval import default_line
from s16_guard import typo_guard
from s06_match import line_of # C6
from s04_auto import seeds_of # C6
ITEMS = json.loads((K.C6 / "items.json").read_text(encoding="utf-8"))
AUTO = json.loads((K.C6 / "auto.json").read_text(encoding="utf-8"))
HAND = set(K.S.LAB)
def answers():
got = hm.targets() # 씨앗 켬 상태로 씨앗 짝을 읽음
auto, seed = {}, {}
for r in ITEMS:
i = r["id"]
if i in HAND:
continue
a = AUTO.get(i, {}).get("정답")
if a:
auto[i] = [a]
if r["파일"] not in ("북부청임도", "북부청사방"):
s = seeds_of(r, got)
if s:
seed[i] = sorted(s)
return auto, seed
def run(rows, variant, fn):
with no_seeds(), patched(hb, _clause=clause_prefix), patched(hm, candidates=candidates_nofold):
hm._targets.cache_clear()
hb._ours.cache_clear()
got = hm.targets()
return {r["id"]: fn(line_of(r, variant), got, {"단산글": r.get("글") or []}) for r in rows}
def run_now(rows, variant):
with no_seeds():
hm._targets.cache_clear()
got = hm.targets()
return {r["id"]: default_line(line_of(r, variant), got, {}) for r in rows}
if __name__ == "__main__":
auto, seed = answers()
ids = set(auto) | set(seed)
rows = [r for r in ITEMS if r["id"] in ids]
out = {"자동": auto, "씨앗": seed, "줄": {r["id"]: {"파일": r["파일"], "이름": r["이름"], "호표쓰임": r["호표쓰임"]} for r in rows}}
for v in ("출처만", "출처직종"):
out[f"지금|{v}"] = run_now(rows, v)
out[f"묶음①|{v}"] = run(rows, v, typo_guard)
K.save("wide.json", out)
from collections import Counter
print("자동 새 줄", len(auto), Counter(ITEMS_BY[i]["파일"] for i in auto) if (ITEMS_BY := {r["id"]: r for r in ITEMS}) else "", "· 씨앗 짝", len(seed), Counter(ITEMS_BY[i]["파일"] for i in seed), "· 겹침", len(set(auto) & set(seed)))
for k in ("지금|출처만", "지금|출처직종", "묶음①|출처만", "묶음①|출처직종"):
for nm, ans in (("자동", auto), ("씨앗", seed)):
n = len(ans)
a1 = sum(1 for i in ans if out[k][i]["top"][:1] and out[k][i]["top"][0] in set(ans[i]))
a5 = sum(1 for i in ans if set(out[k][i]["top"][:5]) & set(ans[i]))
print(f"{k} · {nm} {n}: 1위 {100*a1/n:.1f} · 다섯 안 {100*a5/n:.1f}")
@@ -0,0 +1,115 @@
"""req226 C7 ⑤ 가림 시험 — 문턱 · 무게를 북부청 임도로만 정하고 사방 · 영월 · 넓힌 파일 줄에서 다시 잼.
손잡이 = C4 무게(인력 · 장비 · 조항 · 이름 · 조건) · β · 「같음 그대로」 · C5 섞인 줄 뺌 문턱(cut) · C6 기억 규칙.
최종 1순위 = 기억(바로 · 1순위) > 섞인 줄 몫 먼저(C5 길 · cut) > 같음 그대로 > 지문(무게 · β).
python k5_hold.py 준비 → hold_prep.json(섞인 줄 C5 길 · 기억 판단)
python k5_hold.py 그물 <인력> → hold_<인력>.json(설정마다 무리별 1순위 · 다섯 안)
"""
import itertools
import json
import sys
import numpy as np
import k0_lib as K
import k3_vec as V
from k0_lib import S
FULL = np.load(K.HERE / "vec_지문.npz")
LITE = np.load(K.HERE / "vec_가벼움_넓힘.npz")
IDS_F, IDS_W = [str(x) for x in FULL["ids"]], [str(x) for x in LITE["ids"]]
B1 = K.load("st_b1.json") # 묶음 ① · 씨앗 켬(126)
WIDE = K.load("wide.json")
BW = WIDE["묶음①|출처직종"] # 넓힌 줄 · 씨앗 끔 · 읽기 넓힘 뒤
MIXED = {i for i in S.BY if K.PARTS.get(i) and K.SV.is_mixed(K.PARTS[i], S.BY[i])}
GROUP_OF = {"북부청임도": "임도", "북부청사방": "사방", "동부청영월": "영월"}
RULES = ("지금(a 바로)", "a·b 바로(넓히기만)", "모든 열쇠 바로", "근거 가름", "근거 가름 + 발주처")
CUTS = (0.2, 0.35, 0.5)
BETAS = (0.0, 0.5, 1.0, 2.0, 3.0)
# (줄, 무리, 정답) 짝 — 126 사람 정답 · 넓힌 자동 20 · 씨앗 짝 43
PAIRS = [(i, GROUP_OF[S.BY[i]["파일"]], set(S.LAB[i]["정답"])) for i in IDS_F if S.LAB[i]["정답"]]
PAIRS += [(i, "자동", set(v)) for i, v in WIDE["자동"].items()]
PAIRS += [(i, "씨앗", set(v)) for i, v in WIDE["씨앗"].items()]
GROUPS = ("임도", "사방", "영월", "자동", "씨앗")
def mem_dec(rule):
"""{id: (처리, 답)} — 차례 누적(앞 파일 모두 확정) · 대상 = 126 + 넓힌 줄."""
items = {r["id"]: r for r in json.loads((K.C6 / "items.json").read_text(encoding="utf-8"))}
want = set(IDS_F) | set(IDS_W)
out = {}
for at, target in enumerate(K.M8.ORDER[1:], 1):
memos = {name: K.M8.build(K.M8.ORDER[:at], name) for name in K.CHAIN}
for i in want:
r = items[i]
if r["파일"] != target:
continue
name, ans, prov = K.first_hit(r, memos, K.RULE_CHAIN.get(rule, K.CHAIN))
tr = K.tier(rule, name, ans, prov, r, target)
if tr != "없음":
out[i] = (tr, ans)
return out
def prep():
ga1 = K.ga(B1)
c5 = {str(c): {i: v["top"] for i, v in K.solve(ga1, c)[0].items() if i in MIXED} for c in CUTS}
dec = {r: mem_dec(r) for r in RULES}
K.save("hold_prep.json", {"c5": c5, "dec": dec})
def grid(wl):
pre = K.load("hold_prep.json")
n = len(PAIRS)
gi = np.array([GROUPS.index(g) for _, g, _ in PAIRS])
same = np.array([i in B1 and B1[i]["상태"] == "같음" for i, _, _ in PAIRS])
same_ok = np.array([bool(B1[i]["top"]) and B1[i]["top"][0] in a if i in B1 else False for i, _, a in PAIRS])
samew = np.array([i in BW and BW[i]["상태"] == "같음" for i, _, _ in PAIRS])
samew_ok = np.array([bool(BW[i]["top"]) and BW[i]["top"][0] in a if i in BW else False for i, _, a in PAIRS])
is126 = np.array([g in ("임도", "사방", "영월") for _, g, _ in PAIRS])
mixed = np.array([i in MIXED for i, _, _ in PAIRS])
c5_ok = {c: np.array([bool(pre["c5"][c].get(i)) and pre["c5"][c][i][0] in a if i in MIXED else False for i, _, a in PAIRS]) for c in pre["c5"]}
mem_has, mem_ok = {}, {}
for r, d in pre["dec"].items():
mem_has[r] = np.array([i in d and d[i][0] in ("바로", "1순위") for i, _, _ in PAIRS])
mem_ok[r] = np.array([i in d and d[i][1] in a for i, _, a in PAIRS])
keep_same = np.where(is126, same, samew)
keep_ok = np.where(is126, same_ok, samew_ok)
rowF = {i: a for a, i in enumerate(IDS_F)}
rowW = {i: a for a, i in enumerate(IDS_W)}
out = []
for wx, wc, wn, wk in itertools.product((1.0, 2.0, 3.0), (0.75, 1.5, 3.0), (0.3, 0.6, 1.2), (0.3, 0.6, 1.2)):
W = {**V.W0, "인력": wl, "장비": wx, "조항": wc, "이름": wn, "조건": wk}
SF = V.scores(FULL["base"], FULL["var"], W)
SW = V.scores(LITE["base"], LITE["var"], W)
for beta in BETAS:
ok1, ok5 = np.zeros(n, bool), np.zeros(n, bool)
for k, (i, g, a) in enumerate(PAIRS):
if g in ("임도", "사방", "영월"):
top = V.fused_top(SF[rowF[i]], B1[i]["top"], beta)
else:
top = V.fused_top(SW[rowW[i]], BW[i]["top"], beta)
ok1[k] = bool(top) and top[0] in a
ok5[k] = bool(set(top[:5]) & a)
for keep in (True, False):
fp1 = np.where(keep & keep_same, keep_ok, ok1)
fp5 = np.where(keep & keep_same, keep_ok, ok5)
for c in pre["c5"]:
mx1 = np.where(mixed, c5_ok[c], fp1)
for r in RULES:
fin = np.where(mem_has[r], mem_ok[r], mx1)
acc1 = np.bincount(gi, weights=fin, minlength=5) / np.bincount(gi, minlength=5)
acc5 = np.bincount(gi, weights=fp5, minlength=5) / np.bincount(gi, minlength=5)
out.append({"W": [wl, wx, wc, wn, wk], "β": beta, "같음": keep, "cut": c, "기억": r,
"1위": [round(100 * x, 1) for x in acc1], "지문5안": [round(100 * x, 1) for x in acc5]})
K.save(f"hold_{wl:g}.json", out)
print(wl, len(out))
if __name__ == "__main__":
if sys.argv[1] == "준비":
prep()
else:
grid(float(sys.argv[2]))
@@ -0,0 +1,55 @@
"""req226 C7 ⑥ 가림 시험 정리 — 임도만 보고 고른 설정들 대 지금 설정 · 무리별 1순위 엄격."""
import json
import statistics as st
from collections import Counter
import k0_lib as K
rows = []
for w in ("2", "3", "4"):
rows += K.load(f"hold_{w}.json")
G = ("임도", "사방", "영월", "자동", "씨앗")
print("설정 수", len(rows))
def pick(pred):
return [r for r in rows if pred(r)]
def show(name, rs):
if not rs:
return
cols = []
for k, g in enumerate(G):
v = [r["1위"][k] for r in rs]
cols.append(f"{g} {min(v)}~{st.median(v)}~{max(v)}" if len(rs) > 1 else f"{g} {v[0]}")
print(f"{name} (설정 {len(rs)}) | " + " | ".join(cols))
pub = pick(lambda r: r["W"] == [3, 2, 1.5, 0.6, 0.6] and r["β"] == 1.0 and r["같음"] and r["cut"] == "0.35" and r["기억"] == "근거 가름")
pre = pick(lambda r: r["W"] == [3, 2, 1.5, 0.6, 0.6] and r["β"] == 2.0 and r["같음"] and r["cut"] == "0.35" and r["기억"] == "근거 가름")
show("지금 설정(세 파일 보고 고름 · β=1)", pub)
show("미리 정한 값(β=2)", pre)
best1 = max(r["1위"][0] for r in rows)
tied = pick(lambda r: r["1위"][0] == best1)
best5 = max(r["지문5안"][0] for r in tied)
tied2 = [r for r in tied if r["지문5안"][0] == best5]
show(f"임도만 보고 고름 — 임도 1위 최고 {best1}", tied)
show(f" + 동점 가름(임도 지문 다섯 안 {best5})", tied2)
show("모든 설정(참고)", rows)
for k, g in enumerate(G[1:], 1):
v = [r["1위"][k] for r in tied2]
print(f" {g}: 지금 설정 {pub[0]['1위'][k]} → 임도만 고름 가운데 {st.median(v)} (폭 {round(st.median(v) - pub[0]['1위'][k], 1)}) · 가장 나쁨 {min(v)}")
print("임도만 고른 설정의 손잡이 분포:")
for key in ("β", "같음", "cut", "기억"):
print(" ", key, Counter(str(r[key]) for r in tied2).most_common())
for j, nm in enumerate(("인력", "장비", "조항", "이름", "조건")):
print(" ", nm, Counter(r["W"][j] for r in tied2).most_common())
# 손잡이마다 — 가림 무리(사방 · 영월 · 자동 · 씨앗) 평균 1순위, 다른 손잡이는 지금 설정 그대로
base = pub[0]
for key, vals in (("β", (0.0, 0.5, 1.0, 2.0, 3.0)), ("같음", (True, False)), ("cut", ("0.2", "0.35", "0.5")), ("기억", ("지금(a 바로)", "a·b 바로(넓히기만)", "모든 열쇠 바로", "근거 가름", "근거 가름 + 발주처"))):
cells = []
for v in vals:
r = next(x for x in rows if x["W"] == base["W"] and all(x[k] == (v if k == key else base[k]) for k in ("β", "같음", "cut", "기억")))
cells.append(f"{v}: " + "/".join(str(x) for x in r["1위"]))
print(f"[{key}] " + " · ".join(cells))
@@ -0,0 +1,100 @@
"""req226 C7 ⑦ 넓힌 파일 줄 누적 · 가벼운 지문 눈금 · 딱 맞음 정밀(파일별 · 가림)."""
import json
import numpy as np
import k0_lib as K
import k3_vec as V
from k0_lib import S
F4 = K.F4
W = K.load("wide.json")
pre = K.load("hold_prep.json")
dec = pre["dec"]["근거 가름"]
decorg = pre["dec"]["근거 가름 + 발주처"]
LITE = np.load(K.HERE / "vec_가벼움_넓힘.npz")
IDS_W = [str(x) for x in LITE["ids"]]
SW = V.scores(LITE["base"], LITE["var"])
ANS = {"자동": {i: set(v) for i, v in W["자동"].items()}, "씨앗": {i: set(v) for i, v in W["씨앗"].items()}}
def rate(res, ans, k=1):
return round(100 * sum(1 for i in ans if set(res[i]["top"][:k]) & ans[i]) / len(ans), 1)
def mem(res, d):
out = {}
for i, v in res.items():
top = list(v["top"])
if i in d and d[i][0] in ("바로", "1순위"):
a = d[i][1]
top = [a] + [c for c in top if c != a][:4]
out[i] = {**v, "top": top}
return out
def fp(base, keep, beta=1.0):
out = {}
for a, i in enumerate(IDS_W):
if keep and base[i]["상태"] == "같음":
out[i] = dict(base[i])
else:
out[i] = {"상태": "후보", "top": V.fused_top(SW[a], base[i]["top"], beta)}
return out
steps = [
("지금(읽기 = 336 영월 흉내)", W["지금|출처만"]),
("묶음 ①", W["묶음①|출처만"]),
("+ 읽기 넓힘(STmate 직종 이음 · 거창 행 칸 이음)", W["묶음①|출처직종"]),
]
f_keep = fp(W["묶음①|출처직종"], True)
f_free = fp(W["묶음①|출처직종"], False)
steps += [("+ 로직 지문(가벼움 · 같음 그대로)", f_keep), ("+ 로직 지문(가벼움 · 같음도 지문)", f_free)]
steps += [("+ 조건부 기억(근거 가름) — 같음 그대로 위", mem(f_keep, dec)), ("+ 조건부 기억(근거 가름 + 발주처) — 같음 그대로 위", mem(f_keep, decorg))]
print("단계 | 자동 정답 20 1위 · 다섯 안 | 씨앗 짝 43(처음 보는 셈) 1위 · 다섯 안")
for name, res in steps:
print(f"{name} | {rate(res, ANS['자동'])} · {rate(res, ANS['자동'], 5)} | {rate(res, ANS['씨앗'])} · {rate(res, ANS['씨앗'], 5)}")
print("(참고) 지금 위 기억(근거 가름)", rate(mem(W["지금|출처만"], dec), ANS["씨앗"]), "· 묶음① 위", rate(mem(W["묶음①|출처직종"], dec), ANS["씨앗"]))
# 가벼운 지문 눈금 — 136 줄에서 가벼움 대 C4 지문(같은 무게 · β=1 · 같음 그대로)
L = np.load(K.HERE / "vec_가벼움_사람.npz")
SL = V.scores(L["base"], L["var"])
b1 = K.load("st_b1.json")
ok = lambda t, i: bool(t) and t[0] in set(S.LAB[i]["정답"]) # noqa: E731
have = [i for i in S.BY if S.LAB[i]["정답"]]
rowL = {str(i): a for a, i in enumerate(L["ids"])}
lite_res = {i: (b1[i] if b1[i]["상태"] == "같음" else {"top": V.fused_top(SL[rowL[i]], b1[i]["top"], 1.0)}) for i in S.BY}
full_res = K.load("st_fp.json")
print("가벼운 지문 눈금(126) — C4 지문", round(100 * sum(ok(full_res[i]["top"], i) for i in have) / len(have), 1),
"· 가벼움", round(100 * sum(ok(lite_res[i]["top"], i) for i in have) / len(have), 1))
for f in ("북부청임도", "북부청사방", "동부청영월"):
ids = [i for i in have if S.BY[i]["파일"] == f]
print(" ", f, round(100 * sum(ok(full_res[i]["top"], i) for i in ids) / len(ids), 1), "→", round(100 * sum(ok(lite_res[i]["top"], i) for i in ids) / len(ids), 1))
# 딱 맞음 — 파일별(C4 지문 · 126) + 씨앗 짝(가벼움 · 가림)
P = K.pools()
by = {}
for i in S.BY:
if F4.sure(F4.EXP[i], P[i]):
f = S.BY[i]["파일"]
a = by.setdefault(f, [0, 0, 0])
a[0] += 1
if S.LAB[i]["정답"]:
a[1] += 1
a[2] += P[i][0][1] in set(S.LAB[i]["정답"])
print("딱 맞음(지문 1위 · 정답 있음 · 맞음) 파일별:", by)
items = {r["id"]: r for r in json.loads((K.C6 / "items.json").read_text(encoding="utf-8"))}
cnt = {"자동": [0, 0], "씨앗": [0, 0]}
wrong = []
for i in IDS_W:
e = V.lite(items[i])
pool = F4.rank(e, None, 2)
if F4.sure(e, pool):
for g in ("자동", "씨앗"):
if i in ANS[g]:
cnt[g][0] += 1
cnt[g][1] += pool[0][1] in ANS[g][i]
if pool[0][1] not in ANS[g][i]:
wrong.append((g, i, items[i]["이름"], pool[0][1], sorted(ANS[g][i])))
print("딱 맞음(가벼움) 넓힌 줄:", cnt, "틀림", wrong)
@@ -0,0 +1,78 @@
"""req226 C7 ⑧ 「딱 맞음이 같음을 이김」 — 같음 줄이라도 지문이 딱 맞음(숫자 일치 · 2위와 차 큼)이고 다른 로직이면 지문.
126(씨앗 켬 · 끔 · 맨 위 누적까지) · 넓힌 줄(가벼움)."""
import json
import numpy as np
import k0_lib as K
import k3_vec as V
from k0_lib import S
F4 = K.F4
P = K.pools()
SURE = {i: F4.sure(F4.EXP[i], P[i]) for i in S.BY}
have = [i for i in S.BY if S.LAB[i]["정답"]]
ok = lambda t, i: bool(t) and t[0] in set(S.LAB[i]["정답"]) # noqa: E731
MIXED = {i for i in S.BY if K.PARTS.get(i) and K.SV.is_mixed(K.PARTS[i], S.BY[i])}
def finger2(rec):
out = {}
for i in S.BY:
if rec[i]["상태"] == "같음" and not (SURE[i] and P[i][0][1] != rec[i]["top"][0]):
out[i] = dict(rec[i])
else:
K.F4.CUR["id"] = i
out[i] = {"상태": "후보", "top": F4.fuse(None, rec[i], 1.0, P[i])}
return out
for tag in ("켬", "끔"):
st = K.load(f"stack_{tag}.json")["줄"]
b1 = st["묶음①"]
f1, f2 = st["지문"], finger2(b1)
flips = [(i, S.BY[i]["이름"][:12], ok(f1[i]["top"], i), ok(f2[i]["top"], i)) for i in S.BY if f1[i]["top"][:1] != f2[i]["top"][:1]]
print(f"[{tag}] 지문 같음 그대로 → 딱 맞음이 이김: {K.S.table('x', f1)['전체']['1위 엄격']} → {K.S.table('x', f2)['전체']['1위 엄격']} · 바뀐 줄 {flips}")
ga1 = K.ga(b1)
res = {i: dict(ga1[i] if i in MIXED else f2[i]) for i in S.BY}
t3, s3 = K.solve(res, seeds=(tag == "켬"))
t4, s4, _ = K.memory(t3, s3)
K.show(K.row(f"[{tag}] 맨 위 누적(딱 맞음이 같음을 이김)", t4, K.combo_set(t4, s4)))
K.save(f"st_top2_{tag}.json", {"tops": t4, "sets": s4})
# 넓힌 줄
W = K.load("wide.json")
pre = K.load("hold_prep.json")
LITE = np.load(K.HERE / "vec_가벼움_넓힘.npz")
IDS_W = [str(x) for x in LITE["ids"]]
SW = V.scores(LITE["base"], LITE["var"])
items = {r["id"]: r for r in json.loads((K.C6 / "items.json").read_text(encoding="utf-8"))}
ANS = {"자동": {i: set(v) for i, v in W["자동"].items()}, "씨앗": {i: set(v) for i, v in W["씨앗"].items()}}
base = W["묶음①|출처직종"]
res = {}
for a, i in enumerate(IDS_W):
e = V.lite(items[i])
pool = F4.rank(e, None, 2)
sure = F4.sure(e, pool)
if base[i]["상태"] == "같음" and not (sure and pool[0][1] != base[i]["top"][0]):
res[i] = dict(base[i])
else:
res[i] = {"상태": "후보", "top": V.fused_top(SW[a], base[i]["top"], 1.0)}
def rate(r, ans, k=1):
return round(100 * sum(1 for i in ans if set(r[i]["top"][:k]) & ans[i]) / len(ans), 1)
def mem(r, d):
out = {}
for i, v in r.items():
top = list(v["top"])
if i in d and d[i][0] in ("바로", "1순위"):
top = [d[i][1]] + [c for c in top if c != d[i][1]][:4]
out[i] = {**v, "top": top}
return out
for nm, r in (("+ 지문(딱 맞음이 같음을 이김)", res), ("+ 기억(근거 가름)", mem(res, pre["dec"]["근거 가름"])), ("+ 기억(근거 가름 + 발주처)", mem(res, pre["dec"]["근거 가름 + 발주처"]))):
print(f"넓힌 줄 {nm} | 자동 {rate(r, ANS['자동'])} · {rate(r, ANS['자동'], 5)} | 씨앗 {rate(r, ANS['씨앗'])} · {rate(r, ANS['씨앗'], 5)}")
@@ -0,0 +1,485 @@
"""K5 공용 — 「로직 + 옵션」 거꾸로 찾기: 옵션 공간 · 엔진 셈(캐시) · 엑셀 품 맞대기.
우리 쪽 = C4 `s2_ours_fp.run_one`(M01 엔진 `mf.run` — `Labor_Doc.run_line` 과 같은 읽기 길) 그대로 · 읽기만.
옵션 공간(로직 입력 칸마다 후보):
고르기(지역 · 보정작업 · 조종원직종 뺌 — 1,438 로직 모두 품을 안 바꿈 · p01) = 모두
기계 고르기(EQ 키) = 모두(엑셀 규격 수가 있으면 그 규격에 맞는 것만 · 맞는 것 없으면 모두)
범위 = 두 끝 + 범위 안의 엑셀 수
찾기 조건에 쓰인 수 = 그 표의 줄 값(`master_auto._fits`) + 엑셀 수(표 값과 같은 것만 남음)
그 밖 수 = 자동값 + 엑셀 계수(같은 이름 · q f E Cm …) + 엑셀 수(이름 · 규격 · 블록 글) — 「품」 칸은 × 결과단위
조합 = 곱이 CAP 이하면 모두 · 넘으면 엑셀 조건 낱말로 고르기를 좁힘 · 그래도 넘으면 하나씩 바꿈 + 고르기 둘씩.
"""
import itertools
import json
import math
import re
import sys
import unicodedata
from decimal import Decimal
from pathlib import Path
HERE = Path(__file__).resolve().parent
SP = HERE.parent.parent
C4 = SP / "req226" / "C4"
for p in (str(C4), str(SP / "req225" / "C1"), str(SP / "req225" / "C3")):
if p not in sys.path:
sys.path.insert(0, p)
sys.stdout.reconfigure(encoding="utf-8")
import s2_ours_fp as O # noqa: E402
from fp_common import conds, mtypes, specnums # noqa: E402
WHOLE, m01, mau = O.WHOLE, O.m01, O.m01.mau
EQK = re.compile(r"^\d{4}-\d{4}$")
FIXED = ("지역", "보정작업", "조종원직종")
CAP = 1500
_NUM = re.compile(r"(?<![\d,.])(\d+(?:\.\d+)?)(?![\d,])")
UNITS = {
"%": ("%",), "㎝": ("㎝", "cm"), "cm": ("㎝", "cm"), "㎞": ("㎞", "km"), "km": ("㎞", "km"), "㎜": ("㎜", "mm"), "mm": ("㎜", "mm"),
"㎥": ("㎥", "m3"), "㎡": ("㎡", "m2"), "m": ("m",), "회": ("회",), "개월": ("개월",), "층": ("층",), "t": ("t", "ton", "톤"),
"ton": ("t", "ton", "톤"), "톤": ("t", "ton", "톤"), "분": ("분",), "초": ("초", "sec"), "kg": ("kg", "㎏"), "㎏": ("kg", "㎏"),
}
def excel_numbers(texts, spec=None) -> set:
"""엑셀 이름 · 규격 · 블록 글의 수 — spec 을 주면 그 칸의 단위가 뒤에 붙거나 칸 이름이 앞에 붙은 수만
(천 자리 쉼표 든 단가는 뺌)."""
out = set()
flat = [unicodedata.normalize("NFKC", str(t or "")) for t in texts]
if spec is None:
pats = [_NUM]
else:
u = unicodedata.normalize("NFKC", str(spec.get("단위") or "")).strip().lower()
vs = UNITS.get(u, (u,) if u and len(u) <= 3 and "/" not in u else ())
if u == "%": # 「노무비의 2%」 · 「38%」 같은 율이 감률 칸 후보로 드는 것을 막음 — % 칸은 이름 붙은 수만
vs = ()
num = r"(\d+(?:\.\d+)?)"
pats = [re.compile(r"(?<![\d,.])" + num + r"\s*(?:" + "|".join(re.escape(x) for x in vs) + r")(?![A-Za-z가-힣])", re.I)] if vs else []
nm = re.escape(unicodedata.normalize("NFKC", spec["이름"]))
pats.append(re.compile(nm + r"\s*[:=(]?\s*" + num + r"(?![\d,])"))
for t in flat:
for pat in pats:
for n in pat.findall(t):
v = Decimal(n)
if 0 < v <= 2000:
out.add(v.normalize() if v == v.to_integral() else v)
return out
def _uses(row):
try:
return mau._uses(row)
except Exception: # noqa: BLE001
return {}, set()
def eq_label(code):
one = O.EQ.get(str(code)) or {}
return mtypes(f"{one.get('상세구분')} {one.get('이름')}"), specnums(one.get("규격"))
def space(code, ex=None):
"""[(입력 이름, 후보 목록, 갈래)] — ex = 엑셀 줄(수 · 규격 수 · 계수 · 조건 낱말)."""
row = WHOLE.logic(code)
auto = mau.values(WHOLE, row)
finds, _ = _uses(row)
texts = (ex or {}).get("글") or []
coef = {k.lower(): Decimal(str(v)) for k, v in ((ex or {}).get("계수") or {}).items()}
spn = (ex or {}).get("규격수") or set()
times = Decimal(str(O.times_of(row)))
out = []
for s in row.get("입력") or []:
name = s["이름"]
opts = s.get("고르기") or []
if name in FIXED:
continue
if opts and all(isinstance(v, str) and EQK.match(v) for v in opts):
keep = [v for v in opts if spn and eq_label(v)[1] & spn]
out.append((name, keep or list(opts), "기계"))
elif opts:
out.append((name, list(opts), "고르기"))
elif isinstance(s.get("범위"), list) and len(s["범위"]) == 2:
lo, hi = (Decimal(str(x)) for x in s["범위"])
vals = {lo, hi} | {v for v in excel_numbers(texts, s) if lo <= v <= hi}
out.append((name, sorted(vals), "범위"))
elif name in finds:
rows = set()
for ref, cond in finds[name]:
rows |= set(mau._fits(WHOLE, ref, cond))
cand = sorted(rows) or [auto.get(name)]
out.append((name, cand, "표"))
else:
base = auto.get(name)
vals = {base} if base is not None else set()
if name.lower() in coef:
vals.add(coef[name.lower()])
vals |= excel_numbers(texts, s) if texts else set()
if str(s.get("단위") or "").strip() == "%":
vals.add(Decimal(0))
if name == "품":
vals |= {v * times for v in (ex or {}).get("품값", set())}
vals.discard(None)
out.append((name, sorted(vals, key=lambda v: (v != base, v)) or [base], "수"))
return out
def narrow(sp, ex):
"""고르기 후보를 엑셀 조건 낱말로 좁힘 — 낱말이 든 후보가 있으면 그것만."""
words = set()
for ax, ws in ((ex or {}).get("조건") or {}).items():
words |= set(ws)
flat = re.sub(r"\s+", "", unicodedata.normalize("NFKC", " ".join((ex or {}).get("글머리") or [])))
out = []
for name, opts, kind in sp:
if kind == "고르기" and len(opts) > 1:
hit = [o for o in opts if any(w in str(o) for w in words) or (len(str(o)) >= 2 and str(o) in flat)]
if hit:
out.append((name, hit, kind))
continue
out.append((name, opts, kind))
return out
def combos(sp, ex=None, cap=CAP):
"""조합 목록 · 어떻게 냈나(모두 / 좁힘 / 하나씩)."""
size = math.prod(len(o) for _, o, _ in sp) if sp else 1
if size <= cap:
return [dict(zip([n for n, _, _ in sp], vals)) for vals in itertools.product(*[o for _, o, _ in sp])], "모두"
sp2 = narrow(sp, ex)
size2 = math.prod(len(o) for _, o, _ in sp2)
if size2 <= cap:
return [dict(zip([n for n, _, _ in sp2], vals)) for vals in itertools.product(*[o for _, o, _ in sp2])], "좁힘"
# 하나씩 + 고르기 · 표 · 기계 둘씩(수 칸은 하나씩만)
base = {n: o[0] for n, o, _ in sp2}
out = [dict(base)]
for n, o, _ in sp2:
out += [{**base, n: v} for v in o[1:]]
small = [(n, o) for n, o, k in sp2 if k != "수"]
for (n1, o1), (n2, o2) in itertools.combinations(small, 2):
if len(o1) * len(o2) <= 400:
out += [{**base, n1: a, n2: b} for a in o1 for b in o2]
seen, uniq = set(), []
for c in out:
k = json.dumps(c, sort_keys=True, default=str, ensure_ascii=False)
if k not in seen:
seen.add(k)
uniq.append(c)
return uniq[:cap], "하나씩"
_RUN = {}
_ROWS = {}
def _row(code):
if code not in _ROWS:
row = WHOLE.logic(code)
by_name = {}
for h in row.get("호표") or []:
by_name.setdefault(h.get("이름"), h)
_ROWS[code] = (row, by_name, O.times_of(row))
return _ROWS[code]
def run(code, given):
"""엔진 셈 → {L, X, M, S, mix} · 못 돌면 None(캐시).
「기계 시간당 기계경비」 로직 = 고른 기계(EQ 칸)의 갈래 · 규격 1 시간(조종원 줄은 기계경비 속이라 뺌)."""
k = (code, json.dumps(given, sort_keys=True, default=str, ensure_ascii=False))
if k not in _RUN:
row, by_name, times = _row(code)
try:
v = O.run_one(code, row, dict(given), times, by_name)
except Exception: # noqa: BLE001
v = None
_RUN[k] = v
v = _RUN[k]
row = _row(code)[0]
if v is not None and O.is_mach_logic(row):
auto = _AUTO.setdefault(code, mau.values(WHOLE, row))
X = []
for s in row.get("입력") or []:
opts = s.get("고르기") or []
if opts and all(isinstance(o, str) and EQK.match(o) for o in opts):
c = given.get(s["이름"], auto.get(s["이름"]))
ts, sp = eq_label(c)
X.append((sorted(ts | set(O.FIXED_MACH.get(code, set()))), sorted(sp), 1.0))
if not X and O.FIXED_MACH.get(code):
X = [(sorted(O.FIXED_MACH[code]), [], 1.0)]
v = {**v, "L": {}, "X": X}
return v
_AUTO = {}
def sig(v, nd=4):
"""품 꼴 서명 — 직종 · 장비(갈래 · 시간) · 자재 · 딸린 로직 수량(유효 nd 자리)."""
if v is None:
return None
r = lambda x: float(f"{x:.{nd}g}") # noqa: E731
return json.dumps(
[
sorted((k, r(q)) for k, q in v["L"].items() if q),
sorted((tuple(a), tuple(b), r(q)) for a, b, q in v["X"] if q),
sorted((k, r(q)) for k, q in v["M"].items() if q),
sorted((k, r(q)) for k, q in v["S"].items() if q),
],
ensure_ascii=False,
)
# ── 맞대기 ──────────────────────────────────────────────────────────────
def near(a, b, t, r=0.0):
"""a(엑셀) ≈ b(우리) — 비 t 안 · r = 엑셀 값이 적힌 자릿수의 반올림 폭(끝수 허용)."""
return a is not None and b is not None and a > 0 and b > 0 and abs(a - b) <= max(t * max(a, b), r)
def ru(x):
"""엑셀 값의 반올림 폭 — 소수 둘째~넷째 자리로 적힌 값만 · 값의 4% 안(0.15 → 0.005 · 0.1 → 0 · 0.109091 → 0)."""
s = repr(float(x))
if "e" in s or "." not in s:
return 0.0
d = len(s.split(".")[1].rstrip("0"))
return min(0.5 * 10 ** (-d), 0.04 * float(x)) if 2 <= d <= 4 else 0.0
UNITW = {"mm", "cm", "kg", "ton", "sec", "kgf", "mpa"}
def mat_words(name):
"""자재 이름 낱말 — 한글 두 자 넘는 덩이 · 영문 세 자 넘는 덩이(괄호 속도) · 단위 낱말 뺌."""
t = unicodedata.normalize("NFKC", str(name or "")).lower().replace(".", "")
return {w for w in re.findall(r"[가-힣]{2,}|[a-z]{3,}", t) if w not in UNITW}
def compare(e, v, t, base=False, rnd=False):
"""엑셀 지문 e(C4 꼴) ↔ 우리 셈 v · 허용 t.
→ {직종: 같음/다름/없음, 장비: …, 자재: …, 핵심: bool, 덧: [엑셀만 장비 …]}
base=True 면 엑셀 밑품(섞음 · 할증 뺀 품)으로 견줌."""
if v is None:
return None
E = {k: (x["밑품"] if base else x["품"]) for k, x in e["인력"].items() if x.get("품")}
O_ = {k: q for k, q in v["L"].items() if q and q > 0}
out = {"덧": []}
if E:
out["직종"] = "같음" if set(E) == set(O_) and all(near(E[k], O_[k], t, ru(E[k]) if rnd else 0) for k in E) else ("직종 같음" if set(E) == set(O_) else "다름")
elif O_:
out["직종"] = "우리만"
else:
out["직종"] = "없음"
# 장비 — 같은 갈래끼리 시간
EX = [m for m in e["장비"] if m.get("시간")]
OX = [(set(a), q) for a, _, q in v["X"] if q]
if EX and OX:
ok = []
for m in EX:
mt = set(m["갈래"])
mine = [q for a, q in OX if a & mt]
if not mine:
out["덧"].append("엑셀만 장비 " + "·".join(sorted(mt)))
continue
h = m["밑시간"] if base and m.get("밑시간") else m["시간"]
ok.append(any(near(h, q, t, ru(h) if rnd else 0) for q in mine) or near(h, sum(mine), t, ru(h) if rnd else 0))
et = set().union(*[set(m["갈래"]) for m in e["장비"]])
for a, _ in OX:
if not a & et:
out["덧"].append("우리만 장비 " + "·".join(sorted(a)))
out["장비"] = ("같음" if all(ok) else "다름") if ok else "갈래 안 겹침"
elif EX:
out["장비"] = "엑셀만"
elif OX:
out["장비"] = "우리만"
else:
out["장비"] = "없음"
# 자재 — 이름 낱말이 겹치는 것끼리 수량
EM = [m for m in e["자재"] if m.get("품")]
OM = {k: q for k, q in v["M"].items() if q}
if EM and OM:
ok = []
for m in EM:
ws = mat_words(m["이름"])
mine = [q for k, q in OM.items() if ws & mat_words(k) or any(a in b or b in a for a in ws for b in mat_words(k))]
if mine:
x = m["밑품"] if base and m.get("밑품") else m["품"]
ok.append(any(near(x, q, t, ru(x) if rnd else 0) for q in mine))
out["자재"] = ("같음" if all(ok) else "다름") if ok else "이름 안 겹침"
elif EM:
out["자재"] = "엑셀만"
else:
out["자재"] = "없음"
core = []
if E:
core.append(out["직종"] == "같음")
elif O_:
core.append(False)
if out["장비"] in ("같음", "다름"):
core.append(out["장비"] == "같음")
if out["자재"] in ("같음", "다름"):
core.append(out["자재"] == "같음")
out["핵심"] = bool(core) and all(core)
out["직종만"] = bool(E) and out["직종"] == "같음"
return out
# ── 믹서 갈래(C4 MACH 에 없음) — 엑셀 · 우리 둘 다 같은 함수를 쓰므로 여기서 한 번 더함 ──
import fp_common as _F # noqa: E402
for _k in ("믹서", "분무기"):
if not any(k == _k for k, _ in _F.MACH):
_F.MACH.append((_k, (_k,)))
# ── 엔진 셈 캐시(명령 사이 이어 씀) ─────────────────────────────────────
import pickle # noqa: E402
CACHE = HERE / "run_cache.pkl"
def load_cache():
if CACHE.exists():
_RUN.update(pickle.loads(CACHE.read_bytes()))
return len(_RUN)
def save_cache():
CACHE.write_bytes(pickle.dumps(_RUN))
def plan(code, ex=None):
"""(조합들, 어떻게, 움직이는 칸, 안 닿는 칸, 값 묶음).
곱이 CAP 이하 = 모두 돌림 → 안 닿는 칸(어떤 자리에서도 품 서명이 안 바뀜) · 수 칸의 같은 값 묶음(같은 표 줄)을 끝에 셈.
곱이 넘침 = 기본값 · 마지막 값 두 기준점에서 하나씩 바꿔 안 닿는 칸을 기본값으로 접고 · 같은 품 수 값을 한 값으로 · 남은 칸을 좁힘."""
sp = space(code, ex)
size = math.prod(len(o) for _, o, _ in sp) if sp else 1
if size <= CAP:
names = [n for n, _, _ in sp]
cb = [dict(zip(names, vals)) for vals in itertools.product(*[o for _, o, _ in sp])]
sg = [sig(run(code, c)) for c in cb]
active, inert, cls = [], [], {}
for n, o, k in sp:
if len(o) < 2:
continue
ctx = {}
for c, s_ in zip(cb, sg):
key = json.dumps({x: str(y) for x, y in c.items() if x != n}, sort_keys=True, ensure_ascii=False)
ctx.setdefault(key, {})[str(c[n])] = s_
keys = sorted(ctx)
prof = {str(v): tuple(ctx[g].get(str(v)) for g in keys) for v in o}
if len(set(prof.values())) == 1:
inert.append(n)
else:
active.append(n)
if k != "고르기":
ids = {}
cls[n] = {v: ids.setdefault(pf, len(ids)) for v, pf in prof.items()}
return cb, "모두", active, inert, cls
base = {n: o[0] for n, o, _ in sp}
alt = {n: o[-1] for n, o, _ in sp}
active, inert = [], []
for n, o, k in sp:
if len(o) == 1:
continue
s0 = {sig(run(code, {**base, n: v})) for v in o}
s1 = {sig(run(code, {**alt, n: v})) for v in o} if len(s0) == 1 else s0
if len(s0) > 1 or len(s1) > 1:
if k != "고르기": # 같은 표 줄에 드는 수(0 · 35 …)는 한 값으로 — 두 기준점 품이 같으면 같은 값
keep, seen = [], set()
for v in o:
key = (sig(run(code, {**base, n: v})), sig(run(code, {**alt, n: v})))
if key not in seen:
seen.add(key)
keep.append(v)
o = keep
active.append((n, o, k))
else:
inert.append((n, o, k))
cb, how = combos(active, ex)
cb = [{**base, **c} for c in cb]
return cb, how, [n for n, _, _ in active], [n for n, _, _ in inert], {}
def vary(hits, act, cls):
"""맞은 조합들 사이에서 값이 갈리는 칸 — 수 칸은 같은 값 묶음(같은 표 줄)이면 한 값."""
out = []
for n in act:
vals = {cls.get(n, {}).get(str(g.get(n)), str(g.get(n))) for g in hits}
if len(vals) > 1:
out.append(n)
return out
def add(vs):
"""복합 — 몫 벡터 합."""
vs = [v for v in vs if v is not None]
if not vs:
return None
out = {"L": {}, "X": [], "M": {}, "S": {}, "mix": {}}
for v in vs:
for k in ("L", "M", "S"):
for a, q in v[k].items():
out[k][a] = out[k].get(a, 0.0) + q
out["X"] += v["X"]
return out
def gap(e, v, base=False):
"""닮음 거리(작을수록 가까움) — 직종 묶음 어긋남 1 · 품은 직종마다 |ln 비| 평균 · 장비 시간 같은 갈래끼리."""
if v is None:
return 99.0
E = {k: (x["밑품"] if base else x["품"]) for k, x in e["인력"].items() if x.get("품")}
O_ = {k: q for k, q in v["L"].items() if q and q > 0}
d = 0.0
if E or O_:
keys = set(E) | set(O_)
d += len(set(E) ^ set(O_)) / len(keys)
both = [min(1.0, abs(math.log(E[k] / O_[k]))) for k in set(E) & set(O_)]
d += (sum(both) / len(both)) if both else 1.0
EX = [m for m in e["장비"] if m.get("시간")]
OX = [(set(a), q) for a, _, q in v["X"] if q]
if EX and OX:
hs = []
for m in EX:
mine = [q for a, q in OX if a & set(m["갈래"])]
h = m["밑시간"] if base and m.get("밑시간") else m["시간"]
hs.append(min(1.0, min(abs(math.log(h / q)) for q in mine)) if mine else 1.0)
d += 0.5 * sum(hs) / len(hs)
return d
def csig(e, v, nd=4):
"""엑셀이 보이는 몫만의 품 서명 — 직종 전부 · 엑셀 갈래와 겹치는 장비 시간 · 엑셀 이름과 겹치는 자재 수량."""
if v is None:
return None
r = lambda x: float(f"{x:.{nd}g}") # noqa: E731
et = set().union(*[set(m["갈래"]) for m in e["장비"]]) if e["장비"] else set()
em = [mat_words(m["이름"]) for m in e["자재"] if m.get("품")]
def named(k):
w = mat_words(k)
return any(ws & w or any(a in b or b in a for a in ws for b in w) for ws in em)
return json.dumps(
[
sorted((k, r(q)) for k, q in v["L"].items() if q),
sorted((tuple(a), r(q)) for a, _, q in v["X"] if q and set(a) & et),
sorted((k, r(q)) for k, q in v["M"].items() if q and named(k)),
],
ensure_ascii=False,
)
def strong(e, c):
"""딱 맞음(강) — 핵심 맞음 + 엑셀 몫을 다 덮음: 장비 갈래가 양쪽 같음(엑셀만 · 우리만 장비 없음) ·
자재 다름 없음 · 「보통인부 하나뿐 · 장비 없음」 줄 아님(같은 품 로직이 수십이라 우연 맞음)."""
if not c or not c.get("핵심"):
return False
if c.get("장비") in ("엑셀만", "우리만", "갈래 안 겹침") or any(("엑셀만 장비" in d or "우리만 장비" in d) for d in c.get("덧") or []):
return False
if c.get("자재") == "다름":
return False
E = [k for k, x in e["인력"].items() if x.get("품")]
if E == ["보통인부"] and not any(m.get("시간") for m in e["장비"]):
return False
return True
@@ -0,0 +1,123 @@
"""K5 ① 대상 줄 — 정답 로직이 알려진 엑셀 줄의 지문(C4 꼴: 인력 품 · 밑품 · 장비 시간 · 자재 수량 · 계수 · 조건 · 글).
사람 정답 126(C1 labels · labels_y — 지문은 C4 excel_fp 그대로) +
새 파일 51(C7 wide — 자동 정답 20 · 씨앗 짝 43 · 지문은 C6 STmate 블록 「자원」 L · X · M · S 와 items 품 ·
거창은 C4 `north` 식 풀기로 블록 글을 풂).
python k5_s1_targets.py → targets.json
"""
import json
import re
from collections import Counter
import k5_lib as K
from fp_common import conds, mtypes, specnums
import s1_excel_fp as X1 # C4 엑셀 지문(식 풀기 · 북부청 꼴)
from labels import L
from labels_y import LY
SP, C4 = K.SP, K.C4
EX = {e["id"]: e for e in json.loads((C4 / "excel_fp.json").read_text(encoding="utf-8"))}
WIDE = json.loads((SP / "req226/C7/wide.json").read_text(encoding="utf-8"))
ITEMS = {r["id"]: r for r in json.loads((SP / "req226/C6/items.json").read_text(encoding="utf-8"))}
DATA = {"봉화": "봉화", "울진대흥": "대흥설계", "울진소광": "소광", "영덕계류": "계류보전", "동부청영월": "영월", "거창": "거창"}
COMPOSITE = {
**{i: ["GC000206", "GC000207"] for i in ("N003", "N019", "N045", "S055", "S059", "Y010")},
"N050": ["GC000705", "GC000714", "GC000712"],
}
_D = {}
def data(f):
if f not in _D:
_D[f] = json.loads((SP / f"req226/C6/data_{DATA[f]}.json").read_text(encoding="utf-8"))
return _D[f]
def stmate(r):
d = data(r["파일"])
b = d["블록"].get(r["코드"]) or {}
fp = X1.empty({**r, "근거": r["근거"]})
fp["글"] = [str(t) for t in b.get("글") or []][:60]
fp["조건"] = {k: sorted(v) for k, v in conds(" ".join([r["이름"], r["규격"]] + fp["글"][:4])).items()}
fp["이름조건"] = {k: sorted(v) for k, v in conds(f"{r['이름']} {r['규격']}").items()}
fp["옛판"] = bool(X1._OLD.search(" ".join(fp["글"][:4])))
for t in fp["글"]:
for k, v in X1._COEF.findall(t):
fp["계수"].setdefault(k, float(v))
rates = X1.rates_in(f"{r['이름']} {r['규격']}")
lab_rate = min(rates) if len(rates) >= 2 else None
mach_rate = max(rates) if len(rates) >= 2 else None
for t, q in (r.get("품") or {}).items():
fp["인력"][t] = {"품": q, "밑품": q / lab_rate if lab_rate else q, "율": [[lab_rate, "섞음"]] if lab_rate else [], "어떻게": ["QTY"]}
seen = Counter()
for x in b.get("자원") or []:
kind, nm, sp, q = x.get("갈래"), x.get("이름") or "", x.get("규격") or "", x.get("품")
if not q:
continue
key = (x.get("코드"), round(q, 9))
seen[key] += 1
if seen[key] > 1: # 노무비 · 재료비 · 경비 세 줄이 같은 품 — 한 번만
continue
ts = mtypes(f"{nm} {sp}")
if kind == "X" or (kind == "S" and ts):
fp["장비"].append({"갈래": sorted(ts), "규격": sorted(specnums(sp) | specnums(nm)), "시간": q,
"밑시간": q / mach_rate if mach_rate else q, "율": [], "절": "0", "글": f"{nm} {sp}"}) # fmt: skip
elif kind in ("M", "S"):
fp["자재"].append({"이름": f"{nm} {sp}".strip(), "품": q, "밑품": q})
return fp
def geochang(r):
b = data("거창")["블록"].get(r["코드"]) or {}
item = {**r, "단산글": [b.get("머리") or r["이름"]] + [str(t) for t in b.get("글") or []]}
return X1.north(item)
def answers(i):
if i in L or i in LY:
lab = {**L, **LY}[i]
return lab["정답"], lab.get("허용", []), "사람"
got = []
for d in (WIDE["자동"], WIDE["씨앗"]):
for k in d.get(i) or []:
if k not in got:
got.append(k)
kind = "자동+씨앗" if i in WIDE["자동"] and i in WIDE["씨앗"] else "자동" if i in WIDE["자동"] else "씨앗"
return got, [], kind
def main():
out = []
for i, e in EX.items():
ans, allow, kind = answers(i)
if not ans:
continue
out.append({**e, "정답": ans, "허용": allow, "무리": kind, "복합": COMPOSITE.get(i)})
for i in WIDE["줄"]:
if i in EX:
continue
r = ITEMS[i]
fp = geochang(r) if r["파일"] == "거창" else stmate(r)
ans, allow, kind = answers(i)
comp = ans if set(ans) == {"GC000206", "GC000207"} else None
out.append({**fp, "id": i, "파일": r["파일"], "정답": ans, "허용": allow, "무리": kind, "복합": comp, "줄수": r.get("줄수")})
for e in out:
for one in e["인력"].values():
one["품"] = round(one["품"], 6)
one["밑품"] = round(one["밑품"], 6)
(K.HERE / "targets.json").write_text(json.dumps(out, ensure_ascii=False, indent=0, default=str), "utf-8")
c = Counter(e["무리"] for e in out)
print("대상", len(out), dict(c))
print("인력 있음", sum(1 for e in out if e["인력"]), "· 장비 시간", sum(1 for e in out if any(m.get("시간") for m in e["장비"])),
"· 자재 수량", sum(1 for e in out if any(m.get("품") for m in e["자재"])), "· 복합", sum(1 for e in out if e["복합"])) # fmt: skip
for e in out:
if e["무리"] != "사람":
print(" ", e["id"], e["이름"], e["규격"], "| 인력", {k: v["품"] for k, v in e["인력"].items()},
"| 장비", [(m["갈래"], round(m["시간"], 4)) for m in e["장비"] if m.get("시간")],
"| 자재", [(m["이름"][:14], round(m["품"], 4)) for m in e["자재"] if m.get("품")][:3], "| 정답", e["정답"]) # fmt: skip
if __name__ == "__main__":
main()
@@ -0,0 +1,302 @@
"""K5 ② 옵션 거꾸로 찾기 — 정답 로직(복합은 몫 합)의 옵션 조합을 모두(넘치면 엑셀 조건 낱말로 좁혀) 엔진으로 셈 →
엑셀 품(직종 · 장비 시간 · 자재 수량)과 같은 조합: 하나만 / 여럿(옵션 못 가름) / 없음 / 엔진 못 셈 · 허용 0.5% · 3%.
python k5_s2_answer.py <시작> <끝> → ans_<시작>.json (캐시 run_cache.pkl 이어 씀)
python k5_s2_answer.py merge → answer.json
"""
import itertools
import json
import math
import sys
import time
from decimal import Decimal
import k5_lib as K
from fp_common import specnums
TOLS = (0.005, 0.03)
MODES = (("0.005", 0.005, False), ("R", 0.005, True), ("0.03", 0.03, False)) # R = 0.5% + 엑셀 자릿수 반올림 허용
RANK = {"하나만": 0, "여럿(같은 품)": 1, "여럿(다른 품)": 2, "없음": 3, "못 셈": 4}
def ex_of(e):
texts = [e["이름"], e.get("규격") or ""] + [str(t) for t in e.get("글") or []]
sp = set()
for m in e["장비"]:
sp |= set(m.get("규격") or [])
sp |= specnums(f"{e['이름']} {e.get('규격') or ''}")
return {
"글": texts,
"글머리": texts[:8],
"계수": e.get("계수") or {},
"규격수": sp,
"조건": e.get("조건") or {},
"품값": {Decimal(str(x["품"])) for x in e["인력"].values() if x.get("품")},
}
def plans(codes, ex):
"""몫마다 (조합, 어떻게, 움직임, 안 닿음) → 복합이면 곱(넘치면 몫마다 하나씩)."""
ps = {k: K.plan(k, ex) for k in codes}
if len(codes) == 1:
k = codes[0]
cb, how, act, ine, cls = ps[k]
return [({k: c}) for c in cb], how, {k: act}, {k: ine}, {k: cls}
pools = [[(k, c) for c in ps[k][0]] for k in codes]
size = 1
for p in pools:
size *= len(p)
if size <= 4000:
out = [dict(x) for x in itertools.product(*pools)]
how = "모두"
else: # 몫마다 하나씩(나머지 몫은 첫 조합)
first = {k: ps[k][0][0] for k in codes}
out = [dict(first)] + [{**first, k: c} for k in codes for c in ps[k][0][1:]]
how = "하나씩"
return out, how, {k: ps[k][2] for k in codes}, {k: ps[k][3] for k in codes}, {k: ps[k][4] for k in codes}
def vec(given):
vs = [K.run(k, c) for k, c in given.items()]
if any(v is None for v in vs):
return None
return vs[0] if len(vs) == 1 else K.add(vs)
def judge(e, codes):
ex = ex_of(e)
cbs, how, act, ine, cls = plans(codes, ex)
rows = []
for g in cbs:
v = vec(g)
rows.append((g, v, K.sig(v)))
ran = [r for r in rows if r[1] is not None and (r[1]["L"] or r[1]["X"] or r[1]["M"])]
out = {"로직": codes, "조합": len(cbs), "어떻게": how, "움직임": act, "안닿음": ine, "돈 조합": len(ran), "못 돎": sum(1 for r in rows if r[1] is None)}
if not ran:
out["판정"] = {m: "못 셈" for m, _, _ in MODES}
return out
for mk, t, rnd in MODES:
hit = [(g, v, s) for g, v, s in ran if (K.compare(e, v, t, rnd=rnd) or {}).get("핵심")]
lab = [(g, v, s) for g, v, s in ran if (K.compare(e, v, t, rnd=rnd) or {}).get("직종만")]
base = [(g, v, s) for g, v, s in ran if (K.compare(e, v, t, base=True, rnd=rnd) or {}).get("핵심")]
sigs = {K.csig(e, v) for _, v, _ in hit}
vary = sorted({f"{k}.{n}" for k in codes for n in K.vary([g[k] for g, _, _ in hit], act[k], cls[k])})
if not hit:
verdict = "없음"
elif len(sigs) > 1:
verdict = "여럿(다른 품)"
elif vary:
verdict = "여럿(같은 품)"
else:
verdict = "하나만"
out.setdefault("판정", {})[mk] = verdict
out[f"맞음{mk}"] = {
"조합": len(hit),
"서명": len(sigs),
"전체서명": len({s for _, _, s in hit}),
"갈리는 칸": vary,
"직종만 맞음": len(lab),
"밑품 맞음": len(base),
"보기": [{k: {n: str(x) for n, x in c.items() if n in act[k]} for k, c in g.items()} for g, _, _ in hit[:3]],
"기본값 맞음": any(all(all(str(c.get(n)) == str(ex_k) for n, ex_k in _defaults(k).items() if n in act[k]) for k, c in g.items()) for g, _, _ in hit),
"덧": sorted({d for _, v, _ in hit[:20] for d in (K.compare(e, v, t, rnd=rnd) or {}).get("덧", [])}),
}
if len(codes) == 1: # 지금 M02 규격 읽기(spec_inputs)로 낸 입력값 — 같은 품이 나오나
k = codes[0]
try:
vals, unread = K.O.hm.spec_inputs({"이름": e["이름"], "규격": e.get("규격"), "단위": e.get("단위")}, K.O.hm._logic_specs(k))
except Exception: # noqa: BLE001
vals, unread = {}, ["(읽기 오류)"]
v = K.run(k, {**_defaults(k), **vals})
out["지금 읽기"] = {"입력": {n: str(x) for n, x in vals.items()}, "못읽음": unread,
**{m: bool((K.compare(e, v, t, rnd=rnd) or {}).get("핵심")) for m, t, rnd in MODES}} # fmt: skip
if not out["맞음0.03"]["조합"] and not out["맞음R"]["조합"]:
out["풀이"] = explain(e, ran, codes, act)
best = min(ran, key=lambda r: K.gap(e, r[1]))
bb = min(ran, key=lambda r: K.gap(e, r[1], base=True))
out["가장 가까움"] = {"입력": {k: {n: str(x) for n, x in c.items() if n in act[k]} for k, c in best[0].items()}, "거리": round(K.gap(e, best[1]), 4),
"우리": _show(best[1]), "견줌3": K.compare(e, best[1], 0.03), "밑품거리": round(K.gap(e, bb[1], base=True), 4),
"밑품견줌3": K.compare(e, bb[1], 0.03, base=True), "밑품우리": _show(bb[1])} # fmt: skip
return out
def _scale(v, kl, kx=None):
return {**v, "L": {k: q * kl for k, q in v["L"].items()}, "X": [(a, b, q * (kx if kx is not None else kl)) for a, b, q in v["X"]]}
def _mean(vs, harmonic=False):
"""같은 꼴(직종 묶음 · 장비 수 같음) 벡터들의 평균 — 조화 평균은 일 시공량 평균(1 / 평균(1 / 품))."""
if len({tuple(sorted(v["L"])) for v in vs}) != 1 or len({len(v["X"]) for v in vs}) != 1:
return None
f = (lambda xs: len(xs) / sum(1 / x for x in xs) if all(xs) else 0.0) if harmonic else (lambda xs: sum(xs) / len(xs)) # noqa: E731
L = {k: f([v["L"][k] for v in vs]) for k in vs[0]["L"]}
X = [(a, b, f([v["X"][i][2] for v in vs])) for i, (a, b, _) in enumerate(vs[0]["X"])]
return {"L": L, "X": X, "M": vs[0]["M"], "S": vs[0]["S"], "mix": {}}
def explain(e, ran, codes, act, t=0.03):
"""「없음」 풀이 — 섞기 전(우리 섞음 비율 풂) · 한 배수 · 옵션 둘 합 · 옵션 평균(산술 · 조화)."""
got = {}
for g, v, _ in ran: # 우리 로직의 섞음(인력비율 · 기계비율)을 풀면 같나
ri, rm = (v.get("mix") or {}).get("인력비율"), (v.get("mix") or {}).get("기계비율")
if ri and 0 < ri < 100:
u = _scale(v, 100 / ri, (100 / rm) if rm and 0 < rm < 100 else 1.0)
if (K.compare(e, u, t) or {}).get("직종만"):
got["섞기 전 맞음"] = f"인력 {ri:g}% → 100%"
break
got.update(ratio(e, ran, act, t))
pool = sorted(ran, key=lambda r: K.gap(e, r[1]))[:120]
for tt in (0.005, t): # 0.5% 에서 맞으면 그 값(우연 맞음 덜함) · 아니면 3%
for (g1, v1, _), (g2, v2, _) in itertools.combinations(pool, 2):
if (K.compare(e, K.add([v1, v2]), tt) or {}).get("직종만"):
got["옵션 둘 합"] = {"값": [_opt(g1, act), _opt(g2, act)], "허용": tt}
break
if "옵션 둘 합" in got:
break
for tt in (0.005, t):
_avg(e, ran, codes, act, tt, got)
if "옵션 평균" in got:
got["옵션 평균"]["허용"] = tt
break
return got
def _avg(e, ran, codes, act, t, got):
if len(codes) == 1:
k0 = codes[0]
for n in act[k0]:
groups = {}
for g, v, _ in ran:
rest = json.dumps({x: str(y) for x, y in g[k0].items() if x != n}, sort_keys=True, ensure_ascii=False)
groups.setdefault(rest, []).append((g, v))
for members in groups.values():
for size in (2, 3, 4):
for sub in itertools.combinations(members, size):
for hm_ in (False, True):
m = _mean([v for _, v in sub], hm_)
if m and (K.compare(e, m, t) or {}).get("직종만"):
got["옵션 평균"] = {"칸": n, "값": [str(g[k0].get(n)) for g, _ in sub], "꼴": "조화(일 시공량 평균)" if hm_ else "산술"}
break
if "옵션 평균" in got:
break
if "옵션 평균" in got:
break
if "옵션 평균" in got:
break
if "옵션 평균" in got:
break
def _ratios(e, v):
"""엑셀/우리 비 — 직종(묶음 같을 때) · 같은 갈래 장비 시간. 직종 묶음이 다르면 None."""
E = {k: x["품"] for k, x in e["인력"].items() if x.get("품")}
O_ = {k: q for k, q in v["L"].items() if q}
if E and set(E) != set(O_):
return None
rs = [E[k] / O_[k] for k in E]
for m in e["장비"]:
if not m.get("시간"):
continue
mine = [q for a, _, q in v["X"] if q and set(a) & set(m["갈래"])]
if mine:
rs.append(m["시간"] / mine[0])
return rs or None
def ratio(e, ran, act, t=0.03):
"""한 배수 — 모든 몫(직종 · 장비 시간)이 같은 배수 k(3% 안 · k ≠ 1)인 조합.
k 고르기: 엑셀 글의 수 · 율로 설명되는 k(÷6 · ×1.2 · ×0.1 …) → 몫 수 많은 것 → 1 에 가까운 것."""
import re
import unicodedata
text = unicodedata.normalize("NFKC", " ".join([e["이름"], e.get("규격") or ""] + [str(x) for x in e.get("글") or []]))
divs = {float(n) for n in re.findall(r"[÷/]\s*\(?\s*(\d+(?:\.\d+)?)", text) if float(n) > 0}
muls = {float(n) for n in re.findall(r"[×*x]\s*(\d+(?:\.\d+)?)(?!\s*[,\d])", text) if 0 < float(n) < 50}
pcts = {float(n) / 100 for n in re.findall(r"(\d+(?:\.\d+)?)\s*%", text) if 0 < float(n) < 100}
rates = {float(r[0]) for x in e["인력"].values() for r in x.get("율") or [] if r and r[0]} | pcts
cands = []
for g, v, _ in ran:
rs = _ratios(e, v)
if not rs or max(rs) / min(rs) > 1 + t:
continue
k = sum(rs) / len(rs)
if abs(k - 1) <= t:
continue
why = next((f"÷ {n:g}" for n in divs if abs(k * n - 1) <= 0.01), None)
why = why or next((f"× {n:g}" for n in muls if abs(k - n) <= 0.01 * max(k, n)), None)
why = why or next((f"율 {r:g}" for r in rates if abs(k - r) <= 0.01 * max(k, r) or abs(k * r - 1) <= 0.01), None)
cands.append((why is not None, len(rs), -abs(math.log(k)), k, why, g))
if not cands:
return {}
cands.sort(key=lambda c: c[:3], reverse=True)
ok, n, _, k, why, g = cands[0]
return {"한 배수": round(k, 4), "몫 수": n, "배수 까닭": why, "배수 입력": _opt(g, act)}
def _opt(g, act):
return {k: {n: str(x) for n, x in c.items() if n in act[k]} for k, c in g.items()}
_DEF = {}
def _defaults(k):
if k not in _DEF:
sp = K.space(k)
_DEF[k] = {n: o[0] for n, o, _ in sp}
return _DEF[k]
def _show(v):
if v is None:
return None
return {"L": {k: round(q, 5) for k, q in v["L"].items() if q}, "X": [[a, round(q, 5)] for a, _, q in v["X"] if q], "M": {k: round(q, 5) for k, q in v["M"].items() if q}}
def line(e):
alts = [e["복합"]] if e.get("복합") else [[k] for k in e["정답"]]
got = []
for codes in alts:
if not all(K.WHOLE.logic(k) for k in codes):
got.append({"로직": codes, "판정": {m: "못 셈" for m, _, _ in MODES}, "까닭": "로직 없음"})
continue
got.append(judge(e, codes))
best = {m: min(got, key=lambda g: RANK[g["판정"][m]]) for m, _, _ in MODES}
allow = {}
for k in e.get("허용") or []:
if K.WHOLE.logic(k):
j = judge(e, [k])
allow[k] = j["판정"]
return {"id": e["id"], "무리": e["무리"], "이름": e["이름"], "규격": e.get("규격"), "단위": e.get("단위"), "정답": e["정답"], "갈래": got,
"판정": {t: best[t]["판정"][t] for t in best}, "판정로직": {t: best[t]["로직"] for t in best}, "허용판정": allow,
"엑셀": {"L": {k: x["품"] for k, x in e["인력"].items()}, "Lb": {k: x["밑품"] for k, x in e["인력"].items()},
"X": [[m["갈래"], m.get("시간"), m.get("밑시간")] for m in e["장비"] if m.get("시간")], "M": [[m["이름"], m["품"]] for m in e["자재"] if m.get("품")],
"율": [r for x in e["인력"].values() for r in x.get("율") or []], "옛판": e.get("옛판"), "출처": e.get("출처")}} # fmt: skip
def main():
T = json.loads((K.HERE / "targets.json").read_text(encoding="utf-8"))
if sys.argv[1:] == ["merge"]:
out = []
for p in sorted(K.HERE.glob("ans_*.json"), key=lambda p: int(p.stem.split("_")[1])):
out += json.loads(p.read_text(encoding="utf-8"))
(K.HERE / "answer.json").write_text(json.dumps(out, ensure_ascii=False, indent=0, default=str), "utf-8")
print("합", len(out))
return
a, b = int(sys.argv[1]), int(sys.argv[2])
n0 = K.load_cache()
t0 = time.time()
out = []
for e in T[a:b]:
out.append(line(e))
if time.time() - t0 > 70: # 명령 하나 120 초 안 — 남은 줄은 다음 명령
break
K.save_cache()
(K.HERE / f"ans_{a}.json").write_text(json.dumps(out, ensure_ascii=False, indent=0, default=str), "utf-8")
print("끝", a, a + len(out), "캐시", n0, "→", len(K._RUN), f"{time.time() - t0:.1f}s")
for r in out:
print(" ", r["id"], r["이름"][:16], r["판정"], r["판정로직"]["0.03"], [(g["조합"], g["어떻게"]) for g in r["갈래"]])
if __name__ == "__main__":
main()
@@ -0,0 +1,236 @@
"""K5 ④ 할 일 1 · 2 정리 — 옵션 거꾸로 찾기 표 · 「없음」 나눔.
python k5_s4_sum12.py → 표 찍음 · sum12.json
"""
import json
import re
import unicodedata
from collections import Counter, defaultdict
import k5_lib as K
A = json.loads((K.HERE / "answer.json").read_text(encoding="utf-8"))
T = {e["id"]: e for e in json.loads((K.HERE / "targets.json").read_text(encoding="utf-8"))}
UNIT = {x["로직"]: x.get("단위") for x in K.O.hm.targets()["로직"]}
V = ("하나만", "여럿(같은 품)", "여럿(다른 품)", "없음", "못 셈")
import s10_all as C1 # noqa: E402 (사람 정답 줄의 엑셀 줄 수)
ROWS = {i: C1.BY[i].get("줄수") or 1 for i in C1.BY}
def rows_of(i):
return ROWS.get(i) or T[i].get("줄수") or 1
def split_same(r, t):
"""여럿(같은 품) 나눔 — 이름만 다름(모든 몫 같음) / 엑셀에 안 보이는 몫(폼타이 · 기계 규격)이 갈림."""
g = pick(r, t)
m = g.get(f"맞음{t}") or {}
return "이름만 다름" if m.get("전체서명", 1) <= 1 else "엑셀에 안 보이는 몫 갈림"
COUNT = {"개소", "개", "본", "ea", "조", "매", "대", "기", "공"}
HOUR = {"hr", "시간", "h"}
def unit_fit(a, b):
if K.O.hm.fits(a, b):
return True
na = re.sub(r"[\s/]", "", str(a or "")).lower()
for nb in [re.sub(r"[\s/]", "", x).lower() for x in str(b or "").split("·")]:
if na == nb or (na in COUNT and nb in COUNT) or (na in HOUR and nb in HOUR):
return True
return False
def group(r):
if r["무리"] == "사람":
return {"N": "사람 · 북부청 임도", "S": "사람 · 북부청 사방", "Y": "사람 · 동부청 영월"}[r["id"][0]]
return "새 파일 · " + r["무리"]
def pick(r, t="0.03"):
return next(g for g in r["갈래"] if g["로직"] == r["판정로직"][t])
def flat(e):
return re.sub(r"\s+", "", unicodedata.normalize("NFKC", " ".join([e["이름"], e.get("규격") or ""] + [str(x) for x in e.get("글") or []])))
def plausible(e, found):
"""옵션 합 · 평균이 엑셀 글로 받쳐지나 — 갈리는 칸 값의 한글 낱말(두 자+)이 글에 있음."""
text = flat(e)
if "칸" in found: # 평균
vals = found["값"]
else: # 합 — 두 조합에서 다른 값
a, b = found["값"]
vals = []
for k in a:
for n in a[k]:
if a[k][n] != b[k].get(n):
vals += [a[k][n], b[k][n]]
if not vals:
return False
for v in vals:
ws = re.findall(r"[가-힣]{2,}", unicodedata.normalize("NFKC", str(v)))
if not ws or not any(w in text for w in ws):
return False
return True
# 원문(2026 건설 · 2025 산림) 대조로 확인한 줄 — 까닭 · 근거(어느 쪽이 원문과 같나)
CHECKED = {
"U012": ("옛 판 값", "건설 6-2-3 조립 Ⅱ-2 시공량 2026 = 1.1 ton(우리) · 엑셀 1.0 ton 옛 값"),
"Y014": ("옛 판 값", "건설 7-2-2 찰붙임('26 보완) 35cm이하 30㎡/일 = 우리 · 엑셀 석공 0.05 는 옛 값"),
"C016": ("옛 판 값", "건설 2-6-1 동바리('26 보완) 2.5m이하 50공㎥ = 우리 0.06(간격 0.8m 초과 0.054) · 엑셀 0.05 = 옛 60공㎥"),
"B031": ("옛 판 값", "건설 2-6-1 동바리('26 보완) = 우리 · 엑셀 0.054 · 0.021 은 2024 파일 옛 값(2-2-4 손율 인용)"),
"B032": ("옛 판 값", "건설 2-7-1 비계('26 보완) 10m이하 55㎡ = 우리 · 엑셀 0.05 · 0.02 옛 값"),
"N032": ("옛 판 값", "건설 3-5-1 고르기('25 보완) 연질토 250㎡ = 우리 0.032 · 엑셀 0.0276 = 옛 290㎡"),
"N040": ("할증 · 횟수 붙임", "건설 1-6-2 A-Type 20㎝ 100㎥(우리 그대로) × 1.2 = 산림 12-6 ⑧ 종단경사 20% 할증을 건설 로직에 붙임"),
"S072": ("단위 환산", "건설 6-7-2 원문 본당(우리 그대로 · 관경 1000) ÷ 6 m/본 = 엑셀 m 당"),
"S083": ("단위 환산", "건설 6-7-2 본당(관경 500) ÷ 6 m/본"),
"Y115": ("단위 환산", "건설 6-7-2 본당(관경 800) ÷ 6 m/본 · 크레인 0.25/6 을 0.04 로 반올림"),
"N017": ("직종 다름(더함 · 이름)", "건설 6-3-10 다웰바 형틀목공 0.043 · 보통인부 0.009 = 우리 · 엑셀은 철근 가공(6-2) 철근공을 더함"),
"N015": ("자재만 다름", "건설 6-3-4 품 0.07 · 0.03 = 우리 · 엑셀이 문양판 수량에 1/0.72 를 붙임(원문엔 없음)"),
"C018": ("옵션을 둘 넘게 섞음(합 · 평균)", "지주 + 표지판 두 옵션 합이 0.5% 안 — 숫자가 북부청 N051 과 같음(글에 「지주」 낱말만 없음)"),
"N039": ("묶음(다른 일을 한 줄에)", "잔디붙임(건설 4-1-1) 조경공 0.0067 그대로 + 떼꽂이(산림 5-23-3) 보통인부를 더함"),
"S079": ("쪼갬(우리 한 로직을 두 줄로)", "원형돌망태(산림 13-11-1) 한 로직을 조립설치 · 채움 두 줄로 나눔"),
"S080": ("쪼갬(우리 한 로직을 두 줄로)", "원형돌망태(산림 13-11-1) 한 로직을 조립설치 · 채움 두 줄로 나눔"),
"N020": ("할증 · 횟수 붙임", "산림 13-2-3 큰돌 채집 × 품할증 0.75 × 발생석 50% · 개 → ㎡(4 개/㎡)"),
"S094": ("할증 · 횟수 붙임", "산림 13-2-3 큰돌 채집 × 품할증 0.75 · 개 → ㎡"),
"U015": ("다른 책 로직 숫자와 같음", "건설 1-6-2 B-Type 20㎝ 4/50 · 2/50 = 0.08 · 0.04 × 두께 0.2 = 0.016 · 0.008 — 씨앗은 산림 GF000162"),
"Y027": ("옛 판 값", "건설 6-7-1 플륨관('25 보완) 원문은 굴착기 편성 = 우리 · 엑셀(2024)은 크레인 · 다른 시공량(옛 값 추정)"),
"B026": ("옛 판 값", "건설 4-1-1 잔디붙임('24 보완) 평떼 150㎡ = 우리 0.0267 · 엑셀(2024) 0.0291 옛 값"),
"B010": ("옛 판 값", "산림 13-3-2(2025) 75mm 미만 0.019 · 굴착기 0.076 = 우리 · 엑셀(2024) 0.018 · 0.07 옛 값"),
"B017": ("옛 판 값", "건설 6-1-2 기계비빔 무근 0.15 · 0.46 = 우리(GF000156 같은 숫자) · 엑셀(2024) 0.03 · 0.31 옛 값(추정)"),
"Y028": ("옛 판 값", "건설 6-1-1('24 보완) 무근 장비사용 63㎥ = 우리 · 엑셀(2024) 0.09 · 0.02 · 굴착기 0.31 옛 값(추정)"),
"C013": ("옛 판 값", "건설 6-1-1('24 보완) 철근 장비사용 55㎥ = 우리 0.0545 · 엑셀 0.07 · 0.02 옛 값(추정)"),
}
OURC = K.O.hb.ours()
VERIFY_NOTE = "원문 확인"
def why_none(r):
"""「없음」(3% · R 모두) 한 줄의 까닭(차례대로) + 덧 꼬리표."""
e = T[r["id"]]
g = pick(r)
p = g.get("풀이") or {}
best = g.get("가장 가까움") or {}
c3 = best.get("견줌3") or {}
m3 = g.get("맞음0.03") or {}
rates = r["엑셀"]["율"]
tags = []
if c3.get("장비") == "엑셀만" or any("엑셀만 장비" in d for d in (c3.get("덧") or [])):
tags.append("엑셀이 장비 일을 더함")
if r["id"] in CHECKED:
cat, note = CHECKED[r["id"]]
return cat, tags + [VERIFY_NOTE], note
allow = [k for k, v in r["허용판정"].items() if v["0.03"] != "없음" or v.get("R") != "없음"]
if allow:
return "다른 책 로직 숫자와 같음", tags, f"허용 {allow[0]} 이 맞음"
if m3.get("밑품 맞음"):
notes = {n for _, n in rates}
kind = "섞음(엑셀이 인력 % · 기계 % 를 곱함)" if notes & {"섞음", "%"} else "할증 · 횟수 붙임"
return kind, tags, f"율 {rates[:2]}"
if "섞기 전 맞음" in p:
return "섞음 비율을 엑셀이 바꿈(우리 로직 섞음 고정)", tags, p["섞기 전 맞음"]
for key in ("옵션 평균", "옵션 둘 합"):
if key in p and plausible(e, p[key]):
return "옵션을 둘 넘게 섞음(합 · 평균)", tags, json.dumps(p[key], ensure_ascii=False)[:160]
E = r["엑셀"]["L"]
O_ = (best.get("우리") or {}).get("L") or {}
books = {b for b, _ in (r["엑셀"]["출처"] or [])}
ours = {(OURC.get(k) or {}).get("책") for k in g["로직"]}
if "한 배수" in p and p.get("배수 까닭") and (p.get("몫 수", 0) >= 2 or str(p["배수 까닭"]).startswith("율")):
code = g["로직"][0]
if not unit_fit(e.get("단위"), UNIT.get(code)) and str(p["배수 까닭"]).startswith("÷"):
return "단위 환산", tags, f"배수 {p['한 배수']} ({p['배수 까닭']}) · 단위 {e.get('단위')} ↔ {UNIT.get(code)} · {p.get('배수 입력')}"
return "할증 · 횟수 붙임", tags, f"배수 {p['한 배수']} ({p['배수 까닭']}) · {p.get('배수 입력')}"
if books and not (books & ours):
return "다른 책 숫자(짝 · 정답은 다른 책 로직)", tags, f"엑셀 인용 {sorted(books)} ↔ 우리 {sorted(ours)}"
if E and set(E) != set(O_):
return "직종 다름(더함 · 이름)", tags, f"엑셀 {sorted(E)} ↔ 우리 {sorted(O_)}"
if E and c3.get("직종") == "같음":
return "장비 · 자재만 다름", tags, f"장비 {c3.get('장비')} · 자재 {c3.get('자재')}"
if not E:
return "장비만 줄 — 시간 · 갈래 다름", tags, f"엑셀 {[(a, round(h, 4)) for a, h, _ in r['엑셀']['X'][:2]]} ↔ 우리 {(best.get('우리') or {}).get('X')}"
if r["엑셀"]["옛판"]:
return "옛 판 값", tags, f"엑셀 글에 옛 판 표시 · 출처 {r['엑셀']['출처']}"
if "엑셀이 장비 일을 더함" in tags:
return "묶음(다른 일을 한 줄에)", tags, f"엑셀 {E} ↔ 우리 {O_}"
return "계수 다름 — 까닭 못 가림", tags, f"엑셀 {E} ↔ 우리 {O_}"
def main():
groups = defaultdict(list)
for r in A:
groups[group(r)].append(r)
order = ["사람 · 북부청 임도", "사람 · 북부청 사방", "사람 · 동부청 영월", "새 파일 · 자동", "새 파일 · 자동+씨앗", "새 파일 · 씨앗"]
out = {"표1": {}, "없음": [], "반올림": [], "갈리는 칸": {}, "기본값": {}, "지금읽기": {}}
print("== 표 1 — 옵션 거꾸로 찾기(정답 로직 · 고유 줄)")
rows = [(n, groups[n]) for n in order] + [("사람 126", [r for r in A if r["무리"] == "사람"]), ("새 파일 51", [r for r in A if r["무리"] != "사람"]),
("모두 177", A)] # fmt: skip
for name, rs in rows:
line = {}
for t in ("0.005", "R", "0.03"):
c = Counter(r["판정"][t] for r in rs)
w = Counter()
for r in rs:
w[r["판정"][t]] += rows_of(r["id"])
line[t] = {v: c.get(v, 0) for v in V}
line[t + "줄무게"] = {v: w.get(v, 0) for v in V}
for t in ("0.005", "R", "0.03"):
line[t + "같은품나눔"] = dict(Counter(split_same(r, t) for r in rs if r["판정"][t] == "여럿(같은 품)"))
out["표1"][name] = {"n": len(rs), **line}
f = lambda d: " · ".join(f"{d[v]}" for v in V) # noqa: E731
wt = lambda t: f"{sum(line[t + '줄무게'][v] for v in V[:3])}/{sum(line[t + '줄무게'].values())}" # noqa: E731
print(f"{name:<18} n={len(rs):>3} | 0.5% {f(line['0.005'])} | R {f(line['R'])} | 3% {f(line['0.03'])} | 줄무게 맞음 {wt('0.005')} · {wt('R')} · {wt('0.03')} | 같은품 {line['R같은품나눔']}")
# 맞은 줄 — 기본값 그대로 맞음 · 지금 M02 규격 읽기로 맞음 · 옵션을 바꿔야 맞음
for t in ("0.005", "R", "0.03"):
ok = [r for r in A if r["판정"][t] in V[:3]]
d = Counter()
for r in ok:
g = pick(r, t)
m = g.get(f"맞음{t}") or {}
now = (g.get("지금 읽기") or {}).get(t)
d["기본값 그대로 맞음"] += bool(m.get("기본값 맞음"))
d["지금 M02 규격 읽기로 맞음"] += bool(now)
d["옵션을 바꿔야 맞음(기본값 아님)"] += not m.get("기본값 맞음")
d["옵션 바꿔야 + 지금 읽기 못 맞춤"] += (not m.get("기본값 맞음")) and not now
out["기본값"][t] = {"맞은 줄": len(ok), **d}
print(t, "맞은 줄", len(ok), dict(d))
# 여럿 — 갈리는 칸
for t in ("0.005", "R", "0.03"):
cnt = Counter()
for r in A:
if r["판정"][t].startswith("여럿"):
g = pick(r, t)
m = g.get(f"맞음{t}") or {}
for n in m.get("갈리는 칸") or []:
cnt[n.split(".", 1)[1]] += 1
out["갈리는 칸"][t] = dict(cnt.most_common())
print(t, "여럿 줄 갈리는 칸", dict(cnt.most_common(20)))
# 반올림 — 3% 맞음 · 0.5% 없음
for r in A:
if (r["판정"]["0.03"] in V[:3] or r["판정"]["R"] in V[:3]) and r["판정"]["0.005"] == "없음":
g = pick(r)
b = (g.get("가장 가까움") or {}).get("우리")
out["반올림"].append({"id": r["id"], "이름": r["이름"], "규격": r["규격"], "로직": g["로직"], "엑셀": r["엑셀"]["L"], "엑셀X": r["엑셀"]["X"][:2], "우리": b})
print("반올림(3% 만 맞음)", len(out["반올림"]))
for x in out["반올림"]:
print(" ", x["id"], x["이름"][:14], x["로직"], x["엑셀"], [(a, round(h, 4)) for a, h, _ in x["엑셀X"]], "| 우리", x["우리"]["L"] if x["우리"] else None, (x["우리"] or {}).get("X"))
# 없음 나눔
cats = Counter()
for r in A:
if r["판정"]["0.03"] != "없음" or r["판정"]["R"] != "없음":
continue
cat, tags, note = why_none(r)
cats[cat] += 1
out["없음"].append({"id": r["id"], "무리": r["무리"], "이름": r["이름"], "규격": r["규격"], "로직": pick(r)["로직"], "까닭": cat, "덧": tags, "보기": note})
print("== 없음(3%) 나눔", sum(cats.values()), dict(cats.most_common()))
for x in out["없음"]:
print(" ", x["id"], x["무리"][:2], x["이름"][:16], x["로직"], "|", x["까닭"], "|", x["보기"][:110], x["덧"])
out["없음셈"] = dict(cats)
(K.HERE / "sum12.json").write_text(json.dumps(out, ensure_ascii=False, indent=1, default=str), "utf-8")
if __name__ == "__main__":
main()
@@ -0,0 +1,121 @@
"""K5 ⑤ 맞춤 방법 흉내 — 정답을 모르는 채 (로직 + 옵션) 짝을 엑셀 품 닮음으로 줄 세움 · 금액 안 씀.
1 단계(거친 순위) = C4 지문 변형(`s3_match.P` · 1,438 로직)을 품 채널(인력 3 · 장비 2 · 자재 1 · 단위 0.5)로만 셈.
2 단계(옵션 찾기) = 1 단계 위 K 로직만 K5 옵션 공간(엑셀 조건 낱말 · 수로 좁힘 · CAP)을 엔진으로 돌려
0.5% · 반올림 허용(R) · 3% 「딱 맞음」과 거리.
C4 지문만(`score_pair` 기본 무게) 점수도 같이 냄 — 견줌 · 섞기용.
python k5_s5_rank.py <시작> <끝> → rank_<시작>.json · merge → rank.json
"""
import json
import sys
import time
import k5_lib as K
import k5_s2_answer as A2
import s3_match as S # C4 — P(우리 지문) · ex_prep · score_pair (같은 프로세스 · 읽기만)
TOPK = 15
STAGE_CAP = 400
QW = {"인력": 3.0, "장비": 2.0, "자재": 1.0, "단위": 0.5}
MODES = A2.MODES
def stage_b(e, code):
ex = A2.ex_of(e)
try:
sp = K.space(code, ex)
except Exception: # noqa: BLE001
return {"수준": 9, "거리": 99.0}
import math
if math.prod(len(o) for _, o, _ in sp) > 20000: # 너무 큰 공간은 좁힌 하나씩만
pass
old = K.CAP
K.CAP = STAGE_CAP
try:
cb, how, act, ine, cls = K.plan(code, ex)
finally:
K.CAP = old
best = {"수준": 9, "약수준": 9, "거리": 99.0}
hits = {m: [] for m, _, _ in MODES}
lab3 = 0
for g in cb:
v = K.run(code, g)
if v is None or not (v["L"] or v["X"] or v["M"]):
continue
d = K.gap(e, v)
lvl = weak = 9
for i, (m, t, rnd) in enumerate(MODES):
c = K.compare(e, v, t, rnd=rnd) or {}
if c.get("핵심"):
weak = min(weak, i)
if K.strong(e, c):
hits[m].append((g, K.csig(e, v)))
lvl = min(lvl, i)
lab3 += bool((K.compare(e, v, 0.03) or {}).get("직종만"))
if (lvl, weak, d) < (best["수준"], best["약수준"], best["거리"]):
best = {"수준": lvl, "약수준": weak, "거리": round(d, 4), "입력": {n: str(x) for n, x in g.items() if n in act},
"견줌": K.compare(e, v, 0.03), "직종같음": set(v["L"]) == {k for k, x in e["인력"].items() if x.get("품")} and bool(v["L"])} # fmt: skip
best["맞음수"] = {m: len(h) for m, h in hits.items()}
best["서명수"] = {m: len({s for _, s in h}) for m, h in hits.items()}
best["갈리는 칸"] = {m: K.vary([g for g, _ in h], act, cls) for m, h in hits.items() if h}
best["직종만3"] = lab3
best["조합"] = len(cb)
best["어떻게"] = how
return best
def line(e):
ep = S.ex_prep(e)
coarse, full = [], {}
for o in S.P:
s, ch, v = S.score_pair(ep, o, QW)
coarse.append((s, o["로직"]))
full[o["로직"]] = S.score_pair(ep, o)[0]
coarse.sort(key=lambda x: -x[0])
top = [k for _, k in coarse[:TOPK]]
fp_rank = sorted(full, key=lambda k: -full[k])
b = {k: stage_b(e, k) for k in top}
return {
"id": e["id"],
"무리": e["무리"],
"정답": e["정답"],
"허용": e.get("허용") or [],
"복합": e.get("복합"),
"거친": [[k, round(s, 3)] for s, k in coarse[:TOPK]],
"지문만": fp_rank[:15],
"지문점수": {k: round(full[k], 3) for k in set(fp_rank[:15]) | set(top)},
"옵션": b,
"정답거친차례": next((i + 1 for i, (_, k) in enumerate(coarse) if k in set(e["정답"])), None),
}
def main():
T = json.loads((K.HERE / "targets.json").read_text(encoding="utf-8"))
if sys.argv[1:] == ["merge"]:
out = []
for p in sorted(K.HERE.glob("rank_*.json"), key=lambda p: int(p.stem.split("_")[1])):
out += json.loads(p.read_text(encoding="utf-8"))
(K.HERE / "rank.json").write_text(json.dumps(out, ensure_ascii=False, indent=0, default=str), "utf-8")
print("합", len(out), len({r["id"] for r in out}))
return
a, b = int(sys.argv[1]), int(sys.argv[2])
n0 = K.load_cache()
t0 = time.time()
out = []
for e in T[a:b]:
t1 = time.time()
out.append(line(e))
print(e["id"], f"{time.time() - t1:.1f}s", flush=True)
if time.time() - t0 > 70: # 명령 하나 120 초 안 — 남은 줄은 다음 명령
break
K.save_cache()
(K.HERE / f"rank_{a}.json").write_text(json.dumps(out, ensure_ascii=False, indent=0, default=str), "utf-8")
print("끝", a, a + len(out), "캐시", n0, "→", len(K._RUN), f"{time.time() - t0:.1f}s")
if __name__ == "__main__":
sys.setrecursionlimit(10000)
main()
@@ -0,0 +1,191 @@
"""K5 ⑥ 할 일 3 · 4 정리 — (로직 + 옵션) 줄 세우기 수치 · 화면 「맞음」 표시 기준 안.
python k5_s6_sum34.py → 표 찍음 · sum34.json
"""
import json
from collections import Counter, defaultdict
import k5_lib as K
import s3_match as S
R = json.loads((K.HERE / "rank.json").read_text(encoding="utf-8"))
T = {e["id"]: e for e in json.loads((K.HERE / "targets.json").read_text(encoding="utf-8"))}
A = {r["id"]: r for r in json.loads((K.HERE / "answer.json").read_text(encoding="utf-8"))}
C4 = json.loads((K.C4 / "out_match.json").read_text(encoding="utf-8"))["줄"]
def single(e):
"""숫자 하나뿐 줄 — 직종 하나 · 장비 없음 또는 장비 시간 하나 · 직종 없음(기계 1 시간 줄은 뺌). 같은 숫자 로직이 많아 우연 맞음."""
E = [k for k, x in e["인력"].items() if x.get("품")]
X = [m for m in e["장비"] if m.get("시간")]
if len(E) + len(X) != 1:
return False
return not (X and abs(X[0]["시간"] - 1.0) < 1e-9)
def lvl(r, k):
b = r["옵션"].get(k)
if b is None:
return 9
return 9 if single(T[r["id"]]) else b["수준"]
def clause_ok(r, code):
e = T[r["id"]]
our = S.OURC.get(code)
theirs = [list(x) for x in e.get("출처") or []]
return bool(our and theirs and S.clause_prefix(theirs, our)[0] >= 0.9)
def order_P(r):
"""품만 — 2 단계 로직: 딱 맞음(강) 수준 → 약한 맞음 → 거리 → 1 단계 점수."""
sc = {k: s for k, s in r["거친"]}
b = r["옵션"]
return sorted(b, key=lambda k: (lvl(r, k), b[k]["약수준"], b[k]["거리"], -sc.get(k, 0)))
def order_P0(r):
return [k for k, _ in r["거친"]]
def order_F(r, lv=1):
"""지문 + 딱 맞음 먼저 — 2 단계에서 딱 맞음(강, 수준 ≤ lv) 로직을 앞에(지문 점수 차례) · 나머지는 지문만 차례."""
b, fs = r["옵션"], r["지문점수"]
first = sorted([k for k in b if lvl(r, k) <= lv], key=lambda k: -fs.get(k, -99))
return first + [k for k in r["지문만"] if k not in first]
def order_C(r):
return r["지문만"]
def ok_top(order, S_):
return bool(order) and order[0] in S_
def metrics(rows, fn):
n = len(rows)
c = Counter()
for r in rows:
o = fn(r)
Sx, Lx = set(r["정답"]), set(r["정답"]) | set(r["허용"])
b = r["옵션"].get(o[0]) if o else None
c["1위 엄격"] += ok_top(o, Sx)
c["1위 느슨"] += ok_top(o, Lx)
c["5안 엄격"] += bool(set(o[:5]) & Sx)
c["5안 느슨"] += bool(set(o[:5]) & Lx)
opt = ok_top(o, Sx) and b is not None and lvl(r, o[0]) <= 1
c["1위 옵션까지(R)"] += opt
c["1위 옵션까지(3%)"] += ok_top(o, Sx) and b is not None and lvl(r, o[0]) <= 2
c["1위 옵션 하나로"] += opt and not (b.get("갈리는 칸") or {}).get("R")
return {k: round(100 * v / n, 1) for k, v in c.items()} | {"n": n}
def display(r, fn=order_F):
"""화면 표시 갈래 — 1 위 (로직 + 옵션) 기준."""
o = fn(r)
b = r["옵션"].get(o[0]) if o else None
exact = [k for k in r["옵션"] if lvl(r, k) <= 1]
if b and lvl(r, o[0]) <= 1:
return "맞음(로직 하나)" if len(exact) == 1 else "맞음(로직 여럿 — 고름)"
if b and lvl(r, o[0]) == 2:
return "거의 맞음(3% 안)"
if b and b.get("직종같음") and clause_ok(r, o[0]):
return "다른 점 보임(직종 · 인용 조항 같음 · 계수 다름)"
return "후보"
def main():
groups = {
"사람 126": [r for r in R if r["무리"] == "사람"],
" 임도 49": [r for r in R if r["id"][0] == "N"],
" 사방 43": [r for r in R if r["id"][0] == "S"],
" 영월 34": [r for r in R if r["id"][0] == "Y"],
"새 파일 51": [r for r in R if r["무리"] != "사람"],
" 자동 정답 20": [r for r in R if "자동" in r["무리"]],
" 씨앗 짝 43": [r for r in R if "씨앗" in r["무리"]],
}
ways = {
"C4 지문만(로직만 · 조항 · 이름 덤)": order_C,
"1 단계만(C4 변형 · 품 채널만)": order_P0,
"품만(로직 + 옵션 · 딱 맞음 먼저)": order_P,
"지문 + 딱 맞음(R) 먼저": order_F,
"지문 + 딱 맞음(3%) 먼저": lambda r: order_F(r, 2),
}
out = {"수치": {}, "표시": {}, "표시줄": {}, "다른로직": [], "덮음": {}}
print("== 할 일 3 — 1위 엄격/느슨 · 다섯 안 엄격/느슨 · 1위 옵션까지(R/3%) · 옵션 하나로")
for g, rows in groups.items():
for w, fn in ways.items():
m = metrics(rows, fn)
out["수치"].setdefault(g, {})[w] = m
print(f"{g:<14}| {w:<26}| {m['1위 엄격']:>5}/{m['1위 느슨']:>5} · {m['5안 엄격']:>5}/{m['5안 느슨']:>5} · 옵션 {m['1위 옵션까지(R)']:>5}/{m['1위 옵션까지(3%)']:>5} · 하나로 {m['1위 옵션 하나로']:>5}")
# C4 지문만 재현 확인(126) · C4 추천 수치
have = [r for r in R if r["무리"] == "사람"]
same = sum(1 for r in have if r["지문만"][:5] == C4[r["id"]]["지문만"][:5])
print("C4 지문만 다섯 재현", same, "/", len(have))
rec = sum(1 for r in have if C4[r["id"]]["추천"] if False) if False else None # noqa: SIM210
# 1 단계가 정답을 15 안에 넣었나
for g, rows in groups.items():
cov = sum(1 for r in rows if set(r["정답"]) & {k for k, _ in r["거친"]})
out["덮음"][g] = f"{cov}/{len(rows)}"
print("1 단계 15 안 정답", out["덮음"])
# 할 일 4 — 표시 갈래
print("== 할 일 4 — 표시(지문 + 딱 맞음(R) 먼저 · 1 위)")
for g in ("사람 126", "새 파일 51", " 씨앗 짝 43", " 자동 정답 20"):
rows = groups[g]
cnt = defaultdict(lambda: Counter())
for r in rows:
d = display(r)
o = order_F(r)
Sx, Lx = set(r["정답"]), set(r["정답"]) | set(r["허용"])
cnt[d]["줄"] += 1
cnt[d]["엑셀 줄"] += T[r["id"]].get("줄수") or 1
cnt[d]["틀림 엄격"] += not ok_top(o, Sx)
cnt[d]["틀림 느슨"] += not ok_top(o, Lx)
if d.startswith("맞음"):
b = r["옵션"][o[0]]
cnt[d]["옵션 하나로"] += not (b.get("갈리는 칸") or {}).get("R")
out["표시줄"].setdefault(g, []).append({"id": r["id"], "표시": d, "1위": o[0] if o else None, "정답": r["정답"], "맞음": ok_top(o, Sx)})
out["표시"][g] = {d: dict(c) for d, c in cnt.items()}
for d, c in sorted(cnt.items()):
print(f" {g:<14} {d:<28} {dict(c)}")
# C4 딱 맞음(sure)과 견줌 — 126
sure_ids = set(json.loads((K.C4 / "out_match.json").read_text(encoding="utf-8"))["딱 맞음"])
mine = {r["id"] for r in have if display(r).startswith("맞음")}
print("C4 딱 맞음(고유 136 기준)", len(sure_ids & {r['id'] for r in have}), "· K5 맞음", len(mine), "· 둘 다", len(mine & sure_ids), "· K5 만", len(mine - sure_ids), "· C4 만", len((sure_ids & {r['id'] for r in have}) - mine))
out["C4딱맞음견줌"] = {"C4": sorted(sure_ids & {r["id"] for r in have}), "K5": sorted(mine)}
# 정답 로직이 「없음」인데 다른 로직이 엑셀 품을 그대로 냄
for r in R:
a = A[r["id"]]
if a["판정"]["R"] == "없음" and a["판정"]["0.03"] == "없음":
ex = [k for k in r["옵션"] if lvl(r, k) <= 1]
if ex:
out["다른로직"].append({"id": r["id"], "무리": r["무리"], "이름": T[r["id"]]["이름"], "정답": r["정답"], "딱 맞는 로직": ex,
"입력": {k: r["옵션"][k].get("입력") for k in ex}}) # fmt: skip
print("정답 로직은 없음인데 다른 로직이 딱 맞음(강 · R)", len(out["다른로직"]))
for x in out["다른로직"]:
print(" ", x["id"], x["무리"], x["이름"][:16], x["정답"], "→", x["딱 맞는 로직"], [K.WHOLE.logic(k).get("이름")[:16] for k in x["딱 맞는 로직"]], x["입력"])
# C4 변형(입력 조합 64 안 · 범위 · 수 칸은 기본값)만으로 1 위 로직의 품이 엑셀과 같나 ↔ K5 옵션 찾기
c4opt = Counter()
for g, rows in (("사람 126", groups["사람 126"]), ("새 파일 51", groups["새 파일 51"])):
for r in rows:
e = T[r["id"]]
o = order_C(r)
if not o or o[0] not in set(r["정답"]):
continue
ep = S.ex_prep(e)
pp = next(x for x in S.P if x["로직"] == o[0])
_, _, v = S.score_pair(ep, pp)
vv = {"L": v["L"] if v else {}, "X": [(sorted(a), sorted(b_), q) for a, b_, q in (v["X"] if v else [])], "M": v["M"] if v else {}, "S": {}}
c = K.compare(e, vv, 0.005, rnd=True) if v and v.get("in") is not None else None
c4opt[(g, "C4 변형으로 같은 품")] += bool(c and K.strong(e, c)) and not single(e)
c4opt[(g, "K5 옵션 찾기로 같은 품")] += lvl(r, o[0]) <= 1
c4opt[(g, "1위 로직 맞음")] += 1
out["C4변형옵션"] = {f"{a}|{b}": v for (a, b), v in c4opt.items()}
print("C4 변형 대 K5 옵션 찾기(1위 로직 맞은 줄 · R)", dict(c4opt))
(K.HERE / "sum34.json").write_text(json.dumps(out, ensure_ascii=False, indent=1, default=str), "utf-8")
if __name__ == "__main__":
main()
@@ -0,0 +1,77 @@
"""K5 들여다보기 — 정답 로직 · 모든 로직의 입력 칸(고르기 · 범위 · 예시 · 기계 목록) 꼴과 조합 수. 읽기만."""
import json
import math
import re
import sys
from collections import Counter
from pathlib import Path
ROOT = Path(r"C:\Program_coding\임도설계 및 견적자동화 프로그램 개발")
HERE = Path(__file__).resolve().parent
SP = HERE.parent.parent
for p in (str(ROOT), str(SP / "req225" / "C1")):
sys.path.insert(0, p)
sys.stdout.reconfigure(encoding="utf-8")
from M01_MasterData import M01_MasterData_Store as m01 # noqa: E402
from M02_MasterTemplete import M02_Hopyo_Match as hm # noqa: E402
from labels import L # noqa: E402
from labels_y import LY # noqa: E402
FILES, WHOLE = m01.loaded()
EQ = re.compile(r"^\d{4}-\d{4}$")
wide = json.loads((SP / "req226/C7/wide.json").read_text(encoding="utf-8"))
ans = set()
for v in {**L, **LY}.values():
ans |= set(v["정답"])
for d in (wide["자동"], wide["씨앗"]):
for v in d.values():
ans |= set(v)
codes = [x["로직"] for x in hm.targets()["로직"]]
print("돈 로직", len(codes), "· 정답 로직", len(ans))
def kinds(row):
sel, eq, rng, other = [], [], [], []
for s in row.get("입력") or []:
o = s.get("고르기") or []
if o and all(isinstance(v, str) and EQ.match(v) for v in o):
eq.append((s["이름"], len(o)))
elif o:
sel.append((s["이름"], len(o)))
elif isinstance(s.get("범위"), list):
rng.append((s["이름"], s.get("범위"), s.get("예시")))
else:
other.append((s["이름"], s.get("예시"), s.get("단위")))
return sel, eq, rng, other
st = Counter()
names = Counter()
for c in codes:
row = WHOLE.logic(c)
sel, eq, rng, other = kinds(row)
prod = math.prod(n for _, n in sel) if sel else 1
st["고르기 곱 >64"] += prod > 64
st["고르기 곱 >512"] += prod > 512
st["기계 목록 있음"] += bool(eq)
st["범위 있음"] += bool(rng)
st["그 밖 수 입력 있음"] += bool(other)
for n, _ in sel:
names[n] += 1
print(dict(st))
print("자주 나오는 고르기 이름", names.most_common(40))
if "show" in sys.argv:
for c in sorted(ans):
row = WHOLE.logic(c)
if not row:
print(c, "없음")
continue
sel, eq, rng, other = kinds(row)
print(c, row.get("이름"), "| 고르기", sel, "| 기계", eq, "| 범위", rng, "| 그 밖", other[:6])
if len(sys.argv) > 2:
row = WHOLE.logic(sys.argv[2])
print(json.dumps(row.get("입력"), ensure_ascii=False, default=str)[:4000])
print(json.dumps(row.get("호표"), ensure_ascii=False, default=str)[:3000])
print(json.dumps({k: row.get(k) for k in ("이름", "결과단위", "덧줄", "원문번호")}, ensure_ascii=False, default=str)[:2000])
@@ -0,0 +1,39 @@
"""K5 들여다보기 — 「지역」 · 「보정작업」 고르기가 품(직종 · 장비 시간 · 자재 수량)을 바꾸는 로직이 있나 · 엔진 속도.
C4 run_one(같은 엔진 길)으로 기본값 하나와 값 바꾼 것 하나를 견줌. 읽기만."""
import sys
import time
from pathlib import Path
HERE = Path(__file__).resolve().parent
C4 = HERE.parent.parent / "req226" / "C4"
sys.path.insert(0, str(C4))
sys.stdout.reconfigure(encoding="utf-8")
import s2_ours_fp as O # noqa: E402
codes = [x["로직"] for x in O.hm.targets()["로직"]]
a, b = int(sys.argv[1]), int(sys.argv[2])
which = sys.argv[3] if len(sys.argv) > 3 else "지역"
t0, runs, changed, err = time.time(), 0, [], 0
for code in codes[a:b]:
row = O.WHOLE.logic(code)
spec = next((s for s in row.get("입력") or [] if s.get("이름") == which and s.get("고르기")), None)
if not spec:
continue
by_name = {}
for h in row.get("호표") or []:
by_name.setdefault(h.get("이름"), h)
times = O.times_of(row)
keys = set()
for v in spec["고르기"][:: max(1, len(spec["고르기"]) // 6)]:
try:
got = O.run_one(code, row, {which: v}, times, by_name)
runs += 1
except Exception: # noqa: BLE001
err += 1
continue
keys.add(O._key(got))
if len(keys) > 1:
changed.append(code)
print(which, "로직", b - a, "· 돌림", runs, "· 오류", err, "· 품 바뀜", len(changed), changed[:20], f"{time.time() - t0:.1f}s")
@@ -0,0 +1,59 @@
"""K5 들여다보기 — 표시 갈래 조건 바꿔 보기(다른 점 보임 = 직종 같음 + 조항 같음 / 점수 차) · 틀린 줄 목록."""
import json
import sys
from collections import Counter
import k5_lib as K
import k5_s6_sum34 as M
import s3_match as S
OURC = S.OURC
T = M.T
def clause_ok(e, code):
our = OURC.get(code)
theirs = [list(x) for x in e.get("출처") or []]
return bool(our and theirs and S.clause_prefix(theirs, our)[0] >= 0.9)
def disp(r, rule):
o = M.order_F(r)
b = r["옵션"].get(o[0]) if o else None
e = T[r["id"]]
exact = [k for k, x in r["옵션"].items() if x["수준"] <= 1]
fs = r["지문점수"]
gap = fs.get(o[0], 0) - (fs.get(o[1], 0) if len(o) > 1 else 0)
if b and b["수준"] <= 1:
return "맞음(하나)" if len(exact) == 1 else "맞음(여럿)"
if b and b["수준"] == 2:
return "거의 맞음"
same = b and b.get("직종같음")
if rule == "직종" and same:
return "다른 점"
if rule == "직종+조항" and same and clause_ok(e, o[0]):
return "다른 점"
if rule == "직종+차" and same and gap > 0.3:
return "다른 점"
if rule == "직종+(조항 또는 차)" and same and (clause_ok(e, o[0]) or gap > 0.3):
return "다른 점"
return "후보"
for g, rows in (("사람", [r for r in M.R if r["무리"] == "사람"]), ("새", [r for r in M.R if r["무리"] != "사람"])):
for rule in ("직종", "직종+조항", "직종+차", "직종+(조항 또는 차)"):
c = Counter()
for r in rows:
d = disp(r, rule)
o = M.order_F(r)
c[(d, "줄")] += 1
c[(d, "틀림")] += not M.ok_top(o, set(r["정답"]))
print(g, rule, {d: f"{c[(d, '줄')]}줄 틀림{c[(d, '틀림')]}" for d in ("맞음(하나)", "맞음(여럿)", "거의 맞음", "다른 점", "후보")})
if "list" in sys.argv:
for r in M.R:
d = disp(r, "직종+(조항 또는 차)")
o = M.order_F(r)
if d.startswith("맞음") and not M.ok_top(o, set(r["정답"])):
print("틀린 맞음", r["id"], T[r["id"]]["이름"], o[0], r["정답"], r["옵션"][o[0]].get("입력"))
if d == "다른 점" and not M.ok_top(o, set(r["정답"])):
print("틀린 다른 점", r["id"], T[r["id"]]["이름"], o[0], r["정답"])
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
# 338 실험 자료 (요구 225~229 · 브레인 스크래치패드 사본)
옮겨 쓸 알고리즘의 본보기 — 그대로 돌리는 코드 아님(자료 경로 · 중간 파일은 원래 스크래치패드 기준). 저장소 모듈로 옮길 때 읽기만.
- `C1/` — 정답 표 `labels.py`(북부청 임도 · 사방 · 사람 정답) · `labels_y.py`(영월) · `out_items.json`(줄 id · 단산 번) · 지표 셈 `s04_eval.py` · STmate 흉내 `s09_stmate.py`
- `C4/` — 로직 지문: 엑셀 블록 지문 `s1_excel_fp` · `fp_common` · 우리 로직 지문(M01 엔진 셈) · 순위 · 딱 맞음
- `C5/` — 조합 풀이: `c01` 나무 · 절 · 몫 · `c05` combine · cover · is_mixed · 호표 조합 맞음
- `C6/` — STmate · 거창 읽기 · 노무 목록 이음 · 자동 정답 · 기억 흉내
- `C7/` — 겹쳐 잼 · 섞인 줄 몫 먼저 · 가림 시험
- `K5/` — 옵션 거꾸로 찾기 · `targets.json`(새 파일 51 줄 정답)
- 설계 = `../2026-10-15_338_일위대가맞춤_설계.md`
@@ -0,0 +1,566 @@
# 요구 229-4 PA4 — 외부 일위대가 맞춤 설계 (연결된 단가산출 → 우리 로직 + 옵션 → 우리 일위대가)
재료 = 336 조사 셋(목적다시 · 재검토 · 이름맞춤 검토 + 부록3 · 부록4) · 스크래치패드 실험(`req226/C4 · C5 · C6 · C7` · `req227/K5`) · 저장소 코드 HEAD 8a4598c05(M02 맞춤 코드는 510b36eea 뒤 안 바뀜 · 줄 번호 = 지금 파일) · 이번 읽기 확인(`req229/PA4/p01~p05.py` · 읽기만 · 파일마다 1 초 안). 저장소 · git 쓰기 없음. 파일 자리는 따로 적지 않으면 `M02_MasterTemplete/` 아래.
- 실험 자료 = 같은 폴더 `2026-10-15_338_실험자료/`(스크래치패드 사본 · 「338_실험자료/C4/…」 꼴로 적음 · 읽기만).
## 0. 요약
1. 사용자 말(229) = 엑셀 호표 줄 → 이어진 단가산출 블록(목록 · 식) → 우리 단가산출 로직 + 옵션 → 우리 일위대가 하나 — 336 실측이 이 길을 받침(옵션만으로 엑셀 품 그대로 58% · 엑셀 덧셈 20% · 원문과 다른 우리 로직 0).
2. 「하이퍼링크」 실제 — 북부청 · 거창 = 셀 하이퍼링크 0 · HYPERLINK 식 0 · 비고 칸 「단산N참조」 번호 글이 유일한 이음 / STmate = HYPERLINK 식이 있으나 보이는 글이 줄 코드(「D01352 →」)이고 판에 따라 없음(울진대흥 0) → 이음 열쇠 = 블록 번호 · 줄 코드(O 열 · 다섯 파일 모두).
3. 만들 것 = 서버 맞춤 엔진(엑셀 지문 · 우리 지문 캐시 · 순위 · 옵션 거꾸로 · 판정 · 조합) + STmate · 거창 읽기 + 별칭 장부 + 확인 창.
4. 판정은 숫자 — 「맞음」 = 1위 로직 + 찾은 옵션이 엑셀 품(직종 품 · 장비 시간 · 자재 수량)을 그대로 냄 · 「다른 점」 = 로직은 같음(직종 묶음 + 인용 조항) · 품만 다름 · 그 밖 「후보」 · 이름은 약한 덤 · 금액 안 씀.
5. 들어가는 것 = 사용자가 체크하고 [확인하고 사전조합 만들기]를 누른 줄만 · 그때 별칭 장부에 「엑셀 이름 → 로직 + 옵션 + 배」.
6. 셈 = 서버 단독(CLAUDE.md 5장 ③) · 올릴 때 뒤 스레드 · 우리 지문 캐시(마스터 판 열쇠 · 처음 ≈ 2 분 · 다음은 읽기만) · 화면 잠금 없음.
7. 창 셋 — 가 = 맞춤 핵심 서버(새 다섯 파일 + `M02_Hopyo_Match.py`) · 나 = 읽기(STmate · 거창 · 블록 글 · 인용 조항 손질) · 다 = 확인 · 별칭 장부 · 만들기 서버 + 화면 전부.
8. 완료 기준 — 사람 정답 126 줄 1순위 ≥ 92% · 다섯 안 ≥ 96% · 「맞음」 틀림 ≤ 2 · 확인 없이 들어가는 줄 0 · STmate 6 파일 올라감 · master_data 그대로.
### 0-1. 브레인 이해 — 맞음 / 다름
| 브레인 이해 | 판정 | 덧 · 고침 |
|---|---|---|
| 호표 한 줄 = 하이퍼링크 · 「단산N참조」 · STmate 줄 코드(D)로 단가산출 블록 하나에 이어짐 | 반만 맞음 | 엑셀 파일 속 하이퍼링크는 이음이 아님(1 장) · 이음 = 번호 글 · 줄 코드 · 모든 줄이 블록에 이어지지도 않음 — 기계(hr) · 노임 · 자재 · % · 다른 호표 줄은 블록 없음(북부청 임도 415 줄 중 단산 268 · 자재 78 · 별도계상 64 · 기계 3 · % 1 · 호표 1 / STmate 봉화 216 줄 중 단산 103 · 노임 30 · 호표 22) |
| 블록의 목록(직종 · 장비 · 자재 · 수량)과 계산식을 읽음 | 맞음 | 북부청 = 식 글을 풂(맨 앞 단가를 떼고 남은 식 · 514 풂 · 32 못 풂) · STmate = 자원 줄 H 열 품이 곧 품(식 안 풂) · 거창 = 글이 한 줄 여러 칸에 흩어져 이어 붙여야 읽힘 · 지금 코드는 블록에서 출처 · 직종만 읽고 글 · 품을 안 남김 |
| 우리 단가산출 로직(+ 옵션 = 입력 칸 값)을 찾음 | 맞음 | 차례 = 로직 먼저(지문 = 직종 · 품 · 장비 · 조항 · 조건 · 이름 덤) → 그 로직에서 옵션 거꾸로(엔진) · 품만으로 로직을 고르면 1순위 60% 로 약함(K5) |
| 호표 줄을 모아 우리 일위대가(사전조합 = 로직 + 옵션 + 수량 · 줄 별칭 = 엑셀 이름) 하나로 | 맞음 | 덧 — 블록 하나 = 로직 여럿 가능(철근 가공 + 조립 · 섞인 줄 인력 몫) → 사전조합 줄 여럿(같은 별칭) · 자재 줄 = 우리 자재 · 다른 호표 줄 = 그 호표 사전조합 참조 · % 줄 = 안 넣음(우리 덧줄이 셈) · 노임 · 장비를 바로 적은 호표(STmate 49 · 북부청 5 · 거창 5) = 호표 통째를 로직 하나로 |
| 이름은 판정에 거의 안 씀 | 맞음 | 이름 = 순위 덤(무게 0.6 / 약 9.5) · 후보 풀 · 별칭 장부 열쇠(엑셀 이름 → 짝)에만 큼 |
| 금액 안 씀 | 맞음 | 식 맨 앞 수(노임 · 장비 단가)는 떼고 셈 · 식을 못 풀 때만 금액 ÷ 단가로 품을 되찾음(「역산」 꼬리표) · 금액 비교는 만든 뒤 「참고」 한 줄 |
| 확인한 줄만 들어가고 그때 짝이 별칭으로 남음 | 맞음 | 지금 코드는 「같음」 · 「기억」 · 「호표」 줄과 [로직 추가] · 엑셀 보기 창 길이 확인 없이 들어감 → 없앰(4 장) |
---
## 1. 연결 — 호표 줄 → 단가산출 블록
### 1-1. 실측 (이번 · 읽기 전용)
| 파일(꼴) | 셀 하이퍼링크 | HYPERLINK 식 | 다른 시트 참조 식 | 실제 이음 |
|---|---|---|---|---|
| 북부청 임도 · 사방 xls | 0(일위대가 · 단가산출 · 총괄표) | 0 | 0 — 일위대가 시트 식 칸 1 개뿐(값만 든 내보내기) | 비고 「단산N참조」 268 / 268 · 139 / 139 · 「일위N참조」 1 · 2 · 빈 비고 146 · 60 |
| 거창 xlsx | 0 | 0 | 0 — 일위대가 식 432 = 줄 안 합(「J5+L5+N5」) | 같은 「단산N참조」 21 · 「일위N참조」 12 |
| STmate xlsx(봉화 · 영월 · 울진소광) | 2 씩(머리 칸 STmate 안내 풍선) | 일위대가표 249 · 162 · 134 / 단가산출근거 388 · 456 · 237 | — | HYPERLINK = 일위대가표 Z 열 줄마다 「L00004 →」 「D01352 →」(목록표 행으로 감) · B 열 호표 머리 「BDCOD\|B00004 · POSS\|7 · POSE\|9」 · 단가산출근거 G 열 블록 머리 「BDCOD\|D00001 · POSS · POSE」 · Y 열 자원 → 목록표 |
| STmate 줄 코드 칸 | — | — | — | O 열(14) 「D00096_1」 다섯 파일 모두(248 · 161 · 133 · 205 · 39) · Z 열 HYPERLINK 글 넷(울진대흥 0) · M 열(12) 비고는 판마다 다름(「N년개정」 · 「D96」 · 「산근N호표」 · 「대가N」 · 「LN」) |
| STmate 단가산출근거 | — | — | — | 블록 머리 G 열 「BDCOD\|D…」 다섯 모두 · 자원 코드 M 열 「L00002_1」 다섯 모두 · A 열 「L00002L」 셋 · 열 수 25(봉화 · 대흥 · 소광) / 45~47(영덕 · 영월) |
### 1-2. 지금 읽기 코드가 이음을 쓰는 법
- `M02_Hopyo_Excel._REF :34` = 비고 「단산N참조」 · 「일위N참조」만 → 줄 `참조 {종류, 번}` · 블록 = `M02_Hopyo_Basis.read :84-125` 산출근거 열 머리 「N.이름[단위]」 번호 → `read_book :257-261` 이 줄에 `근거 {출처, 직종, 행}`만 붙임(블록 글 · 품 · 장비 · 조건 안 남김).
- 시트 = `Excel._sheets :74-102` 가 「일위대가」 · 「일위대가총괄표」 · 「단가산출」 이름만 읽음 → STmate(일위대가표 · 단가산출근거)는 400 「일위대가 시트 없음」.
- 거창 = 번호 이음은 되나 `Basis.read` 가 산출근거 한 열만 읽어 블록 글 · 출처 · 직종 0.
### 1-3. 하이퍼링크를 쓰면 더 튼튼한가 — 아님
- 북부청 · 거창 — 쓸 하이퍼링크가 없음. 번호 글이 유일한 이음이고 지금도 268 / 268 · 139 / 139 · 21 / 21 이어짐.
- STmate — HYPERLINK 의 보이는 글 = 줄 코드와 같은 글 · 대상 주소는 식(`data_only=False`)으로 읽어야 하고 목록표를 한 번 더 거침 · 판에 따라 빠짐(울진대흥) → 줄 코드 칸보다 약함.
- 그래서 정함 — 열쇠 = 블록 번호 · 줄 코드 · 하이퍼링크는 안 읽음 · 대신 번호 글 꼴 · 코드 칸 자리를 넓혀 판 차이에 견딤(아래).
### 1-4. 정함 — 이음 열쇠 (나 창)
| 꼴 | 줄 → 블록 | 넓힘 |
|---|---|---|
| 엑셀꼴(「일위대가」 시트) | 비고 `^(단산\|산근\|단가산출)\s*(\d+)\s*(참조\|호표)?$` → 단산 N · `^(일위\|대가\|일위대가)\s*(\d+)\s*(참조\|호표)?$` → 호표 N(빈칸 무시) | 비고가 비었고 노무비 · 경비 단가가 있는 줄 = 줄 품명 + 단위가 블록 머리 이름 + 단위와 같은 블록이 하나뿐이면 이음 · 꼬리표 「이름으로 이음」 |
| STmate | 줄 M~Z 열에서 코드 꼴 `^(?:COD\|)?([A-Z]{1,2}\d{5})(?:_\d+)?(?: →)?$` 첫 것 → D = 단산(번 = 코드 숫자 · 코드 남김) · B = 호표(호표 머리 코드 표로 번) | 열 번호에 안 묶임 → 판(25 · 45~47 열)이 달라도 읽힘 |
| 블록 머리 | 엑셀꼴 = 산출근거 열 「N.이름[단위]」 · STmate = G 열 「BDCOD\|D…」 + 단가산출근거목록표(이름 · 규격 · 단위) | — |
---
## 2. 맞춤 알고리즘 명세
### 2-1. 흐름
```
올리기 ─ 읽기(나) ─ HP 파일(호표 · 줄 · 블록 글 · STmate 자원) ─ 저장
└─ 뒤 셈(가) Fit.ensure(층, 키) ── 블록마다(같은 블록 · 같은 줄 열쇠는 한 번)
① 엑셀 지문(Finger) → ② 순위(지문 점수 + β × 인용 조항 차례 덤) → ③ 옵션 거꾸로(위 다섯 · 엔진)
→ ④ 판정(맞음 / 다른 점 / 후보) → ⑤ 조합(절 · 몫 · 섞인 줄 몫 먼저) → 맞춤셈 캐시 파일
보기(Match.view) = 맞춤셈 캐시 + 별칭 장부 + 갈래 규칙 → 줄 상태 · 체크 제안 (싸게 · 요청마다)
고침(Match.guess) = 고른 로직 · 옵션으로 엔진 한 번 → 우리 품 ↔ 엑셀 품 (저장 안 함)
확인(다) = [확인하고 사전조합 만들기] → 서버 재검사 · 우리 엔진으로 셈 · 사전조합 · 별칭 장부
```
### 2-2. ① 엑셀 지문 — 새 `M02_Hopyo_Finger.py` (가 · 약 450 줄)
옮길 것 = C4 `fp_common.py`(식 풀기 · 장비 갈래 · 조건 낱말 · 닮음 도우미) + `s1_excel_fp.north` · `yeongwol` + C5 `c01_trees.sections` · `sections_stm`(몫) — 한 번 도는 고리에서 지문 · 몫을 함께 냄.
| 함수 | 입력 → 출력 |
|---|---|
| `excel(block, line) -> dict` | HP 블록(3-1) + 그 줄(이름 · 규격 · 단위) → 지문(아래) |
| `machine(line) -> dict` | 블록 없는 기계(hr) 줄 → 지문 `장비 [{갈래, 규격, 시간 1}]` |
| `direct(hopyo) -> dict` | 노임 · 장비 줄을 바로 적은 호표 → 줄 수량 합 지문 + 근거글 출처 |
| `parse_rhs(rhs) -> {단가, 품, 밑품, 율, 인자} \| None` | 「273,540 × 3인 ÷ 55㎥ × 2 = 29,840」 → 품 0.10909 |
| `mtypes(text)` · `specnums(text)` · `conds(text)` | 장비 갈래 · 규격 수 · 조건 낱말(엑셀 · 우리 같은 함수) |
지문 꼴:
```
{이름, 규격, 단위,
인력: {직종: {품, 밑품, 율: [[값, 메모]], 어떻게: "식" | "역산" | "QTY"}},
장비: [{갈래: [..], 규격: [수..], 시간, 밑시간, 율, 절, 글}],
자재: [{이름, 품, 밑품}], 율: [[바탕, %]], 계수: {q: 0.5},
조건: {축: [낱말]}, 이름조건: {축: [낱말]}, 출처: [[책, 번호]], 옛판: bool,
몫: [{제목, 절, 노임: {직종: 품}, 장비: {"굴착기|0.6": 시간}}]}
```
규칙(실험 그대로):
- 절 = `^\d+\.` 맨 윗 절 · `^[가-하]\.` 작은 절(STmate `^\d+[.)]` · ①~⑩) · 절 제목 장비 낱말 = 지금 장비.
- 직종 줄 「직종 : 단가 × 식 = 금액」(직종 = 우리 인력 이름 · 옛이름 — `Basis.trade_names`) → `parse_rhs` = 맨 앞 수(단가) 떼고 식 셈 · 「× 10%」 · 「(품할증)」 · 「× 2회」 = 율(밑품 = 율 뺀 품) · 바꿔 쓴 식(「= 215,907×4×0.026」)은 앞 조각 율을 이어받음 · 율 메모 없는데 맨 인자가 이름 · 절 머리 몫(「9:1」 · 「(90%)」)과 같으면 「섞음」 율 · 못 풀면 금액 ÷ 단가(「역산」 · 품 되찾기만).
- 장비 = 「노무비 · 경비 · 재료비 : 단가 ÷ Q × 몫」 + 지금 장비(노무비 > 경비 > 재료비 차례) · 자재 = 「이름 단가 × 수량 = 금액」 · 「: 별도계상」.
- 율 「…의 N%」 · 계수 `q|K|f|E|Cm|L|V|T = 수` · 조건 축(토질 · 메찰 · 형 · 무근/철근 · 난이 · 규모 · 방법 · N회 · N개월 · Type) = 이름 · 규격 · 글 앞 다섯 줄 · ※ · 작업조건 줄 · 옛판 = 글 앞 여섯 줄에 2020 이하 해 · 「임도품셈」 · 「사방품셈」.
- STmate = 자원 줄 품(H 열)이 곧 품 · L = 노무비목록표 이름 · X = L 몫(시간) 앞섬(L > S > M) · S 중 장비 낱말 = 장비 · 몫 율 = 절 제목 몫 수가 식 글에 곱으로 있으면.
- 몫(조합용) = 절마다 노임 · 장비 벡터 — C5 `sections` 결과와 같아야 함(시험).
### 2-3. 우리 로직 지문 · 캐시 — 새 `M02_Hopyo_Ours.py` (가 · 약 300 줄)
옮길 것 = C4 `s2_ours_fp.py`(`static` · `combos` · `run_one` · `fingerprint`) + C4 `s3_match.prep`(섞기 전 변형 · 고를 수 있는 장비).
| 함수 | 하는 일 |
|---|---|
| `version() -> str` | 마스터 판 열쇠 = `resources/master_data` 첫 층 *.json(밑줄 뺌) 이름 + `M01_MasterData_Store.version_of`(sha256 16) 묶음의 sha256 앞 16 · 파일 자국(`Store_Cache._stamps`)이 같으면 다시 안 셈 |
| `table() -> list[dict] \| None` | 우리 지문 1,438(돈 로직 = `Match.targets()["로직"]`) · 준비 전 None |
| `ready() -> bool` · `warm() -> Thread` | 디스크 캐시 있으면 읽기만 · 없으면 뒤 스레드 셈(≈ 110 초 · C4) |
| `run(code, given) -> {L, X, M, S, mix} \| None` | 엔진 한 번(`mau.values` 자동값 + 입력 → `m01.cm.mf.run` · `Labor_Doc.run_line` 과 같은 길) · 메모리 캐시(5 만 칸 · ponytail) |
- 정적 = 호표 인력 → 직종 · 「로직(기계경비 …)」 → 장비 갈래 · 규격(EQ) · 재료 이름 · 덧줄 % · 조항(`Basis.ours`) · 이름 · 단위 · 이름 · 고르기 글 조건 낱말 · 기계경비 로직 표시.
- 변형 = 고르기 입력 곱 ≤ 64 모두 · 넘으면 하나씩 · 뺌(지역 · 조종원직종 · 보정작업 · 기종 목록 칸) · 줄마다 단위당 L · X · M · S(결과단위 「원/100㎡」 ÷ 100) · 인력비율 · 기계비율 든 로직 = 섞기 전 변형 덧.
- 캐시 자리 = `tmp/m02_hopyo/우리지문_<판>.json`(저장소 `tmp/` = git 밖 · 환경 `AISLO_HOPYO_CACHE` 로 바꿈 · 시험은 임시 폴더) · 쓰기 = `atomic_write_json`.
- 시동 = `M02_Labor_Router` 불러올 때 `warm()`(디스크 읽기만 · M01 `store.warm()` 과 같은 자리) · 셈은 처음 필요할 때(올리기 · 보기) 뒤에서.
- 원본 = `m01.loaded()` 읽기 · 엔진 부르기만 · `resources/master_data` 쓰기 없음.
### 2-4. ② 순위 — 새 `M02_Hopyo_Fit.py` (가 · 약 450 줄)
옮길 것 = C4 `s3_match.score_pair` · `lab_sim` · `mach_sim` · `unit_fit` · `sure` · `fuse` + C7 `k0_lib.finger` · k8(딱 맞음이 같음을 이김).
- 점수(로직마다 가장 닮은 변형) = 3.0 인력 + 2.0 장비 + 1.0 자재 + 0.3 율 + 0.5 단위 + 0.6 조건 + 1.5 조항 + 0.6 이름 · 금액 없음.
- 인력 = 0.35 직종 겹침(보통인부 반 무게) + 0.25 꼴(코사인 · 품 · 밑품 큰 쪽) + 0.40 크기(직종마다 ln 비 3% 안 = 1) · 단위가 다르면 크기 덤 없음(단위 별칭 개소 = 개 = 본 · hr = 시간) · 엑셀에 없는 우리 인력 −0.6 · 장비 −0.4.
- 장비 = 갈래 겹침 0.6 + 규격 0.2 + 시간 0.2 · 우리에만 있는 갈래는 깎음.
- 조항 = `Basis.clause`(같음 1 · 윗마디 0.9 · 앞 마디 0.5) × 옛판 0.5 · 조건 = 엑셀 조건 낱말이 우리 이름 · 고르기 글에 든 비율 − 이름 축 어긋남(토질 · 메찰 · 형) · 이름 = 정리 이름 글자 두 개 묶음 닮음.
- 섞기 = 지문 위 40 ∪ B 차례 위 5 → 점수 + β(1) × B 차례 덤(1 · 0.6 · 0.45 · 0.35 · 0.25) → 위 다섯.
- B 차례 = `Match.b_order(line, got)` — 지금 이름 후보 + 근거 후보 합침(`Basis.merge`) + 인용 조항 맞대기 손질(접힘 없음 · 「같음」 근거 검사 · 옛 판 글이면 같음 안 냄) · 씨앗(일위대가조합.json 메모)은 이름 같음과 같은 자리(덤만).
- 같음 규칙 = B 1위가 이름 같음 · 씨앗(근거 검사 통과)이면 1위 고정 — 단 지문 「딱 맞음」(1위 직종 묶음 같음 · 품 3% 안 · 2위와 점수 차 0.3 넘음 · 보통인부 하나뿐 줄 아님)이 다른 로직을 가리키면 지문 1위.
- 기대(사람 126 · 씨앗 켬) = 1순위 88.9 · 다섯 안 96.8(C4 · 336 재검토 ②) · 씨앗 끔 85.7.
### 2-5. ③ 옵션 거꾸로 — 새 `M02_Hopyo_Option.py` (가 · 약 400 줄)
옮길 것 = K5 `k5_lib.py`(`excel_numbers` · `space` · `narrow` · `combos` · `plan` · `sig` · `near` · `ru` · `compare` · `gap` · `csig` · `strong` · `vary`) + `k5_s2_answer.MODES` · `ex_of` + `k5_s5_rank.stage_b`.
| 함수 | 하는 일 |
|---|---|
| `space(code, ex)` | 입력 칸마다 후보 — 고르기 = 모두 · 기계(EQ) = 엑셀 규격 수와 맞는 기종(없으면 모두) · 범위 = 두 끝 + 범위 안 엑셀 수 · 표 찾기 칸 = 그 표 줄 값(`master_auto._fits`) · 그 밖 수 = 자동값 + 같은 이름 계수 + 칸 이름 · 단위 붙은 엑셀 수(% 칸 = 0 덧 · 「품」 칸 = 엑셀 품 × 결과단위) · 뺌 = 지역 · 보정작업 · 조종원직종 |
| `plan(code, ex, cap=400)` | 곱 ≤ cap 모두(안 닿는 칸 · 같은 품 값 묶음) · 넘으면 엑셀 조건 낱말로 고르기 좁힘 · 그래도 넘으면 기준점 둘에서 하나씩 + 고르기 둘씩 |
| `compare(efp, v, tol, rnd, base)` | 직종 묶음 같고 품 허용 안 · 같은 갈래 장비 시간 · 이름 낱말 겹치는 자재 수량 → `{직종, 장비, 자재, 핵심, 덧: ["엑셀만 장비 덤프" …]}` |
| `strong(efp, cmp)` | 핵심 + 엑셀 몫을 다 덮음(엑셀만 · 우리만 장비 없음 · 자재 다름 없음 · 보통인부 하나뿐 줄 아님) |
| `find(code, efp) -> 옵션 결과` | 조합마다 `Ours.run` → 허용 셋(0.5% · R · 3%) 판정 → 아래 꼴 |
```
옵션 결과 = {수준: 0 | 1 | 2 | 9, # 0.5% · R(0.5% + 엑셀 값 자릿수 반올림 폭 · 값의 4% 안) · 3% · 없음
판정: "하나만" | "여럿(같은 품)" | "여럿(다른 품)" | "없음",
입력: {움직이는 칸: 값}, 갈리는칸: ["측압", "폼타이규격"],
견줌: compare(…, 0.03), 우리품: {인력, 장비, 자재}, 거리: 0.0, 섞기전: bool}
```
- 같은 수준이면 거리(직종 어긋남 + 품 ln 비 + 장비 시간 반 무게) 작은 것 · 품에 안 닿는 칸(물빼기파이프 · 모래 · 자갈상태 · 강도 · 슬럼프) = 자동값 · 엑셀 자재 이름 · 규격 글이 고르기 글과 같으면 그 값.
- 입력값 출처(칸마다) = 별칭(장부) > 블록(찾은 값이 자동값 · 규격 읽기와 다름) > 규격(`Match.spec_inputs`) > 자동값.
- 기대(K5) = 1위 로직이 맞은 103 줄 중 옵션까지 엑셀 품 그대로 56 · 입력값을 옵션 찾기로만 채우는 줄 46(레미콘 「소량」 → 소량감률 50 · 합판 「4회」 → 유형 보통).
### 2-6. ④ 판정 (Fit)
| 상태 | 조건(1위 기준) | 체크 제안 | 글 · 꼬리표 |
|---|---|---|---|
| 맞음 | 옵션 수준 ≤ 1 · `strong` | 체크 | 「맞음 — GC000198 · 장비사용타설 · 철근구조물 · 소량감률 50」 · 딱 맞는 로직이 둘 넘으면 「맞음 둘」 + 둘째를 후보 맨 앞 · 갈리는 칸이면 「측압 · 폼타이규격 못 가름」 |
| 다른 점 | 1위 직종 묶음 = 엑셀 · 엑셀 인용 조항 = 그 로직 조항(`Basis.clause` ≥ 0.9) · 수준 2(3% 안) 포함 | 안 함 | 「특별인부 엑셀 0.354 · 우리 0.370」 · 밑품이 같으면 「섞음 — 엑셀이 × 10%」 · 율 「할증 × 1.2」 · 「엑셀만 장비 덤프」 · 가까운 옵션을 입력값으로 |
| 후보 | 그 밖 | 안 함 | 후보 다섯 — 후보마다 그 로직 옵션 판정 꼬리표(위 다섯은 다 옵션 찾기를 돌림) |
- 기준 = K5 4-1 · 기대(사람 126) = 맞음 58 · 틀림 1 · 다른 점 25 · 틀림 1(직종만 보면 39 · 틀림 16 → 인용 조항까지).
- 「거의 맞음」 · 「옛 판」 같은 까닭은 자동으로 안 지음 — 숫자 사실(엑셀 · 우리 품)만 · 까닭은 사용자가 [원문 보기]로.
### 2-7. ⑤ 조합 — 새 `M02_Hopyo_Combine.py` (가 · 약 300 줄)
옮길 것 = C5 `c03_vec.sim` · `fit` · `dims` · `index` + `c05_solve.combine` · `cover` · `is_mixed` · `machine_of` · `best_hint` · `Prior` · `pool_for` + C7 `k0_lib.solve`(섞인 줄 1순위 바꿈).
- 할 때 = 블록 몫 둘 넘음 또는 섞인 줄(제목 · 이름 · 규격에 「N%」 · 「a:b」 + 노임만 든 몫 + 장비만 든 몫).
- 우리 벡터 = `Ours` 변형의 L · X(같은 캐시 · 따로 안 셈) · 맞음 = 차원마다 작은/큰 가중 평균(보통인부 0.5 · 장비 있음 0.5 + 시간 0.5 · 크기 다르면 × 0.8 · 굴착기 ↔ 굴착기+집게 × 0.8) · 몫 x ∈ {0.1 … 0.9} × 0.95 · 제목 몫 수와 같은 x 면 +0.1.
- 섞인 줄 몫 먼저 = 1위가 블록 합을 35% 못 덮고 몫 · 절 로직이 있으면 1위를 인력 몫 로직으로 바꿈(x = 몫 · 고임돌 「인력 10%」 → GF000211 × 0.1) — 지문 × 조합을 그냥 겹치면 조합 맞음 49.4% 로 깎이던 것을 막는 규칙.
- 덧 로직 = 1위가 못 덮는(덮음 < 0.6) 절은 절 1위(맞음 ≥ 0.75) · 아니면 몫마다(≥ 0.6) · 장비만 든 몫 = 우리 일 로직이 맞으면 그것 · 아니면 「기계 시간당 기계경비」(GC000267 · GC000268) 후보 — 체크 안 함 · 꼬리표 「시간 = 엑셀 값(우리 셈 아님)」.
- 순위 덤 = 지금 차례 0.3 · 인용 조항 같음 0.3 · 같은 책 0.1 · 후보 풀 = 섞기 위 15 + 드문 직종 · 장비 색인(40 개 이하) + 절 제목 이름 후보 8.
- 결과 = 대상 목록 `[{로직, 어디(절 제목), 몫, 맞음}]` → 덧 로직마다 그 몫 지문으로 옵션 거꾸로.
- 기대(336 재검토 ③) = 1순위 92.9 · 조합 맞음 75.9(덮을 수 있는 83 호표 · 63).
### 2-8. 블록 없는 줄 · 호표 단위
| 줄 | 처리 |
|---|---|
| 기계(hr) 줄 | `Finger.machine` → 같은 순위 · 옵션(기계 칸 = 엑셀 규격 수 기종) → 「기계 시간당 기계경비」 + 기계 · 펌프차 · 크레인 「맞음 둘」 많음 |
| 노임 줄(단위 인 + 우리 직종) | 호표에 노임 · 장비 줄이 있으면 직접 줄 호표 → `Finger.direct` → 같은 순위 · 판정 → 호표 단위 제안 「통째 = GF000225 메붙임(굴착기 + 집게)」(C5 (나) 벡터 + 인용 32 / 34) · 통째로 안 맞으면 노임 줄 「노임 · 그릇 없음」 |
| 자재 · 별도계상 | 지금 이름 · 규격 같음(`Match._exact` 자재) — 하나 = 맞음 · 체크 / 그 밖 후보 |
| % 줄 | 안 들어감(우리 덧줄이 셈) · 체크 없음 |
| 다른 호표(일위N) | 그 호표가 만들 층에 확인돼 있으면 체크 · 아니면 「먼저 확인」 |
| 블록 없는 그 밖 줄 | B 차례 후보(이름 · 근거 · 씨앗) → 「후보」 · 꼬리표 「숫자 판정 없음」 |
### 2-9. 상수 한 곳 (`M02_Hopyo_Fit.py` 머리 · 손잡이)
`W = {인력 3.0, 장비 2.0, 자재 1.0, 율 0.3, 단위 0.5, 조건 0.6, 조항 1.5, 이름 0.6}` · `BETA = 1.0` · `RB = (1, .6, .45, .35, .25)` · `POOL = 40` · `TOP_OPT = 5` · `CAP = 400` · `TOL = (0.005, "R", 0.03)` · `SURE = (0.03, 0.3)` · `MIXED_CUT = 0.35` · `COMBO = (0.85, 0.75, 0.6)` · `COVER = 0.6` · `SHARES = (0.1, 0.2, 0.3, 0.5, 0.7, 0.8, 0.9)` — 값은 336 · K5 실험 그대로(무게를 한 파일로 다시 맞추지 않음 · 336 재검토 2-5).
### 2-10. 뒤 셈 · 캐시 · 계산 자리
| 할 것 | 정함 |
|---|---|
| `Fit.ensure(folder, key) -> {끝, 한, 모두, 까닭}` | HP 파일 sha256 16 + 마스터 판 → 캐시 파일 있으면 끝 · 없으면 일감 줄(작업 스레드 하나 · `queue.Queue`)에 넣음 · 블록 차례로 셈 · 20 블록마다 캐시 파일 덮음 |
| `Fit.result(folder, key) -> dict \| None` | 캐시 읽기(보기 · 미리 셈이 씀) |
| 캐시 파일 | `tmp/m02_hopyo/맞춤_<HP16>_<판16>.json`(꼴 3-2) · 층 폴더에 안 둠(마스터 층 `resources/master_template/실무자료` 는 git 안 · 파생 값으로 git 소음) |
| 부르는 때 | 올리기 길 저장 뒤 · 보기(없거나 판이 바뀜) · 우리 지문 준비 전이면 「우리 로직 준비 중」 |
| 셈 단위 | 블록 + 줄 열쇠(`단산:<번>\|<품명\|규격\|단위>`) 한 번 · 블록 없는 줄 `줄:<열쇠>` · 직접 줄 호표 `호표:<번>` |
| 계산 자리 | ③ 서버 단독 · 화면은 보이기만 · 옵션 바꿈 = `guess` 를 뒤에서 · 최신 응답만(요청 차례 번호) · 화면 전체 잠금은 [확인하고 사전조합 만들기]에만 |
| 무게 | 우리 지문 처음 ≈ 110 초(C4) · 캐시 읽기 몇 초 · 옵션 찾기 줄당 0.1~26 초(K5 · 엔진 셈 캐시로 줄어듦) → 북부청 임도 고유 52 줄 · 블록 95 = 몇 분 뒤에서(측정해 기록) |
| 위험 | 엔진을 뒤 스레드에서 돌림 — 요청 스레드 셈과 겹칠 때 값이 그대로인지 시험 · 안 되면 셈 잠금 하나 · `master_auto._uses` · `_fits`(밑줄 함수) 의존은 시험이 잡음 |
| ponytail | 일감 줄 하나 · 서버 여럿이면 같은 파일을 둘이 셈 · 블록 글이 같은 다른 파일도 다시 셈 · 캐시 파일 지우기 없음(작음 · `tmp`) |
---
## 3. 자료 꼴 — 지금 꼴과 이어지게
### 3-1. HP 파일(`{층}/실무자료/HP-######.json`)에 더할 것 — 나 창
```
{…지금 칸(키 · 파일 · 올린때 · 올린이 · 시트 · 호표 · 단산 · 검산),
꼴: "엑셀꼴" | "STmate", # 새
호표: [{번, 이름, 규격, 단위, 금액, 행, 코드?: "B00004", # 코드 = STmate
근거글?: ["건설표준품셈 7-2-2(찰붙임)"], # STmate 빈 코드 줄
줄: [{…지금 칸, 갈래: + "노임", # 노임 = 단위 인 + 우리 직종 · STmate L
참조: {종류: "단산" | "일위", 번: 96, 코드?: "D00096"},
이음?: "이름"}]}], # 이름으로 이은 줄만
단산: [{번, 이름, 단위, 행, # 지금
코드?, 규격?, 출처: [[책, 번호]], 직종: [이름], # 새(지금은 줄 근거에만)
글: ["1. 현장가공", " 철근공 : 273,540 × …", …], # 머리 뺀 몸 줄 · 200 줄 · 줄마다 300 자 안
자원?: [{갈래: "L"|"X"|"M"|"S", 코드, 이름, 규격, 품, 절}]}]} # STmate
```
- 줄 `근거 {출처, 직종, 행}`은 그대로(지금 화면 · `Basis.candidates` 가 씀).
- 옛 HP 파일(글 없음) = 숫자 판정 없음 → 「다시 올리면 숫자 판정」 꼬리표(지금 격자 없음과 같은 길).
### 3-2. 맞춤셈 캐시 — 가 창 (`tmp/m02_hopyo/맞춤_<HP16>_<판16>.json`)
```
{판, 지문판: 1, 셈: [한, 모두], 끝: bool,
블록: {"단산:12|레미콘타설(장비사용)|철근구조물-소량 타설|㎥": {
지문: {인력, 장비, 자재, 출처, 조건, 옛판}, # 화면 「엑셀 품」
순위: [{로직, 점수, 채널: {인력, 장비, 조항 …}, B: 0}], # 위 다섯
옵션: {"GC000198": 옵션 결과(2-5), …}, # 위 다섯 · 조합 덧 로직
조합: [{로직, 어디, 몫, 맞음}] | null,
판정: {상태: "맞음" | "다른 점" | "후보", 글, 꼬리표: [..]}}}}
```
### 3-3. 맞춤 응답 — 줄 하나 (`GET hopyo/{키}/{번}` · 가 → 다)
지금 `맞춤 {열쇠, 상태, 대상, 입력값, 못읽음, 환산, 들어감, 후보}`(Api `HopyoMatch :91-100`) → 아래로 바꿈(`들어감` 없앰 → `체크` · `대상` = 목록).
```
맞춤: {열쇠: "품명|규격|단위",
상태: "맞음" | "별칭" | "다른 점" | "후보" | "없음" | "뺌" | "호표" | "%" | "노임",
체크: bool, # 처음 체크 제안(확인 대기) — 들어감 아님
대상: [{로직? | 자재? | 호표?, 이름, 단위,
입력값: {칸: 값}, 못읽음: [칸], 출처: {칸: "별칭" | "블록" | "규격" | "자동값"},
환산: {배, 갈래}, 몫: 0.1 | null, 배: 0.1 | null, # 배 = 환산 × 몫 · 우리 수량 = 엑셀 수량 × 배
어디?: "1. 현장가공"}],
후보: [{로직 | 자재, 이름, 단위, 점수, 판정: "맞음" | "다른 점" | "후보", 글, 근거}], # 다섯
글: "맞음 — GC000198 · …", 꼬리표: ["옵션 — 블록에서", "근거 건설 6-1-1"],
품: {엑셀: {인력: {직종: 품}, 장비: [{갈래, 시간}], 자재: [{이름, 수량}]},
우리: {…같은 꼴}, 같음: {"콘크리트공": true, "굴착기": true}},
갈리는칸?: ["측압", "폼타이규격"],
별칭?: {층, 주인, 때, 누가, 처음파일, 규격다름: bool, 근거다름: bool},
뺌?: "우리에 없음" | "안 씀"}
```
- 호표: `{…지금, 맞춤: [체크 제안 수, 줄 수], 통째?: {대상: [..], 글, 체크}, 셈: {끝, 한, 모두, 까닭?}, 만든?: {키, 확인: bool}}` · 파일 보기 `맞춤` 합도 같은 뜻.
- 만들 층 = 길 덧 물음 `?to=<층>&to_owner=`(없으면 파일 층) — 호표 참조 줄 체크 · `만든` 은 그 층을 봄([로직 추가] 모달 = 위 층 파일 · 문서 층에 만듦).
- 상태 정하는 차례(`Match.match_line` · 가) = ① % ② 호표 참조 ③ 노임 ④ 장부 「우리에 없음 · 안 씀」 → 뺌 ⑤ 장부 짝 → 별칭 ⑥ 맞춤셈 판정 ⑦ 셈 전 · 숫자 없음 → B 후보(「셈 중」 · 「숫자 판정 없음」 꼬리표).
- 별칭 체크 = 짝이 있고 다음이 다 아닐 때 — 규격 다름(품명 \| 단위 느슨 열쇠로 찾음) · 근거 다름(엑셀 인용 조항과 짝 로직 조항이 `Basis.clause` < 0.9) · 숫자 판정 「맞음」이 다른 로직.
### 3-4. 미리 셈 — `POST hopyo/{키}/{번}/guess` (가)
- 몸 `{차례, 대상: [{로직 | 자재, 입력값}]}` → `{대상: [(3-3 대상 꼴)], 품: {엑셀, 우리, 같음}, 글}` · 저장 안 함 · 엔진 한 번(ms).
- 지금 몸 `{차례, 대상: {로직}}`(하나)은 목록으로 바꿈.
### 3-5. 별칭 장부 한 줄 — 다 창 (`{층}/실무자료/맞춤.json` 키움)
```
{줄: {"<품명|규격|단위 정규화>": {
원래: {품명, 규격, 단위},
갈래: "짝" | "우리에 없음" | "안 씀",
짝: [{로직 | 자재 | (호표 통째 = 로직), 입력값: {}, 배: 1, 몫: null}],
누가: {id, 이름}, 때: "2026-10-15T23:10:00", 처음파일: "…xls", 엑셀조항: [[책, 번호]],
판: "<마스터 판16>"},
"호표|<이름|규격|단위>": {…직접 줄 호표 통째}}}
```
- 열쇠 = 지금 `Match.line_key :68-76` 그대로 · 찾을 때만 품명 \| 단위 느슨 색인(규격 다름 · 체크 안 함).
- 옛 줄 `{로직 | 자재 | 빼기, 입력값, 환산, 확정}` = 읽을 때 `{갈래 짝, 짝 [{…, 배: 환산}]}` · 빼기 = 「안 씀」으로 바꿔 읽음(파일은 다음 쓰기 때 새 꼴).
- 쓰는 때 = [확인하고 사전조합 만들기] · 장부 화면 [저장] 때만 · 확인한 줄은 모두(이름 같음이라도 — 옵션 · 배를 남기려고) · 같은 열쇠는 덮음 · 처음파일은 있던 값 둠.
- 층 = 만드는 층에 씀 · 읽는 차례 개인 → 회사 → 마스터(`Match.chain :333-341`) · 아래 층이 이김.
### 3-6. [확인하고 사전조합 만들기] 요청 — `POST /layers/{층}/hopyo-build` (다)
```
{층, 주인, 키, 번, 새로: false, # 지금 BuildBody 그대로 + 아래
줄: [{차례: 3, 대상: [{로직: "GC000198", 입력값: {…}, 배: 1, 몫: null}]},
{차례: 4, 대상: [{로직: "GC000206", 입력값: {}, 배: 1}, {로직: "GC000207", 입력값: {}, 배: 1}]},
{차례: 5, 뺌: "우리에 없음"},
{차례: 6, 대상: [{자재: "PM000123", 배: 1}]},
{차례: 7, 대상: [{호표: 12, 배: 1}]}],
통째?: {대상: [{로직, 입력값, 배: 1}]}} # 직접 줄 호표
→ {참조, 이름, 다시씀, 빠진줄: [{차례, 이름, 규격, 수량, 단위, 금액, 까닭}],
비교: {엑셀, 우리, 차이, 까닭}, # 참고 한 줄(판정 아님)
장부: {씀: n}}
```
- 서버 재검사(브라우저 값을 받아 적지 않음 · pydantic `extra="forbid"` — 금액 · 품 칸은 400) — 차례가 그 호표 안 · 대상 하나마다 로직 · 자재 · 호표 중 하나 · 로직 = 돈 로직 · 입력값 이름 = 그 로직 입력 · 배 0 넘고 유한 · 몫 (0, 1] · 자재 = M01 자재품목 · 호표 = 만들 층에 확인된 사전조합이 있음(없으면 409 「제N호표 먼저 확인」) · 줄 500 개 안.
- 빠진 줄 = 몸에 없는 줄(「확인 안 함」) · 뺌(「뺌 — 우리에 없음」) · % 줄(「% 줄 — 우리 로직 덧줄이 셈」).
- `줄` 이 없거나 빈 몸 = 400 「확인한 줄이 없음」(옛 「들어감」 길 없앰).
- 같은 본뜬 사전조합이 있으면 덮음(사용자가 확인한 내용이 앞섬) · 응답 `다시씀` true · 화면 글 「있던 SC-… 를 확인한 내용으로 덮음」.
### 3-7. 사전조합 줄 — 로직 여럿 · 같은 엑셀 이름 (다)
```
{이름: "철근가공조립(현장) Type-Ⅰ", 단위: "ton", 구분: "실무자료", 상세구분: "<파일>",
담은로직: [
{로직: "GC000206", 입력값: {…}, 별칭: "철근가공조립(현장)", 메모: "실무 1ton × 1 · Type-Ⅰ", 수량: {손: 1}},
{로직: "GC000207", 입력값: {…}, 별칭: "철근가공조립(현장)", 메모: "실무 1ton × 1 · Type-Ⅰ", 수량: {손: 1}},
{로직: "GF000211", 입력값: {…}, 별칭: "고임돌채집및운반", 메모: "실무 0.5㎥ × 0.1(인력 10%)", 수량: {손: 0.05}},
{참조: {층, 주인, 키}, 별칭: "…", 메모, 수량: {손}}],
자재: [{코드, 수량: {손}, 별도계상}],
본뜬: {층: "실무자료", 주인: null, 키: "HP-000001 제3호표", 파일, 확인: {누가: {id, 이름}, 때}}} # 확인 = 새
```
- 같은 로직 둘째(줄 사이 · 한 줄 안) = 지금처럼 한 줄 사전조합으로 감싸 참조(`Labor_Doc.clean` 「같은 로직 두 번 금지」 · `Build.build :151-161`).
- `본뜬.확인` = 「이 호표 확인함」 기록 — 확인 저장소 따로 없음 · 옛 실무자료 사전조합(확인 없음)은 [로직 추가]에서 확인 창으로.
- 수량 손 = 엑셀 수량 × 배 · 값은 우리 엔진(`M02_Labor_Ref.Expand`)이 셈.
### 3-8. 길 표 (`M02_Labor_Router.py` · 다)
| 길 | 지금 | 바꿈 |
|---|---|---|
| `POST /layers/{층}/hopyo` | 올리기 | + STmate · 저장 뒤 `Fit.ensure` · DRM 글 |
| `GET /layers/{층}/hopyo/{키}` | 호표 맞춤 n/m | + `셈` · n = 체크 제안 수 |
| `GET /layers/{층}/hopyo/{키}/{번}` | 줄 맞춤 | 3-3 꼴 · `?to=&to_owner=` 만들 층 |
| `POST …/{번}/guess` | 대상 하나 미리 채움 | 3-4 꼴(대상 목록 · 품 비교) |
| `POST /layers/{층}/hopyo-memory` | 줄마다 즉시 확정 | **없앰** |
| `POST /layers/{층}/hopyo-build` | 들어감 줄로 만듦 | 3-6 몸 · 확인 줄만 · 장부 씀 |
| `GET /layers/{층}/hopyo-picks` | 호표마다 n/m | + `확인: {키} \| null`(만들 층에 확인된 사전조합) |
| `GET /layers/{층}/hopyo-ledger` | — | 새 · 이 층 + 위 층 줄 `{열쇠, …(3-5), 층, 주인, 고침: bool}` |
| `PUT /layers/{층}/hopyo-ledger` | — | 새 · `{줄: {열쇠: 값 \| null}}` · 이 층 쓰기만 · 짝 재검사 |
---
## 4. 화면 흐름 (다 창)
### 4-1. 확인 창 — 새 `M02_MasterTemplete_Hopyo_Check.ts` (본 칸 · 모달 둘 다)
- 자리 — 실무자료 본 화면 호표 누름(지금 `View.ts` 호표 하나 화면을 바꿔 끼움) · [로직 추가] 「실무 호표」 모달 · 엑셀 보기 창 펼침에서 [확인 창 열기].
- 머리 — 호표 이름 · 규격 · 단위 · 「제안 n / 줄 m」 · 셈 중이면 「숫자 판정 셈 중 한/모두」(3 초마다 다시 읽음 · 끝나면 멈춤 · 체크는 사용자가 안 만진 줄만 새 제안으로) · 통째 제안(직접 줄 호표)이면 맨 위 한 줄.
- 줄 칸 — [✓] · 갈래 · 엑셀 품명 · 규격 · 수량 · 단위 · 상태(색 — 맞음 초록 · 별칭 파랑 · 다른 점 주황 · 후보 회색) · 우리 로직(이름 · 키 · 몫 · 배 — 여럿이면 칸 안 여러 줄) · 옵션 요약(칸=값 · 출처 꼬리표 · 「못 가름」) · 품 비교(직종 엑셀 → 우리 · 같음 ✓ / 다름 ≠) · 단추.
- 줄 단추 — [고르기](고르기 창) · [옵션](`Labor_Input.ts:53 openLaborInput` 다시 씀 → `guess` 로 품 비교 다시) · [원문 보기](엑셀 인용 조항 · 우리 로직 출처 두 갈래 → `/m02/logic-lib/source` · `Router_Combos.py:117`) · [엑셀에서 보기](엑셀 보기 창 · 그 단가산출 블록 행) · [뺌](까닭 「우리에 없음」 · 「안 씀」).
- 호표 단추 — [맞음 · 별칭 모두 체크] · **[확인하고 사전조합 만들기]**(체크 한 줄 넘을 때 · 누르면 「넣을 줄 n · 빠질 줄 m(까닭별)」 확인 글 → 서버 → 결과 「SC-… 만듦 / 덮음 · 빠진 줄 표 · 값 비교(참고)」 · 화면 잠금은 이때만).
- 캐시 — sessionStorage `m02-hopyo-check:{층}:{주인}:{키}:{번}` = `{체크: {차례: bool}, 대상: {차례: [..]}, 뺌: {차례: 까닭}}` · 조작 즉시 · 만들기 성공하면 지움 · 다시 열면 서버 제안 위에 덮어 보임 · 자동 저장 없음.
- 쓰기 단추 = 만들 층 쓰기 권한만(지금 `canWrite`) · 파일 층은 읽기만 있으면 됨 → 위 층 파일도 내 층으로 확인.
### 4-2. 고르기 창 — 새 `M02_MasterTemplete_Hopyo_Choose.ts` (`View.ts choose` 를 옮김)
- 위 = 엑셀 블록 품(직종 · 장비 · 자재) · 인용 조항 · [엑셀에서 보기].
- 후보 다섯 = 이름 · 키 · 책 · 조항 · 판정 꼬리표(맞음 / 다른 점 / 후보) · 우리 품 · 「근거 맞음」 초록(지금 `basisOk`) · 금액 비 없음.
- 고름 = 한 로직 · 또는 [로직 더함]으로 둘 넘게(조합 · 몫 칸) · 고른 뒤 옵션은 서버 `guess` 가 채움.
- [다른 로직 찾기] = `openLogicPicker`(`M02_MasterTemplete_Labor_Pick.ts:83`)에 찾기 글 미리 넣음 덧(선택 칸 `query` · 엑셀 인용 조항 번호 → 없으면 품명 첫 뜻 낱말 · M01 찾기는 원문번호로도 걸림) · 다른 부르는 곳은 그대로.
### 4-3. 엑셀 보기 창 · [로직 추가] · 좌측 판
- 엑셀 보기 창(`Hopyo_Sheet.ts importPicked :229-278`) — [고른 n개 불러오기] → **[고른 n개 확인하고 만들기]** = ① 고른 호표마다 서버 보기 → 펼침 표(호표 · 넣을 줄 = 체크 제안 · 빠질 줄 · 셈 중 · [확인 창 열기]) ② [이대로 n개 만들기] = 하위 호표부터 차례로 3-6 요청(몸 = 그 호표 체크 줄) · 펼쳐 보이고 한 번 누름 = 사용자 확인(목적다시 7-3).
- [로직 추가] 「실무 호표」(`Hopyo_Pick.ts pickHopyo :82-94`) — `확인` 키가 있으면 그 사전조합을 바로 참조(「확인한 사전조합 SC-…」) · 없으면 확인 창 모달 → 만들기 → `lib.pick` 참조 줄(별칭 = 호표 품명).
- 좌측 판(`Hopyo_Side.ts`) — 파일 목록 아래 **[별칭 장부]** 한 줄 → 본 칸 장부 화면.
### 4-4. 별칭 장부 화면 — 새 `M02_MasterTemplete_Hopyo_Ledger.ts`
- 표 — 엑셀 품명 · 규격 · 단위 | 우리 로직(여럿) · 옵션 · 배 · 몫 | 누가 · 때 · 처음 파일 | 층(내 층 · 회사 · 마스터 — 위 층 회색 읽기만).
- 할 일 — 찾기 · 갈래 거름(짝 · 우리에 없음 · 안 씀) · [고치기](고르기 창 · 옵션 창 · 배) · [지우기] · 위 층 줄 [내 층으로 고쳐 씀] · 고친 것은 sessionStorage `m02-hopyo-ledger:{층}:{주인}` → [저장] 때만 `PUT`.
- 안 둠 — 발주처 칸 · 자동 올림 · 자동 합침 · 쓰인 횟수 · 회사로 올리기 단추.
### 4-5. 없앨 것 (지금 즉시 확정 · 확인 없이 들어감)
| 자리 | 없앰 |
|---|---|
| `View.ts` 줄 [확정] `:393-402` | 즉시 서버 쓰기 → 체크(캐시) |
| `View.ts` [같음 모두 확정] `:279-282` · [근거 맞음 모두 확정] `:283-291 · 525-541` | → [맞음 · 별칭 모두 체크] |
| `View.ts` [사전조합 만들기] `:292-294` | → [확인하고 사전조합 만들기](체크 줄 몸) |
| `Hopyo_Sheet.ts importPicked` | 바로 만들기 → 펼침 뒤 한 번 |
| `Hopyo_Pick.ts pickHopyo` | 바로 만들기 → 확인된 것 참조 / 확인 창 |
| `Api.ts saveHopyoMemory` · 길 `hopyo-memory` | 없앰 |
| 서버 `Match.match_line` `들어감` · `Build.build` 의 `들어감` 줄 고름 | `체크` 제안 · 몸 줄만 |
- 파일 크기 — `View.ts` 614 줄 → 확인 창 · 고르기 창을 새 파일로 빼 400 줄 안 · 새 파일 모두 700 줄 안 · 색 = 공용 토큰.
---
## 5. 읽기 넓힘 (나 창)
### 5-1. STmate — 새 `M02_Hopyo_Stmate.py` (약 350 줄 · C6 `s02_read.read_stmate` + C5 `c01_trees.read_stmate` · `_d_blocks` · `sections_stm` 한 벌)
| 할 것 | 정함 |
|---|---|
| 가름 | 시트 이름에 「일위대가표」 · 「단가산출근거」 둘 다 → STmate · `Excel._sheets` 가 이름 목록(openpyxl `sheetnames`)을 먼저 보고 STmate 시트 + 목록표(노무비 · 중기 · 재료비 · 경비 · 단가산출근거목록표 — 있으면)를 읽음 |
| 호표 | A 열 「제N호표」 줄 → 그 줄 B~Z 열 「BDCOD\|B…」 = 호표 코드 · 다음 줄 A 이름 · B 규격 · D 단위 |
| 줄 | A 이름(빈 · 「합계」 뺌) · B 규격 · C 수량 · D 단위 · 단가 E 계 · G 노무비 · I 재료비 · K 경비(금액 = 다음 열) · M 열 비고 글 · 코드 = 1-4 꼴 첫 것 · T · C 꼴(계 · 소계) 뺌 |
| 갈래 | D 단가산출(참조 단산) · B 호표(참조 일위 · 번 = 호표 코드 표) · L 노임 · X 기계 · M 자재(비고 「별산」 · 규격 「별도계상」 · 값 없음 = 별도계상) · S · W 별도계상 · PR · 단위 % = % · 빈 코드 = 근거글(호표 `근거글`) · 그 밖 기타 |
| 블록 | 단가산출근거 G 열 「BDCOD\|D…」 머리 → 다음 머리 · 「총계」 앞까지 · 글 = B 열 · 자원 = M 열 「X00000_n」(없으면 A 열 「X00000L」 · G 열 「&X00000L&」) · 품 = H 열 · 이름 · 규격 = X 열 「이름 / 규격」(비면 목록표) · 머리 이름 · 규격 · 단위 = 단가산출근거목록표 · 행 = [머리 행, 끝 행] |
| 이음 | 노임 L → 노무비목록표 이름 → 우리 직종(`Basis.trade_names` · 어긋남 0 · C6) · X → 중기목록표 · M → 재료비목록표 · S → 경비목록표 |
| 출처 · 직종 | 블록 글 + 호표 근거글 → `Basis.sources` · 직종 = 자원 L 이름 |
| 검산 | 안 함 — `검산 {어긋남: [], 총괄표: 0}` · 꼴 「STmate」 |
| 결과 | 3-1 꼴(엑셀꼴과 같은 HP) — 맞춤 · 만들기 · 엑셀 보기 창이 꼴을 안 가림 |
- 기대 = 6 파일 올라감(봉화 호표 45 · 영월 23 · 울진대흥 설계 34 · 착공(설계와 같음) · 영덕 9 · 울진소광 25) · 영월 근거 직종 5 → 33 / 36(C4).
### 5-2. 거창 꼴 — `M02_Hopyo_Basis.read :84-125` 고침
- 블록 몸 줄 글 = 산출근거 열 글 · 그 열이 비고 같은 줄 다른 칸에 글이 있으면 산출근거 열 오른쪽 ~ 돈 머리 첫 열 앞(돈 머리 없으면 140 열) 칸을 빈칸으로 이어 붙임(C5 `_row_text` · C6 흩어진 칸) — 북부청은 산출근거 열에 글이 다 있어 그대로.
- 블록에 `글` 저장(3-1) · 기대 = 거창 단산 21 줄 출처 17 · 직종 21 · 몫 21(C5).
### 5-3. 그 밖 읽기 손질
| 파일 | 할 것 |
|---|---|
| `M02_Hopyo_Excel.py` | `_REF` 넓힘(1-4) · 이름으로 이음 · `kind_of :132-149` 노임(단위 인 + 우리 직종) · `read_book` 블록 `글` · `꼴` · DRM(파일 머리 「DRMONE」 · PK · OLE 아님) → 400 「문서 보안(DRM)으로 잠긴 파일 — 보안을 푼 사본으로 저장해 올림」 |
| `M02_Hopyo_Basis.py` (B · 인용 조항 맞대기 손질) | `_clause :166-176` → 공개 `clause` · 윗마디 0.9(엑셀 「6-2」 ↔ 우리 「6-2-2」 · 「6-2-3」) · `sources :60-71` 조항 오타(「13-114」 → 「13-11-4」) |
| `M02_Hopyo_Grid.py` | `SHEETS :22` 에 「일위대가표」 · 「단가산출근거」 · 격자 호표 · 단산 행 = HP 행 |
---
## 6. 창 나눔 · 차례
### 6-1. 파일 경계
| 파일 | 창 | 새 / 고침 | 몫 |
|---|---|---|---|
| `M02_Hopyo_Finger.py` | 가 | 새 | 엑셀 지문 · 식 풀기 · 몫 · 기계 줄 · 직접 줄 호표 |
| `M02_Hopyo_Ours.py` | 가 | 새 | 우리 지문 · 판 열쇠 · 캐시 · 데움 · 엔진 셈 캐시 |
| `M02_Hopyo_Option.py` | 가 | 새 | 옵션 공간 · 거꾸로 찾기 · 견줌 |
| `M02_Hopyo_Fit.py` | 가 | 새 | 순위 · 판정 · 블록 셈 · 뒤 셈 · 맞춤셈 캐시 |
| `M02_Hopyo_Combine.py` | 가 | 새 | 조합 풀이 · 섞인 줄 몫 먼저 |
| `M02_Hopyo_Match.py` | 가 | 고침 | `match_line` 새 상태 · 체크 제안 · `b_order`(접힘 없음 · 같음 근거 검사) · `view` · `guess` 꼴 · 장부 부르기 · `confirm` 없앰 |
| `M02_Hopyo_Stmate.py` | 나 | 새 | STmate 읽기 |
| `M02_Hopyo_Excel.py` · `M02_Hopyo_Grid.py` | 나 | 고침 | 5-3 |
| `M02_Hopyo_Basis.py` | 나 | 고침 | 블록 글 · 거창 이음 · `clause` · 오타 |
| `M02_Hopyo_Ledger.py` | 다 | 새 | 장부 읽기(옛 꼴 바꿔 읽음) · 찾기(열쇠 · 느슨) · 쓰기 · 층 · 재검사 |
| `M02_Hopyo_Build.py` | 다 | 고침 | 3-6 몸 · 로직 여럿 · 배 · 몫 · 통째 · 장부 씀 · `본뜬.확인` · `confirmed(folder)` |
| `M02_Labor_Router.py` | 다 | 고침 | 3-8 길 · 올리기 뒤 `Fit.ensure` · 시동 `Ours.warm()` · picks 에 `확인` |
| TS 새 `Hopyo_Check` · `Hopyo_Choose` · `Hopyo_Ledger` / 고침 `Hopyo_View` · `Hopyo_Sheet` · `Hopyo_Pick` · `Hopyo_Api` · `Hopyo_Side` · `Hopyo.css` · `Labor_Pick`(`query` 선택 칸만) | 다 | | 4 장 |
- 같은 파일은 한 창만 — `Match.py` = 가(B 의 Match 몫 포함) · `Basis.py` · `Excel.py` = 나 · `Labor_Router.py` · `Build.py` = 다.
- 한 창이 남의 함수를 불러야 하면 약속(6-2)으로만 — 남의 파일 고침 없음.
- 공용 파일(`main.py` · `ui_template/`) 안 고침 · `M02_MasterTemplete_Store` · `M02_Labor_Ref` · `M02_Labor_Doc` · `M02_Hopyo_Store` · M01 · `resources/master_data` 안 고침(HP 덧칸은 `read_book` 결과에 실려 `Store.save` 가 그대로 씀).
### 6-2. 서로 약속 (자료 꼴은 3 장)
| 약속 | 낸 창 → 쓰는 창 | 꼴 |
|---|---|---|
| HP 블록 · 줄 꼴 | 나 → 가 · 다 | 3-1(`단산[].글` · `자원` · `출처` · `직종` · 줄 `참조` · `갈래` 노임) |
| `Basis.clause(theirs, our) -> (점수, 조항 글, 꼬리표)` · `Basis.sources` 오타 | 나 → 가 | 윗마디 0.9 · 같음 1 · 앞 마디 0.5 |
| `Fit.ensure(folder, key)` · `Fit.result(folder, key)` · `Ours.warm()` | 가 → 다(길) | 2-10 |
| `Match.view` · `Match.guess` 응답 | 가 → 다(화면) | 3-3 · 3-4 |
| `Ledger.recall(line, folder) -> 장부 줄 \| None`(+ 층 · 규격다름) · `Ledger.key(line)` | 다 → 가 | 3-5 |
| `Build.confirmed(folder) -> {본뜬 키: SC 키}` | 다 → 가(호표 참조 줄 체크 · `만든`) · 다(picks) | 3-7 |
### 6-3. 차례
| 단계 | 가 | 나 | 다 |
|---|---|---|---|
| 1(나란히) | Finger → Ours → Option → Fit → Combine · 시험은 북부청 실물(나 1a 전엔 시험 도우미가 단가산출 시트 글을 직접 읽음) | 1a 블록 글 HP · `clause` · 오타(작음 · 먼저 push · 가 바탕) → 1b STmate · 거창 · Grid · DRM | 1a `Ledger.py` 뼈대(recall · key) 먼저 push → Build 새 몸 · 장부 · 길 · 화면(3-3 꼴 대역으로) |
| 2 | `match_line` 새 상태 · 뒤 셈 · view · guess 꼴 → push | STmate 실물 끝까지(올림 → 셈 → 상태) · 영월 정답 34 줄 | 화면을 실물 응답에 붙임 · [로직 추가] · 엑셀 보기 창 · 장부 화면 · 옛 길 · 단추 없앰 |
| 3(브레인) | 합치기 · 대상 시험 · 골든 · 브라우저 검증(7-4) · 아티팩트 | | |
- 가 · 다가 가장 큼(새 파일 다섯 · 화면 셋) · 나는 1b 끝나면 가의 골든 시험 · 다의 화면 시험 거듦(브레인 배정).
- 손에 걸리는 곳 — 다의 화면은 가의 3-3 꼴이 굳어야 실물로 붙음 → 가는 1 단계 끝에 응답 꼴 대역 JSON(`resources/tester/fixtures/m02_hopyo_view_sample.json`)을 먼저 올림.
---
## 7. 시험 · 완료 기준
### 7-1. 시험 파일
| 시험 | 창 | 무엇 |
|---|---|---|
| `test_m02_hopyo_finger.py`(새) | 가 | 식 풀기 일곱 꼴(「273,540 × 3인 ÷ 55㎥ × 2」 → 0.10909 · 「노무비 : 59,020 ÷ 23.1 × 90%」 → 시간 0.03896 · 율 0.9 · 「0.3인/㎥ × 10%」 → 품 0.03 · 밑품 0.3 · 바꿔 쓴 식 · 「× 2회」 · 품할증 · 못 풂 = 역산) · N001 합판 지문(형틀목공 0.1111 · 보통인부 0.0444 · 조건 4회 · 보통 · 조항 건설 6-3-1) · 고임돌 섞음 율 · 몫 = C5 `sections` 와 같음 · STmate 자원 지문 · 기계 줄 |
| `test_m02_hopyo_ours.py`(새) | 가 | 판 열쇠 같음 · 캐시 쓰기 · 읽기 · 준비 전 None · GC000211 「유형=보통」 형틀목공 0.112 · 섞기 전 변형 · master_data sha256 그대로 |
| `test_m02_hopyo_option.py`(새) | 가 | N000 → GC000198 소량감률 50 「하나만」 · N001 → 유형 보통 · 갈리는 칸 측압 · 폼타이규격 · N013 → R 맞음 · 구조물터파기(인력100%) → 없음 · 다른 점 · 펌프차 41m → 기계 칸 |
| `test_m02_hopyo_fit.py`(새) | 가 | 순위(같음 규칙 · 딱 맞음이 같음을 이김) · 판정 셋 · 조합(철근 = GC000206 + GC000207 · 고임돌 → GF000211 × 0.1) · 뒤 셈 캐시 · 「셈 중」 |
| `test_m02_hopyo_golden.py`(새 · 느림) | 가(+ 나 영월) | 정답 표 지표(7-3) · 우리 지문 캐시 있으면 빠름 · 없으면 처음 ≈ 2 분 |
| `test_m02_hopyo_match.py`(고침) | 가 | 새 상태 · 체크 제안 · 별칭 체크 규칙 셋 · % · 호표 · 노임 · 숫자 없음 · 옛 「들어감」 시험 바꿈 |
| `test_m02_hopyo_stmate.py`(새) | 나 | 6 파일 올라감 · 호표 수 · 코드 갈래 · 노임 이름 어긋남 0 · 블록 자원 품 · 이음 열쇠 O 열 · 장수 · 진안 400 글 |
| `test_m02_hopyo_excel.py` · `_basis.py` · `_grid.py`(고침) | 나 | 이음 넓힘 · 노임 갈래 · 블록 글 · 거창 출처 17 · 직종 21 · 윗마디 · 오타 · STmate 격자 |
| `test_m02_hopyo_ledger.py`(새) | 다 | 옛 꼴 바꿔 읽음 · 새 꼴 · 층 차례 · 느슨 찾기 · 우리에 없음 · 안 씀 · 위 층 읽기만 · PUT 재검사 · 권한 |
| `test_m02_hopyo_build.py`(고침) | 다 | 몸 줄만 들어감 · 몸 없음 400 · 빠진 줄 까닭 · 로직 여럿 같은 별칭 · 같은 로직 감쌈 · 재검사 400(없는 로직 · 모르는 입력 · 배 ≤ 0 · 금액 칸) · 하위 호표 409 · 장부 씀 · `본뜬.확인` · 덮음 |
| `test_m02_hopyo_screen.py` + `helper_m02_hopyo_check.cjs`(새) | 다 | 확인 창 도우미(체크 → 몸 · 캐시 키 · 최신 응답만) · 옛 단추 · `saveHopyoMemory` 없음 · `buildHopyo(` 는 확인 창에서만 · 700 줄 · `npm run typecheck` |
- 돌리는 법 = `./venv/Scripts/python.exe -m pytest resources/tester/<고친 시험> -q` · 전체 시험은 사용자가 시킬 때만.
### 7-2. 정답 표 옮김 — 옮김(브레인 · 1 단계 앞)
- 자리 = `resources/tester/fixtures/m02_hopyo_golden.json` · 꼴 `[{id, 파일, 이름, 규격, 단위, 단산 | 코드, 무리: "사람" | "자동" | "씨앗" | "자동+씨앗", 정답: [로직], 허용: [로직], 짐작: bool}]`.
- 재료 = 스크래치패드 `338_실험자료/C1/labels.py` · `labels_y.py`(사람 136 = 정답 126 + 우리에 없음 10) + `338_실험자료/K5/targets.json`(새 파일 51) + 단산 번(`338_실험자료/C1/out_items.json`) · STmate 코드(`338_실험자료/C6/data_*.json`) · 옮김 스크립트는 스크래치패드에(저장소엔 JSON 만).
- 줄 찾기 = 시험이 실물 엑셀을 지금 읽기로 읽어 (파일 · 이름 · 규격 · 단위 · 단산 번 | 코드)로 맞춤 — 엑셀은 저장소 `resources/knowledge/original/실무문서/` (git 안 · 읽기만).
- 호표 「조합 맞음」 정답 = 시험이 줄 정답을 호표로 모음(C5 `c07_hopyo` 와 같은 규칙 · 아래에서 위로).
### 7-3. 기준 수치 (다시 재고 ±1 줄 · 바꾸면 브레인 · 사용자 판단)
| 지표 | 기준 | 실험 값 |
|---|---|---|
| 북부청 92 줄 1순위 엄격(씨앗 켬 · 조합 전) | ≥ 88% (81 줄) | 82 줄 — 임도 91.8 · 사방 86.0(C4 지문 + 묶음 ① β=1) |
| 사람 126 줄 1순위 엄격(조합 뒤 · 영월 읽기 뒤) | ≥ 92% (116 줄) | 92.9(336 재검토 ③) |
| 사람 126 줄 다섯 안 엄격 | ≥ 96% (122 줄) | 96.8 |
| 씨앗 끔 1순위 | ≥ 88% (111 줄) | 89.7 (113) |
| 「맞음」 표시(사람 126) | ≥ 55 줄 · 틀림 ≤ 2 | 58 · 1(K5) |
| 「다른 점」 틀림(사람 126) | ≤ 2 | 25 · 1 |
| 옵션까지(1위가 정답인 줄 중 엑셀 품 그대로) | ≥ 50 줄 | 56 / 103 |
| 새 파일 51 「맞음」 틀림 | ≤ 3 | 21 · 2 |
| 조합 맞음(덮을 수 있는 83 호표 · T1) | ≥ 72% (60) | 75.9 (63) |
| 확인 없이 들어가는 줄 | 0 — 몸 없는 만들기 400 · 몸 밖 줄은 사전조합에 없음 · `hopyo-memory` 404 · 화면에 바로 만들기 없음 | — |
| STmate 6 파일 · 거창 | 200 · 호표 수 5-1 · 거창 출처 17 · 직종 21 · 장수 · 진안 400 글 | C5 · C6 |
| 원본 안 고침 | `resources/master_data` *.json · 일위대가조합.json sha256 시험 앞뒤 같음(지금 `master_hash` 도우미) · M01 쓰기 없음 | — |
| 셈 무게 | 우리 지문 캐시 없음 ≤ 180 초(뒤) · 있음 ≤ 3 초 · 북부청 임도 맞춤 셈 시간 기록(목표 ≤ 5 분 · 화면 잠금 없음) | 110 초(C4) |
- 「검증완료」 한 줄 = 위 시험 통과 + 수치 한 줄(1순위 · 다섯 안 · 맞음 · 틀림) · 검증보고서 파일 없음.
### 7-4. 브레인 브라우저 검증 목록 (끝에 · ORCA · 검증 층 · 조작한 값 되돌림)
1. 북부청 임도 올림 → 「숫자 판정 셈 중 n/m」 → 끝 · 화면 안 멈춤.
2. 레미콘 소량 줄 = 「맞음 — GC000198 · 소량감률 50」 체크됨 · 품 비교 콘크리트공 0.1091 = 0.1091 · 굴착기 0.29.
3. 합판 4회 = 「맞음 + 측압 · 폼타이규격 못 가름」 · 구조물터파기 = 「다른 점 — 특별인부 0.354 · 0.370」 체크 안 됨 · [옵션]으로 토질 고르면 품 비교가 서버 값으로 바뀜.
4. 고임돌 = GF000211 × 0.1(인력 10%) · 철근가공조립 = 두 로직 · 같은 별칭.
5. [확인하고 사전조합 만들기] → 사전조합 줄 = 체크한 줄만 · 빠진 줄 까닭 · 장부 줄 생김 · 금액은 우리 셈.
6. 사방 올림 → 같은 이름 줄 「별칭」 체크됨 · 규격만 다른 줄은 체크 안 됨.
7. [로직 추가] 실무 호표 → 확인된 것 = 바로 참조 / 아닌 것 = 확인 창 모달 → 참조 줄.
8. 엑셀 보기 창 [고른 3개 확인하고 만들기] → 펼침 → 한 번.
9. STmate 영월 올림 200 · 호표 23 · 줄 상태 · 직접 줄 호표 통째 제안.
10. 별칭 장부 — 보기 · 고치기 · 지우기 · 위 층 읽기만 · [저장] 때만 씀.
11. 옛 단추([확정] · [같음 모두 확정] · [근거 맞음 모두 확정]) 없음 · 확인 없이 들어감 0.
12. 되돌림 — 만든 사전조합 · 장부 줄 · 올린 파일 지움(검증 층만).
---
## 8. 이번 밖 · 정함
### 8-1. 이번 밖
- M01 로직 별칭 칸 엮기(찾기용 · 사람 손) · 발주처 칸 · 별칭 자동 올림(개인 → 회사 → 마스터) · [회사로 올리기] 단추 · 자동 합침 · 쓰인 횟수.
- 우리에 없는 로직 만들기(사용자 확인 뒤 시스템 관리자가 M01 [복사해서 만들기]) · 우리 로직 고치기(원문 근거 + 사용자 확인 뒤 따로).
- 노임 줄 그릇(336 정할 것 7 — 「노임 · 그릇 없음」 표시만) · 기억 c 열쇠(로직 지문 열쇠) · 같은 열쇠 다른 답 쌓기 · 마스터 판 바뀜 알림 세부 · 사전조합에서 고친 별칭 · 옵션 → 장부 되돌림.
- 문서 보안(DRM) 풀기 · STmate STC 직접 읽기 · 외부 AI · 엑셀 하이퍼링크 · HYPERLINK 식 읽기.
- 「옛 판」 · 「다른 책」 까닭 자동 글(숫자 사실만 · 까닭은 [원문 보기]).
### 8-2. 정함 — 브레인 · 사용자가 바꿀 수 있음
1. 이음 열쇠 = 엑셀꼴 비고 번호 글(넓힘) · STmate 줄 코드(O 열 꼴 찾기) · 하이퍼링크 안 읽음.
2. 「맞음」 = 숫자(R 허용 · 엑셀 몫 다 덮음) · 체크 제안 = 맞음 · 별칭(조건 셋) · 나머지 체크 안 함 · 이름 같음 · 씨앗은 상태가 아니라 순위 덤(「같음」 상태 없앰).
3. 같은 일 다른 책 = 숫자(품)가 가름 · 숫자가 같으면 인용 책(336 정할 것 1 풀림 그대로).
4. 섞인 줄 = 인력 몫 로직 × 몫이 기본 · 기계 몫 = 우리 일 로직이 맞으면 덧 · 아니면 「기계 시간당 기계경비」 후보(체크 안 함 · 「시간 = 엑셀 값」) · 기본 기대는 336 T1(인력 몫만).
5. 직접 줄 호표 = 호표 통째 로직 하나 제안(호표 단위 체크).
6. 별칭 장부 = `맞춤.json` 키움 · 확인 때만 씀 · 만드는 층 · 옛 기억은 바꿔 읽음.
7. 캐시 = 저장소 `tmp/m02_hopyo/`(우리 지문 · 맞춤셈 · 마스터 판 열쇠) · 셈 = 올릴 때 뒤 스레드 하나 · 시동 땐 디스크 읽기만.
8. 확인 다시 = 같은 본뜬 사전조합 덮음(`본뜬.확인`) · 옛 실무 사전조합은 [로직 추가]에서 확인 창으로.
9. 엑셀 보기 창 여러 호표 = 펼쳐 보인 뒤 한 번 누름 = 사용자 확인.
10. 옛 HP 파일 = 숫자 판정 없음 · 다시 올리면 됨(꼬리표 글) · 지금 storage 실무자료 셋 · 맞춤.json 2 줄뿐.
11. 정답 표 = 저장소 시험 자료(JSON)로 옮김 · 기준 수치는 7-3(±1 줄).
12. 무게 · 문턱 = 336 · K5 값 그대로(2-9) · 한 파일로 다시 맞추지 않음.