fix(m01): 이름 식 별칭 = 짧은 표 제목 + 찾은 줄 값 · 긴 품명은 앞 핵심만 (PLAN 5장 5바퀴)

- 표 키(QC…) 대신 표 제목을 짧게 — 규격·코드 뗌 · 길면 첫 + 끝 낱말 · 제목 없으면 공종 · 상세구분
- 같은 표 여러 번은 번호 대신 서로 가르는 인자 값만(낙석방지망설치재료_철망 · 합판거푸집_각재)
- 호표 줄 이름도 짧은 품명 · 한 로직 안에서 겹치면 꼬리 _2
- 자동값 7,008줄: 표 키 1,188→0 · 번호 별칭 982→4 · 붙인 20자 넘는 이름 108→0 · 금액 변화 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1MKKZKpUHTPKb513FneU8
This commit is contained in:
2026-09-24 12:04:39 +09:00
co-authored by Claude Opus 5.5
parent 68daa24259
commit f808b67285
4 changed files with 230 additions and 32 deletions
+146 -9
View File
@@ -14,9 +14,12 @@ import re
import master_formula as mf
MAX_NAME = 12 # 이 글자 수를 넘는 표 이름은 억지로 줄이지 않고 표 키를 그대로 보임
MAX_NAME = 12 # 짧은 이름의 길이 상한
MAX_WORD = 8 # 별칭 꼬리(찾은 줄 값) 한 낱말의 길이 상한
_DROP = re.compile(r"[^\w]", re.UNICODE) # 별칭에 못 쓰는 글자(빈칸 · 괄호 · 가운뎃점 …)
_SPACE = re.compile(r"\s+")
_PAREN = re.compile(r"\([^()]*\)")
_WORDS = re.compile(r"[\s·・,/:+×*~∼]+")
_QUOTED = re.compile(r"'[^']*'")
RESERVED = frozenset((*mf.FUNCS, *mf.COST_ITEMS, "찾기", "로직", "그리고", "또는", "계", "줄"))
@@ -98,15 +101,146 @@ def plain(text) -> str:
return _DROP.sub("", str(text or ""))
def _unparen(text: str, keep: bool) -> str:
"""괄호 묶음을 벗김 — `keep` 이면 숫자 없는 묶음(공기식 · 건설)만 글로 남김."""
while True:
got = _PAREN.sub(
lambda m: f" {m.group()[1:-1]} " if keep and not re.search(r"\d", m.group()) else " ",
text,
)
if got == text:
return got
text = got
def _core(text: str) -> list[str]:
"""앞 핵심 낱말 — 머리 번호(1급 · 5-3)는 건너뛰고, 그 뒤 숫자 든 낱말(규격)에서 끊음."""
out: list[str] = []
for word in _WORDS.split(text):
word = plain(word)
if not word:
continue
if re.search(r"\d", word):
if out:
break
continue
out.append(word)
return out
def _fit(words: list[str], cap: int) -> str:
"""낱말들을 이어 이름 하나로 — 길면 첫 낱말 + 끝 낱말 · 그래도 길면 첫 낱말을 자름."""
tries = ["".join(words), *([words[0] + words[-1], words[0][:cap]] if words else [])]
return next((one for one in tries if one and len(one) <= cap), "")
def short(text, cap: int = MAX_NAME) -> str:
"""사람이 읽는 짧은 이름 — 규격·코드를 떼고, 길면 괄호 속 · 가운데 낱말 순으로 버림.
늘 같은 글이면 같은 이름 · 글자 · 숫자 · 밑줄만 · 숫자로 시작하지 않음(못 지으면 빈 글)."""
text = str(text or "")
kept = "".join(_core(_unparen(text, True)))
return kept if kept and len(kept) <= cap else _fit(_core(_unparen(text, False)), cap)
def _base(ref: str, master) -> str:
"""별칭 밑이름 — 표·로직의 `이름` 칸에서 · 비거나 너무 길면 키를 그대로 보임."""
"""별칭 밑이름 — 표·로직 이름을 짧게 · 이름이 없으면 공종 · 상세구분 · 그래도 없으면 키."""
try:
name = plain(master.get(ref).get("이름"))
row = master.get(ref)
except mf.FormulaError:
name = ""
if not name or len(name) > MAX_NAME or name[0].isdigit():
return _DROP.sub("", ref) or "표"
return name
row = {}
use = row.get("용도") if isinstance(row.get("용도"), dict) else {}
for text in (row.get("이름"), use.get("공종"), row.get("상세구분")):
got = short(text)
if got:
return got
return _DROP.sub("", ref) or "표"
def _words(node, master) -> list[str]:
"""부르기 인자 하나의 낱말 — 글은 그 값(요소 코드면 품명 + 규격) · 수 · 이름은 그대로."""
kind = node[0]
if kind == "name":
return [node[1]]
if kind == "num":
return [plain(node[1])]
if kind != "str": # 만약(…) 따위 — 안에 적힌 글 가운데 처음 읽히는 것
return next(filter(None, (_core(_unparen(one, False)) for one in _texts(node))), [])
for ref in (node[1], f"EQ:{node[1]}"): # 기계 입력은 원문번호(0201-0020)
try:
row = master.get(ref)
except mf.FormulaError:
continue
if row.get("이름"):
return [short(row["이름"]), *filter(None, [plain(row.get("규격"))])]
return _core(_unparen(str(node[1]), False)) or [plain(node[1])]
def _texts(node):
"""마디 안의 글 값(작은따옴표) — 적힌 차례로."""
if isinstance(node, tuple) and node[:1] == ("str",):
yield str(node[1])
elif isinstance(node, (tuple, list)):
for one in node:
yield from _texts(one)
def _columns(signs: list[str], refs: list[str], master) -> list[dict[str, str]]:
"""부르기마다 {칸: 꼬리 낱말} — 다른 표가 섞이면 표 제목도 한 칸(`""`).
한 칸 값들이 모두 가진 낱말(`구체콘크리트`)은 떼어 서로 가르는 낱말만 남김."""
rows: list[dict[str, list[str]]] = [{} for _ in signs]
if len(set(refs)) > 1:
for one, ref in zip(rows, refs):
try:
one[""] = _core(_unparen(str(master.get(ref).get("이름") or ""), False))
except mf.FormulaError:
one[""] = []
for one, sign in zip(rows, signs):
try:
args = mf.parse(sign)[2]
except (mf.FormulaError, IndexError, TypeError):
args = []
one.update({key: _words(value, master) for key, value in args})
out: list[dict[str, str]] = [{} for _ in signs]
for key in dict.fromkeys(key for one in rows for key in one):
lists = [one.get(key, []) for one in rows]
common = set.intersection(*map(set, lists))
if not all(set(words) - common for words in lists):
common = set()
for got, words in zip(out, lists):
got[key] = _fit([w for w in words if w not in common], MAX_WORD)
return out
def _tails(signs: list[str], refs: list[str], master) -> list[str]:
"""같은 밑이름의 부르기들 — 서로 가르는 인자 값만 이어 꼬리로(`철망` · `외업_고급기술자`).
가르는 칸은 적게 — 가장 잘 가르는 칸부터 모두 갈릴 때까지 고르고, 줄마다 없어도 되는 칸은 뺌."""
cols = _columns(signs, refs, master)
order = list(dict.fromkeys(key for one in cols for key in one))
def seen(one: dict, keys: list[str]) -> tuple:
return tuple(one.get(key, "") for key in keys)
picked: list[str] = []
left = list(order)
while len({seen(one, picked) for one in cols}) < len(cols) and left:
best = max(left, key=lambda key: len({seen(one, [*picked, key]) for one in cols}))
if len({seen(one, [*picked, best]) for one in cols}) == len(
{seen(o, picked) for o in cols}
):
break
picked.append(best)
left.remove(best)
out = []
for at, one in enumerate(cols):
mine = list(picked)
for key in reversed(picked): # 늦게 고른(덜 가르는) 칸부터 빼 봄
rest = [k for k in mine if k != key]
others = (seen(o, rest) for j, o in enumerate(cols) if j != at)
if not rest or seen(one, rest) in set(others):
break
mine = rest
out.append("_".join(filter(None, seen(one, sorted(mine, key=order.index)))))
return out
def _free(want: str, taken: set[str]) -> str:
@@ -144,8 +278,11 @@ def aliases(row: dict, master) -> list[dict]:
taken |= set(_about(kind, ref, master)["값칸"])
for base, signs in bunch.items():
gap = "_" if base[-1:].isdigit() else "" # 키를 밑이름으로 쓴 자리 — 번호가 붙지 않게
for at, sign in enumerate(signs):
name_of[sign] = _free(f"{base}{gap}{at + 1}" if len(signs) > 1 else base, taken)
tails = _tails(signs, [seen[s][1] for s in signs], master) if len(signs) > 1 else [""]
for at, (sign, tail) in enumerate(zip(signs, tails)):
alone = len(signs) == 1 or any(tails) # 꼬리 없는 하나는 밑이름만
want = f"{base}_{tail}" if tail else base if alone else f"{base}{gap}{at + 1}"
name_of[sign] = _free(want, taken)
out = []
for sign in order: