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"} # 낱말이 제목에 든 것은 종전대로