Files
Aislo/M02_MasterTemplete/M02_Hopyo_MatSame.py
T
eomsangdonandClaude Opus 5.5 bb243af270 feat(817-1): 불러오기 재료 찾기 — 표기만 다름 자동 이음 · 같은 이름 후보 좁힘 · 별도계상 표지
- 새 M02_Hopyo_MatSame — ㉮ 이름 같은 품목(빈칸 · 괄호 차례 · 등급 · 점 · 소리 표기 넷) + 규격 수 묶음 같음(차례 · 단위 배 · 한쪽 듦) + 규격 낱말 안 부딪힘 + 우리 품목 하나 → 이음 · ㉯ 이름 같은 묶음 = 규격 어근 품은 줄 · 덧말 적은 줄 1 순위 · 규격 수 같은 후보 먼저
- M02_Hopyo_Row 재료 길 = 바탕 이름 유일 짝 뒤 ㉮ › 옛 후보 하나 › ㉯ + 옛 후보
- M02_San_Link — 별도계상 줄 = 요소 잇기 없음 + 표지 「별도계상 · 품목은 프로젝트에서」 · 못 이은 재료 까닭에 1 순위 이름 · 규격
- 전수(원파일 2,220 · 엑셀 1,021) 재료 이음 88.0 → 93.5 % · 새 이음 112 줄 51 종 · 끊긴 이음 0 · 금액 그대로 · 치즐 0.7 이 엉뚱한 0.2 줄에서 0.7 줄로 옮김
- 시험 test_817_1_material_find(과녁 100 줄 · 틀린 이음 막기 · 별도계상 표지)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 20:24:56 +09:00

257 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""M02 불러오기 재료 표기 다름 찾기(PLAN 817-1 · 요구 773-5) — `M02_Hopyo_Row._m01_picks` 재료 길이 부름.
서버 단독 · 읽기만 · 별칭 · 정확 일치 · 바탕 이름 유일 짝(`M02_Hopyo_Material.sure`)이 못 찾은 줄만.
- `same(줄)` ㉮ 이음 = 이름이 같은 품목(표기만 다름) · 규격 수 묶음 같음 · 규격 낱말 안 부딪힘 · 그런 우리
품목 하나(지역 · 관급/사급 줄은 한 품목) → 그 자재 줄 · 아니면 None.
- `narrow(줄)` ㉯ 후보 좁힘 = 이름 같은 품목 묶음을 규격 낱말(어근 「초본류」 → 초본)을 품은 줄 · 수 묶음
닮은 줄 · 덧말 적은 줄 차례로(자동 이음 아님 · 1 순위 맨 위).
- `spec_first(후보, 줄)` = 후보 가운데 규격 수 묶음(둘 이상)이 줄과 같은 것 먼저.
이름 같음: 바탕(괄호 · 지역 · 관급/사급 꼬리 · 등급 「1급」 뗌 · 빈칸 · 점 「P.V.C」 · 소리 표기 `SPELL`) 같음 ·
괄호 차례 「패널(유로폼)」 = 「유로폼패널」(글자 묶음 같음) · 괄호 이름 「서비스볼트(고장력볼트)」 = 고장력볼트 ·
(약한 같음 — 수 묶음이 같을 때만) 끝 낱말 같음 「지수판」 ↔ 「PVC지수판」 · 앞 낱말 「구역화물자동차」 ↔ 구역화물 ·
규격에 든 이름 「안고초」 ↔ 산림종자 안고초(가져온 규격 빔).
수 묶음: 규격 + 이름 괄호의 수(차례 무관) · KS 번호 · 무게(kg · ㎏/포 · m/kg) · 제곱 꼬리(㎡ · ㎠) 뗌 ·
천 단위 쉼표 · 같음 > 낱마다 단위 배(1 · 10 · 100 · 1000 — 3.6×3.0×3.0 m = 30×30×3600 mm) > 한쪽이 다른 쪽에 듦
(이름 같을 때만 · 덧글 「2000 길이 · 20~30mm」).
규격 낱말 부딪힘 = 두 쪽 다 괄호 · 규격 한글 낱말이 있고 두 글자 묶음이 하나도 안 겹침(「연강용」 ↔ 「주철용」) —
상대 이름에 든 낱말은 뺌.
"""
from __future__ import annotations
import re
import unicodedata
from functools import lru_cache
from itertools import permutations
from typing import Any
from M02_MasterTemplete import M02_Hopyo_Match as hm
from M02_MasterTemplete import M02_Hopyo_Material as hmat
SPELL = (
(r"칠렌", "틸렌"),
(r"매쉬", "메쉬"),
(r"신나(?![가-힣])", "신너"),
(r"pipe", "파이프"),
) # 소리 · 영문 표기
NOISE = frozenset({"이내", "이하", "이상", "국산", "포함"})
SCALES = (1, 10, 100, 1000)
_STEM = re.compile(r"(?<=..)[류형용]$")
_KS = re.compile(r"ks\s*[a-z]?\s*-?\s*\d+")
_SQUARE = re.compile(r"(?:c|m)?m[23](?![\d.])")
_THOUSAND = re.compile(r"(?<=\d),(?=\d{3}(?!\d))")
_NUM = re.compile(r"\d+(?:\.\d+)?")
_PAREN = re.compile(r"\(([^()]*)\)")
def _low(text: Any) -> str:
t = unicodedata.normalize("NFKC", str(text or "")).lower().replace("m/m", "mm")
return hmat._APART.sub("", t)
def _tidy(text: str) -> str:
t = re.sub(r"(?<=[a-z])[.\s]+(?=[a-z])", "", text)
for a, b in SPELL:
t = re.sub(a, b, t)
t = re.sub(r"\d+급", "", t)
return re.sub(r"[^가-힣a-z0-9]", "", t)
def _bag(text: str) -> tuple[float, ...]:
t = _THOUSAND.sub("", _SQUARE.sub("", _KS.sub("", text)))
return tuple(sorted(float(x) for x in _NUM.findall(t)))
def _quals(text: str) -> frozenset[str]:
words = (w for w in re.findall(r"[가-힣]{2,}", text) if not hmat._region(w))
return frozenset(w for w in words if w not in hmat.SUPPLY and w not in NOISE)
def _item(name: Any, spec: Any) -> dict[str, Any]:
"""이름 · 규격 → 바탕 · 괄호 조각 · 수 묶음 · 규격 낱말 · 온 이름 글."""
low, spec = _low(name), _low(spec)
parens = _PAREN.findall(low)
pieces = tuple(p for p in (_tidy(x) for x in parens for x in x.split(",")) if p)
core = _tidy(hmat._bare(low))
return {
"core": core,
"pieces": pieces,
"chars": "".join(sorted(core + "".join(pieces))),
"bag": _bag(f"{spec} {' '.join(parens)}"),
"quals": _quals(f"{' '.join(pieces)} {spec}"),
"whole": core + "".join(pieces),
"spec": _tidy(spec),
}
@lru_cache(maxsize=2)
def _book(_got: int) -> dict[str, Any]:
rows, _ = hmat.pool()
out: list[dict[str, Any]] = []
by: dict[str, dict[str, list[int]]] = {
"core": {},
"chars": {},
"piece": {},
"tail": {},
"spec": {},
}
for at, r in enumerate(rows):
one = {**_item(r["one"]["이름"], r["one"]["규격"]), "row": r}
out.append(one)
c = one["core"]
by["core"].setdefault(c, []).append(at)
by["chars"].setdefault(one["chars"], []).append(at)
by["spec"].setdefault(one["spec"], []).append(at)
for p in one["pieces"]:
by["piece"].setdefault(p, []).append(at)
for i in range(1, len(c) - 1): # 끝 낱말(두 글자 이상)
by["tail"].setdefault(c[i:], []).append(at)
return {"줄": out, "찾기": by}
def book() -> dict[str, Any]:
return _book(id(hm.targets()))
def _related(me: dict[str, Any]) -> dict[int, bool]:
"""이름 같은 우리 줄 → {차례: 굳셈(True) · 약함(False)}."""
by, c = book()["찾기"], me["core"]
strong = [*by["core"].get(c, []), *by["chars"].get(me["chars"], []), *by["piece"].get(c, [])]
if (
c not in by["core"]
): # 괄호 이름(「서비스볼트(고장력볼트)」) — 바탕 이름이 우리 표에 없을 때만
strong += [i for p in me["pieces"] for i in by["core"].get(p, [])]
weak = (
[*by["tail"].get(c, [])] if len(c) >= 2 else []
) # 우리 이름 끝 = 가져온 이름(「PVC지수판」)
for k in range(
2, len(c) - 1
): # 우리 이름 = 가져온 이름 앞(「구역화물자동차」) · 뒤 덧말이 우리 낱말 아님
if c[k:] not in by["core"] and c[k:] not in by["tail"]:
weak += by["core"].get(c[:k], [])
out = dict.fromkeys(weak, False)
out.update(dict.fromkeys(strong, True))
return out
def _bigrams(words: frozenset[str]) -> set[str]:
return {w[i : i + 2] for w in words for i in range(len(w) - 1)}
def _qual_fit(me: dict[str, Any], x: dict[str, Any]) -> tuple[bool, bool, bool]:
"""(부딪힘, 겹침, 낱말 있음) — 상대 온 이름에 든 낱말은 뺌."""
a = frozenset(w for w in me["quals"] if _tidy(w) not in x["whole"])
b = frozenset(w for w in x["quals"] if _tidy(w) not in me["whole"])
share = bool(_bigrams(a) & _bigrams(b))
return bool(a and b and not share), share, bool(a or b)
def _near(x: float, y: float) -> bool:
return any(abs(x * s - y) <= 1e-6 * max(abs(y), 1) or abs(y * s - x) <= 1e-6 * max(abs(x), 1) for s in SCALES) # fmt: skip
def num_fit(a: tuple[float, ...], b: tuple[float, ...]) -> tuple[int, int]:
"""(갈래 3 같음 · 2 단위 배 · 1 한쪽이 듦 · 0 다름, 맞은 수 개수)."""
if a == b:
return 3, len(a)
if a and len(a) == len(b) and len(a) <= 6:
if any(all(_near(x, y) for x, y in zip(a, p)) for p in permutations(b)):
return 2, len(a)
small, big = sorted((list(a), list(b)), key=len)
if small and len(small) < len(big):
rest = list(big)
for x in small:
if x not in rest:
return 0, 0
rest.remove(x)
return 1, len(small)
return 0, 0
def _fold(x: dict[str, Any]) -> tuple:
r = x["row"]
return r["base"], r["key"][0], r["key"][2]
def _scored(line: dict[str, Any]) -> tuple[dict[str, Any], list[tuple[tuple, dict[str, Any]]]]:
me = _item(line.get("이름"), line.get("규격"))
rows = book()["줄"]
out = []
for at, strong in _related(me).items():
x = rows[at]
clash, share, words = _qual_fit(me, x)
if not clash:
out.append(
((strong, share, *num_fit(me["bag"], x["bag"]), words, x["core"] == me["core"]), x)
)
return me, out
def _pick(group: list[dict[str, Any]], line: dict[str, Any]) -> dict[str, Any]:
"""한 품목의 변형 줄들 → 하나(줄 관급/사급 › 줄 지역 › 서울 › 같은 단위 › 엑셀 차례 — `M02_Elem_Alias.one` 과 같은 차례)."""
supply, regions = hmat._wants(line)
unit = hm.mm.plain_unit(line.get("단위"))
def rank(r: dict[str, Any]) -> tuple:
return (
supply is not None and r["supply"] not in (None, supply),
bool(regions) and not hmat._hit(r, regions),
"서울" not in r["region"],
r["key"][1] != unit,
r["at"],
)
return min((x["row"] for x in group), key=rank)["one"]
def same(line: dict[str, Any]) -> dict[str, Any] | None:
"""㉮ 표기만 다름 — 우리 품목 하나면 그 자재 줄(`hm.targets` 꼴) · 아니면 None."""
me, scored = _scored(line)
if not me["spec"]: # 가져온 규격 빔 = 규격에 든 이름(「안고초」)만 · 그 밖은 ㉯
hits = [book()["줄"][i] for i in book()["찾기"]["spec"].get(me["whole"], [])]
hits = [x for x in hits if not x["bag"] and not me["bag"]]
return _pick(hits, line) if hits and len({_fold(x) for x in hits}) == 1 else None
ok = []
for (strong, share, tier, common, words, same_core), x in scored:
if not tier or (tier == 1 and not strong):
continue
if not me["bag"] and not x["bag"] and not share and words:
continue # 수 없는 규격 = 규격 낱말이 겹쳐야
ok.append(((tier, common, share, strong, same_core), x))
if not ok:
return None
best = max(k for k, _ in ok)
group = [x for k, x in ok if k == best]
return _pick(group, line) if len({_fold(x) for x in group}) == 1 else None
def _stems(me: dict[str, Any]) -> set[str]:
return {_STEM.sub("", w) for w in me["quals"]}
def narrow(line: dict[str, Any], limit: int = 5) -> list[dict[str, Any]]:
"""㉯ 이름 같은 품목 묶음(굳센 같음) → 규격 낱말 어근 품은 줄 › 수 묶음 닮음 › 덧말 적음 차례(품목마다 하나)."""
me, scored = _scored(line)
stems = _stems(me)
rows = [(k, x) for k, x in scored if k[0]]
rank = []
for (_strong, _share, tier, common, _words, same_core), x in rows:
text = x["whole"] + x["spec"]
hit = sum(s in text for s in stems)
rank.append(((-hit, -tier, -common, not same_core, len(x["spec"]), x["row"]["at"]), x))
seen, out = set(), []
for _k, x in sorted(rank, key=lambda t: t[0]):
if (f := _fold(x)) not in seen:
seen.add(f)
out.append(_pick([y for _, y in rank if _fold(y) == f], line))
return out[:limit]
def spec_first(cands: list[dict[str, Any]], line: dict[str, Any]) -> list[dict[str, Any]]:
"""후보 차례 — 규격 수 묶음(둘 이상)이 줄과 같은 것 먼저(「중하중그레이팅 395×995×52」)."""
mine = _item(line.get("이름"), line.get("규격"))["bag"]
if len(mine) < 2:
return cands
return sorted(cands, key=lambda c: _item(c.get("이름"), c.get("규격"))["bag"] != mine)