From 9e240d886daa00ef3da2400c4068e881a339940f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 17:08:14 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(b08):=20=EA=B3=A0=EB=A5=B4=EA=B0=9C=20?= =?UTF-8?q?=ED=9B=84=EB=B3=B4=EB=A5=BC=20=EB=91=90=20=EA=B8=80=EC=9E=90=20?= =?UTF-8?q?=EC=9D=B4=EC=83=81=20=EA=B2=B9=EC=B9=9C=20=EC=A1=B0=EA=B0=81?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=84=93=ED=9E=98(=E2=91=A0)=20=E2=80=94?= =?UTF-8?q?=20=EB=82=B1=EB=A7=90=20=EC=A0=84=EB=B6=80=20=EB=93=A0=20?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=20=EB=A8=BC=EC=A0=80,=20=EA=B7=B8=EB=8B=A4?= =?UTF-8?q?=EC=9D=8C=20=EA=B2=B9=EC=B9=9C=20=EA=B8=80=EC=9E=90=20=EC=88=98?= =?UTF-8?q?=20=EC=88=9C=20=C2=B7=20=EC=A1=B0=EA=B0=81=20=EB=B9=84=EA=B5=90?= =?UTF-8?q?=EB=8A=94=20=EA=B8=80=EC=9E=90=C2=B7=EC=88=AB=EC=9E=90=EB=A7=8C?= =?UTF-8?q?(=EA=B4=84=ED=98=B8=20=EC=95=88=20=EC=85=88)=20=C2=B7=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B4=EB=8A=94=20=EA=B2=83=EC=9D=80=20=EC=82=AC?= =?UTF-8?q?=EB=9E=8C(=EB=B3=84=EC=B9=AD=ED=91=9C=EB=A1=9C=20=EC=95=88=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실측(936be972 단가표): 고정형 줄 이름 그대로 찾기 — 종전 8줄 전부 후보 0 → 깬잡석채집 50건(야면석 채집…) · 고임돌채집 14(막돌 채집…) · 레미콘타설(장비) 22(레디믹스트콘크리트 타설 먼저) · 기초다짐 및 뒤채움 16 · PVC파이프 0(자재라 자원 갈래) · 두 번째부터 40~80ms Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- .../B08_Quantity_Engine_StructureUnitPrice.py | 71 ++++++++++++++----- .../tester/test_b08_price_search_fragments.py | 61 ++++++++++++++++ 2 files changed, 113 insertions(+), 19 deletions(-) create mode 100644 resources/tester/test_b08_price_search_fragments.py diff --git a/B08_Quantity/B08_Quantity_Engine_StructureUnitPrice.py b/B08_Quantity/B08_Quantity_Engine_StructureUnitPrice.py index ef8064e6..85ebe29f 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructureUnitPrice.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureUnitPrice.py @@ -16,6 +16,7 @@ from __future__ import annotations +import re from collections.abc import Callable, Iterable from dataclasses import dataclass from decimal import Decimal @@ -362,32 +363,64 @@ def save_manual( return merged +#: 조각 후보의 최소 겹침 글자 수(2026-09-14 브레인 판정 ① — 「채집」·「타설」이 걸리게). +MIN_FRAGMENT = 2 + + +def _longest_common(a: str, b: str) -> int: + """두 글의 가장 긴 공통 조각 길이.""" + best = 0 + previous = [0] * (len(b) + 1) + for char in a: + current = [0] + for index, other in enumerate(b): + current.append(previous[index] + 1 if char == other else 0) + best = max(best, max(current)) + previous = current + return best + + def search_titles(book: Any, query: str, kind: str, limit: int = 50) -> list[dict[str, Any]]: - """고르개 — 단가표에서 낱말이 **모두** 든(코드·이름·규격) 항목. 단가가 섰는지도 함께.""" + """고르개 — 단가표 항목 후보. 단가가 섰는지도 함께. + + 낱말이 **모두** 든(코드·이름·규격) 항목이 먼저, 그다음 **두 글자 이상 조각이 겹친** 항목을 + 겹친 글자 수 순으로(①). 고정형 줄 이름(깬잡석채집)과 단가표 제목(막돌 채집)은 낱말이 달라 + 전부 포함 방식으로는 후보가 0 이었음. ⚠ 후보만 — 고르는 것은 사람(명세 2장 · 별칭표로 안 맞춤). + """ from B09_Estimation.B09_Estimation_PriceBook import PriceBookError kinds = SEARCH_KINDS[kind] words = query.lower().split() - found: list[dict[str, Any]] = [] - for title in book.titles.values() if words else (): - if len(found) >= limit: - break + # 조각 비교는 글자·숫자만 — 괄호 「(장비)」 같은 기호가 겹친 글자 수를 부풀리지 않게. + compact = re.sub(r"[\W_]+", "", query.lower()) + scored: list[tuple[int, int, int, Any]] = [] + for order, title in enumerate(book.titles.values() if words else ()): if title.kind.value not in kinds: continue text = f"{title.code} {title.name} {title.spec}".lower() if all(word in text for word in words): - try: - money = book.resolve(title.code) - total: float | None = float(money.material + money.labor + money.expense) - except PriceBookError: - total = None - found.append( - { - "code": title.code, - "name": title.name, - "spec": title.spec, - "unit": _unit(title.unit), - "price": total, - } - ) + scored.append((1, len(compact), order, title)) + continue + overlap = _longest_common( + compact, re.sub(r"[\W_]+", "", f"{title.name}{title.spec}".lower()) + ) + if overlap >= MIN_FRAGMENT: + scored.append((0, overlap, order, title)) + scored.sort(key=lambda item: (-item[0], -item[1], item[2])) + found: list[dict[str, Any]] = [] + for _whole, _overlap, _order, title in scored[:limit]: + try: + money = book.resolve(title.code) + total: float | None = float(money.material + money.labor + money.expense) + except PriceBookError: + total = None + found.append( + { + "code": title.code, + "name": title.name, + "spec": title.spec, + "unit": _unit(title.unit), + "price": total, + } + ) return found diff --git a/resources/tester/test_b08_price_search_fragments.py b/resources/tester/test_b08_price_search_fragments.py new file mode 100644 index 00000000..88fc0734 --- /dev/null +++ b/resources/tester/test_b08_price_search_fragments.py @@ -0,0 +1,61 @@ +"""고르개 후보 — 두 글자 이상 조각이 겹치면 띄움 (2026-09-14 브레인 판정 ①). + +실측(936be972): 고정형 줄 이름 그대로(깬잡석채집·고임돌채집…)로 찾으면 **후보 0** — 단가표 제목 +(「막돌 채집」·「레디믹스트콘크리트 타설」)과 낱말이 달라 「낱말 전부 포함」 방식으로는 영영 안 맞음. +⇒ 겹친 조각(「채집」·「타설」)으로 넓게 띄우고 **겹친 글자 수 순**. 고르는 것은 사람(별칭표로 안 맞춤 · 명세 2장). +""" + +from __future__ import annotations + +from decimal import Decimal +from types import SimpleNamespace + +from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import search_titles +from B09_Estimation.B09_Estimation_PriceBook import Money3 + + +def _title(code: str, name: str, spec: str, unit: str) -> SimpleNamespace: + return SimpleNamespace( + code=code, name=name, spec=spec, unit=unit, kind=SimpleNamespace(value="unit_price") + ) + + +class Book: + titles = { + t.code: t + for t in ( + _title("B-1", "막돌 채집", "㎡당", "㎡"), + _title("B-2", "레디믹스트콘크리트 타설", "무근구조물", "㎥"), + _title("B-3", "고임돌 채집", "기계", "㎥"), + _title("B-4", "견치돌 찰쌓기", "뒷길이 35㎝", "㎡"), + _title("B-5", "표토 제거", "", "㎡"), + ) + } + + def resolve(self, code: str) -> Money3: + return Money3(Decimal(1), Decimal(0), Decimal(0)) + + +def _codes(query: str) -> list[str]: + return [item["code"] for item in search_titles(Book(), query, "work")] + + +def test_이름_그대로도_겹친_조각으로_후보가_뜬다() -> None: + assert _codes("깬잡석채집")[:1] and set(_codes("깬잡석채집")) >= {"B-1", "B-3"} + assert _codes("레미콘타설(장비)") == ["B-2"] + assert _codes("깬잡석찰쌓기") == ["B-4"] + + +def test_겹친_글자_수_순() -> None: + # 「고임돌채집」 — 고임돌 채집(5자 겹침) · 막돌 채집(「돌채집」 3자) 차례 + assert _codes("고임돌채집")[:2] == ["B-3", "B-1"] + + +def test_한_글자만_겹치면_안_띄운다() -> None: + assert "B-5" not in _codes("깬잡석채집") # 겹침 없음 + assert "B-4" not in _codes("고임돌채집") # 「돌」 한 글자만 겹침 + + +def test_종전_낱말_검색도_그대로() -> None: + assert _codes("찰쌓기 35") == ["B-4"] + assert set(_codes("돌")) == {"B-1", "B-3", "B-4"} # 낱말이 제목에 든 것은 종전대로 From 31decd15582343ae6a49a7d88ce336829d4cc9df Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 17:10:26 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(b08):=20=EB=82=A8=EA=B8=B8=20=EC=84=B1?= =?UTF-8?q?=EB=B6=84=20=EB=AA=A9=EB=A1=9D=EC=97=90=20=EA=B8=B0=EC=B4=88?= =?UTF-8?q?=EC=9E=A1=EC=84=9D=C2=B7=EC=B1=84=EC=A7=91=EC=84=9D=20=EB=8D=94?= =?UTF-8?q?=ED=95=A8(=E2=91=A1)=20=E2=80=94=20=EC=8B=A4=EB=AC=B4=20?= =?UTF-8?q?=EB=B2=BD=C2=B7=EB=8F=8C=EC=8C=93=EA=B8=B0=20=ED=98=B8=ED=91=9C?= =?UTF-8?q?=2057=20=EC=A0=84=EC=88=98:=20=EA=B8=B0=EC=B4=88=EC=9E=A1?= =?UTF-8?q?=EC=84=9D=200/57=20=C2=B7=20=EC=B1=84=EC=A7=91=EC=84=9D(?= =?UTF-8?q?=EC=82=AC=ED=86=A0=20=EA=B3=B5=EC=A0=9C=20=EB=B6=80=ED=94=BC)?= =?UTF-8?q?=EC=9D=80=20=ED=98=B8=ED=91=9C=20=EC=B1=84=EC=A7=91=20=EC=A4=84?= =?UTF-8?q?(=ED=92=88=20=C2=B7=2041/57)=EA=B3=BC=20=EB=8B=A4=EB=A5=B8=20?= =?UTF-8?q?=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 936be972 실측: 고정형 가져오기 → 기초잡석이 호표 밖 몫으로 따로 섬(내역 6-3 3.5 → 7.7㎥ · +515,466) · 채집석 17.76㎥ 사토 공제 그대로 · 되돌려 전부 같음 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- B08_Quantity/B08_Quantity_Engine_StructureTemplate.py | 4 +++- resources/tester/test_b08_template_keep_earthwork.py | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py b/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py index ea98851b..895eafcd 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py @@ -156,7 +156,9 @@ _PER_LENGTH_UNITS = frozenset({"m"}) #: ㉯ 양식에 **그 이름 줄이 없으면 전개 값을 남기는** 성분(2026-09-14 브레인 판정 · 목록으로 못박음). #: STmate 호표(고정형)는 토공·버림을 안 품음 — 통째로 갈음하면 구조물터파기 85.25→25㎥(−6,037,712원 실측). #: 목록 밖 성분은 양식으로 갈음 · 목록 안이라도 양식이 그 줄을 품으면 양식 값(겹쳐 세지 않음). -KEEP_ENGINE_COMPONENTS = ("터파기", "되메우기", "잔토처리", "버림콘크리트") +#: ② 실무 엑셀 벽·돌쌓기 호표 57 전수 — 기초잡석 0/57(「기초다짐 및 뒤채움」 4 은 다른 것) · +#: 채집석 = 사토에서 빼는 돌 부피(haul_deduction)라 호표의 채집 줄(품 · 41/57)과 다른 것. +KEEP_ENGINE_COMPONENTS = ("터파기", "되메우기", "잔토처리", "버림콘크리트", "기초잡석", "채집석") KEPT_ROW_REASON = "전개 값 그대로 — 양식이 이 줄을 안 품음(토공·버림은 전개가 셈)" diff --git a/resources/tester/test_b08_template_keep_earthwork.py b/resources/tester/test_b08_template_keep_earthwork.py index 7eaf71d3..a93f84fa 100644 --- a/resources/tester/test_b08_template_keep_earthwork.py +++ b/resources/tester/test_b08_template_keep_earthwork.py @@ -60,13 +60,17 @@ def _fixed() -> dict: def test_목록이_코드에_못박혀_있다() -> None: assert set(KEEP_ENGINE_COMPONENTS) >= {"터파기", "되메우기", "잔토처리", "버림콘크리트"} + # ② 원문 빈도(벽·돌쌓기 호표 57) — 기초잡석 0/57 · 채집석(사토 공제 부피)은 호표 채집 줄(품)과 다른 것 + assert {"기초잡석", "채집석"} <= set(KEEP_ENGINE_COMPONENTS) def test_고정형을_가져와도_토공_버림_성분은_남는다() -> None: engine = build_table([WALL], NAMES, {}, {}, None, use_templates=False)["structures"][0] fixed = build_table([WALL], NAMES, {}, {}, None, structure_templates={"masonry_wet": _fixed()}) components = {c["name"]: c for c in fixed["structures"][0]["components"]} - for name in ("터파기", "되메우기", "잔토처리", "버림콘크리트"): + engine_names = {c["name"] for c in engine["components"]} + assert {"기초잡석", "채집석"} <= engine_names # 시험 벽에서 둘 다 서야 보존을 잼 + for name in ("터파기", "되메우기", "잔토처리", "버림콘크리트", "기초잡석", "채집석"): before = next(c for c in engine["components"] if c["name"] == name) assert components[name]["amount"] == before["amount"], name assert "깬잡석찰쌓기" in components and "돌" not in components # 목록 밖은 양식으로 갈음