Files
Aislo/M02_MasterTemplete/M02_San_Basis_Const.py
eomsangdonandClaude Opus 5.5 532cc52c8e feat(793-3a): 근거 제안 엔진 — 번호 ∩ 값 ∩ 제목 ∩ 기계 상수 → M01 기초 + 옵션 · 품 검산 · 세 단
- M01 값 역색인 · 숫자 정규화 한 벌(N1~N10 · 단위 기준 · % ↔ 소수 · t ↔ kg · 인/양 몫) · M01 지문마다 한 번 · 서버 시작 때 뒤에서 지음
- 값 길(상수 지문 → 로직 · 우연 보정 유의 4) · 합친 길(분석 648-D2 차례 · 후보 2~6 엔진 품 대조) · 검산 · 옵션(B1~B6 → 값 길 V → 고르기 맞댐 → 입력 필요)
- 세 단 확정 · 근접(판 차이 · 현장 값 · 감률 · 할증 배수 · 단위 기준 차이) · 사람(+ 값 약한 1 등) · 없음(M01 보강 목록)
- 길 GET hopyo-basis/{키} · hopyo-basis/{키}/{번} · 파생 저장본 · 올림 때 HP 숨은식(STmate G 식) · 끝내기 때 지움 · 판정 길 그대로
- 보정 자료 = 여덟 파일 상수 1,094 · 시험 test_793_3a 11(459 블록 기초 260 · 옵션 전부 52 · 품 같음 141 · 없음 85)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:19:26 +09:00

379 lines
14 KiB
Python
Raw Permalink 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.
"""근거 제안 2 — 산근 블록 글 → 상수 지문(PLAN 793-3a · 분석 648-D 1-2 뽑기) · 읽기만.
뽑기 = 엑셀꼴(EST · STmate 보이는 글): 콜론 앞 이름 · 단가 앞 규격 · 쉼표 금액 · 항목 단가 · 「=」
뒤 결과
(앞 마디가 셈일 때) · 소계 줄 · 절 제목 줄 · 품셈 근거 줄 · 품셈 번호 · 연도 뺌 · 정수 끝 점 지움 /
STmate 숨은 G 식: 따옴표 글 뺌 · 수 바로 뒤 짧은 따옴표 = 단위 · `&…&` 참조 뺌.
N9 일반 수(1 · 100 · 1000 · 60 분 · 8 시간 = 「일반」 표지) · N10 입력(L · 거리 · km) 뺌 ·
인력 줄 몫(「a 인 / b」 · 「a / Q」 = 블록 안 대입)에 직종 표지.
"""
from __future__ import annotations
import re
import unicodedata
from functools import lru_cache
from typing import Any
from M02_MasterTemplete import M02_Hopyo_Formula as formula
from M02_MasterTemplete import M02_Hopyo_ReadEst as est
from M02_MasterTemplete import M02_San_Basis_Index as bi
fam = bi.fam
UNIT = (
r"(㎡|㎥|㎏|kg|Kg|KG|ton|Ton|TON|톤|m3|M3|m2|M2|㎝|cm|㎜|mm|km|Km|KM|ℓ|hr|Hr|HR"
r"|시간|분|초|sec|min|일|인|회|본|개|매|조|대|%|EA|ea|t(?![a-zA-Z])|m(?![a-zA-Z²³])|M(?![a-zA-Z]))"
)
NUMTOK = re.compile(
r"(?<![A-Za-z가-힣_\d.,ⅠⅡⅢⅣ#])(\d[\d,]*(?:\.\d*)?)\s*(?:⟨([^⟩]*)⟩|" + UNIT + r")?"
)
DROP = [
re.compile(
r"(?<![=\d.])\b\d{1,2}(?:\s*-\s*\d{1,3}){1,3}\b|(?:19|20)\d\d\s*[-.]\s*\d{1,2}"
), # 품셈 번호 · 날짜
re.compile(r"(?:19|20)?\d\d\s*년\s*(?:도)?"),
re.compile(r"Type\s*-?\s*[ⅠⅡⅢIV]+(?:\s*-\s*\d)?|[ⅠⅡⅢ]\s*-\s*\d", re.I),
re.compile(r"제\s*\d+\s*호표?|\d+\s*호표|#\s*\d+"),
re.compile(r"\d+\s*차선"),
]
HEADNO = re.compile(r"^\s*(?:\d+\s*\.(?!\d)|[가-하]\s*\.|\(\d+\)|\d+\)|[①-⑳ㅇ○◇□※·\-*]+)")
INPUT_SYM = re.compile(r"^(L\d*|l\d*|거리|운반거리|경사\w*|구배|α|θ)$")
SYM = re.compile(r"([A-Za-z][A-Za-z0-9]{0,3}|[가-힣]{2,6}|[α-ωΑ-Ω])\s*=\s*$")
OPER = re.compile(r"[\d)A-Za-z]\s*[*/+\-]\s*[\d(A-Za-z]")
QUO = re.compile(
r"(?<![A-Za-z가-힣_\d.,])(\d+(?:\.\d+)?)\s*(?:⟨인⟩|인|명)?\s*(?:\*\s*1\s*(?:⟨일⟩|일)\s*)?/\s*(\d+(?:\.\d+)?|[A-Za-z][A-Za-z0-9]{0,3})(?![A-Za-z0-9.])"
)
def nk(s: Any) -> str:
s = unicodedata.normalize("NFKC", str(s or ""))
return s.replace("×", "*").replace("÷", "/").replace("=", "=").replace(":", ":")
def _f(s: Any) -> float | None:
try:
return float(str(s).replace(",", "").rstrip("."))
except ValueError:
return None
def g_text(g: str) -> tuple[str, str]:
"""STmate G 식 → (절 나누기 글, 수 뽑기 글) — 따옴표 글은 이름 · 수 뒤 짧은 따옴표 = 단위."""
parts = g.split("'")
out: list[str] = []
for i, p in enumerate(parts):
if i % 2 == 0:
out.append(p)
elif re.search(r"\d\s*$", parts[i - 1]) and len(p) <= 8 and not re.search(r"\d", p):
out[-1] = parts[i - 1].rstrip()
out.append(f"⟨{p}⟩")
else:
out.append(" ")
s = re.sub(r"&[^&]*&", " ¤ ", "".join(out)) # 단가 · 금액 범위 참조
s = re.sub(r":=\s*A\d+|:\s*T\d+|->|\(-?=+\)|\[[A-Za-z]\w*\]", " ", s)
return g.replace("'", ""), s
def result_spans(s: str) -> list[tuple[int, int]]:
"""「=」 마디 중 바로 앞 마디가 셈인 마디의 첫 수 = 결과 — 여러 대입 줄(「q = 0.7 K =
0.9」)은 결과 아님.
"""
out, at = [], 0
segs = s.split("=")
for i, seg in enumerate(segs):
last = i == len(segs) - 1
if (
i
and OPER.search(segs[i - 1].split(",")[-1])
and (last or not OPER.search(seg.split(",")[0]))
):
m = re.search(r"\d[\d,]*(?:\.\d+)?", seg)
if m and not seg[: m.start()].strip(" ({"):
out.append((at + m.start(), at + m.end()))
at += len(seg) + 1
return out
def extract(text: str, prices: set, results: set, g: bool) -> tuple[list[dict], list[dict], str]:
"""줄 하나 → (상수, 입력, 수 글) — 결과 · 단가 · 금액 · 이름 · 번호 뺌."""
s = nk(text)
if not g and "=" not in s and (formula._title(s.strip()) or re.search(r"품\s*셈", s)):
return [], [], "" # 절 제목 · 품셈 근거 줄 — 규격 · 번호뿐
for r in DROP:
s = r.sub(" ", s)
s = HEADNO.sub(" ", s)
s = re.sub(r"(?<=\d)\.(?!\d)", "", s) # 엑셀꼴 정수 끝 점(「5. 인 / 45. ㎡」)
if not g:
if ":" in s and "=" not in s.split(":")[0]:
s = s.split(":", 1)[1]
else: # 콜론 없는 재료 · 인력 줄 — 단가 앞(이름 · 규격) 자름
for m in re.finditer(r"\d[\d,]*(?:\.\d+)?", s):
x = _f(m.group())
if (
x is not None
and ("," in m.group() or x in prices)
and "=" not in s[: m.start()]
):
s = s[m.start() :]
break
skip = [] if g else result_spans(s)
consts, inputs = [], []
for m in NUMTOK.finditer(s):
if any(a <= m.start(1) < z for a, z in skip):
continue # 셈 결과
raw = m.group(1)
if "," in raw.strip(","):
continue # 금액 · 단가
raw = raw.rstrip(".,")
x = _f(raw)
if x is None or x == 0:
continue
unit = m.group(2) if m.group(2) is not None else (m.group(3) or "")
if not unit and re.match(r"[가-힣]", s[m.end() : m.end() + 1]):
continue # 「2차선」 · 「3층」 같은 낱말 속 수
sym_hit = SYM.search(s[: m.start()])
sym = sym_hit.group(1) if sym_hit else ""
prev = s[: m.start()].rstrip()[-1:]
one = {
"v": x,
"d": len(raw.split(".")[1]) if "." in raw else 0,
"단위": unit,
"기호": sym,
"글": text.strip()[:90],
}
if x in prices or (not g and x in results):
continue
speed = re.match(r"\s*/\s*h", s[m.end() : m.end() + 4], re.I)
if (
(sym and INPUT_SYM.match(sym))
or fam(unit) == "길이"
and unit.lower() in ("km",)
and not speed
):
inputs.append(one)
continue
if (
(x in (1, 100, 1000) and unit != "%")
or (x == 60 and fam(unit) in (None, "시간"))
or (
x == 8
and (prev == "/" or fam(unit) == "시간" or re.search(r"hr|시간", s, re.I))
and fam(unit) in (None, "시간")
)
):
one["일반"] = True
consts.append(one)
return consts, inputs, s
def quotients(body: str, labor: bool, syms: dict) -> list[dict]:
"""인력 줄 「3 / 4.5」 · 「5인 / 45」 · 「2인 * 1일 / 50」 · 「5인 / Q」 → 몫 상수(인/양)."""
out = []
for m in QUO.finditer(body):
a, bt = _f(m.group(1)), m.group(2)
b = syms.get(bt) if re.match(r"[A-Za-z]", bt) else _f(bt)
unit_p = bool(re.search(r"⟨인⟩|인|명", body[m.start() : m.start(1) + len(m.group(1)) + 4]))
if not a or not b or not (labor or unit_p) or b in (8, 60, 100):
continue
out.append(
{
"v": a / b,
"d": 9,
"단위": "인",
"기호": "",
"몫": f"{a:g}/{b:g}",
"글": body.strip()[:90],
}
)
return out
def trade_of(text: str) -> str | None:
"""인력 줄 이름 → 우리 인력 이름(모르면 None)."""
m = est._COLON.match(nk(text))
if not m:
return None
name = re.sub(r"^[0-9가-힣]\.|^[○◇□※·\-*ㅇ\s]+", "", re.sub(r"\s+", "", m.group("name")))
return bi.trade(name)
# ── 인력 줄 가름(엑셀꼴) ─────────────────────────────────────────────
_SWAP = [
("m3", "㎥"),
("M3", "㎥"),
("m2", "㎡"),
("M2", "㎡"),
("m", "m"),
("Ton", "톤"),
("TON", "톤"),
("ton", "톤"),
("굴착기", "굴삭기"),
("백호", "굴삭기"),
("B/H", "굴삭기"),
("BACK-HOE", "굴삭기"),
("브레카", "브레이커"),
("브레이카", "브레이커"),
("뒤채움", "뒷채움"),
("Ⅰ", "I"),
("Ⅱ", "II"),
("ll", "II"),
("=", "="),
(":", ":"),
]
_TOTAL = re.compile(r"^(소계|계|합계|총계|인부소계|자재소계|주자재소계|인건비소계)[:]?#?$")
def _fk(s: Any) -> str:
s = unicodedata.normalize("NFKC", str(s or ""))
for a, b in _SWAP:
s = s.replace(a, b)
return s
def _skel(line: str) -> str:
s = re.sub(r"(?<=[\d\s)])[xX×](?=[\s\d(])", "*", _fk(line)).replace("×", "*").replace("÷", "/")
s = re.sub(r"(?<=\d)\.(?=[*/=%+\-)\s]|$)", "", re.sub(r"\s+", "", s))
return re.sub(r"\d[\d,]*(?:\.\d+)?", "#", s)
@lru_cache(maxsize=1)
def _names() -> dict:
return est.trade_names()
def labor(line: str) -> bool:
"""인력 줄(「보통인부 : …」) — 소계 · 품셈 근거 줄 뺌."""
s = _fk(line).strip()
f, sk = re.sub(r"\s+", "", s), _skel(s)
if (
not f
or _TOTAL.match(sk)
or re.match(r"^(\d+\.)?(합계|소계|계)[:]?", f)
and "*" not in sk
and "/" not in sk
):
return False
if re.search(r"품[셈줌]", s) and "=" not in s:
return False
cm = est._COLON.match(s)
nm = (
re.sub(
r"^[○◇□※·\-*]+", "", re.sub(r"^[0-9가-힣]\.", "", re.sub(r"\s+", "", cm.group("name")))
)
if cm
else ""
)
return bool(cm and (_names().get(nm) or bi.trade(nm)))
# ── 블록 → 줄 · 절 · 상수 ────────────────────────────────────────────
def results_of(lines: list[str], items: list[dict]) -> tuple[set, set]:
prices, results = set(), set()
for it in items or []:
prices |= {
float(v) for v in (it.get("단가") or {}).values() if isinstance(v, (int, float)) and v
}
if isinstance(it.get("금액"), (int, float)):
results.add(float(it["금액"]))
if isinstance(it.get("기준값"), (int, float)):
results.add(round(float(it["기준값"]), 1))
for t in lines:
s = nk(t)
results |= {_f(s[a:z]) for a, z in result_spans(s)}
if re.search(r"(소\s*계|합\s*계|총\s*계|^\s*계\s*:)", s):
results |= {_f(m.group()) for m in re.finditer(r"\d[\d,]*(?:\.\d+)?", s)}
results.discard(None)
return prices, results
def block_lines(blk: dict, kind: str, g: list[str] | None) -> list[dict]:
"""[{글(절 나누기), 수글, g}] — STmate + 숨은 G 식이 있으면 G · 그 밖 보이는 글."""
out = []
if kind == "STmate" and g:
for gl in g:
tree, s = g_text(gl)
if tree.strip() and not re.fullmatch(r"\s*\(-?=+\)\s*", tree):
out.append({"글": tree, "수글": s, "g": True})
else:
out = [{"글": t, "수글": t, "g": False} for t in blk.get("글") or [] if t and t.strip()]
return out
def sections(lines: list[dict]) -> list[tuple[str, list[int]]]:
texts = [ln["글"] for ln in lines]
works = formula.works(formula.tree(texts, list(range(len(texts)))))
if not works:
return [("(블록 통째)", list(range(len(texts))))]
return [
(f"{w['번']}. {w['제목']}"[:40], list(range(w["글"][0], w["글"][1] + 1))) for w in works
]
def prep(blk: dict, kind: str, g: list[str] | None = None) -> dict:
"""블록 → 줄 · 줄별 (상수, 입력) · 절."""
lines = block_lines(blk, kind, g)
prices, results = results_of(blk.get("글") or [], blk.get("항목") or [])
per_line, bodies = [], []
for ln in lines:
cs, ins, body = extract(ln["수글"], prices, results, ln["g"])
per_line.append([cs, ins])
bodies.append(body)
syms: dict = {}
for cs, _ in per_line:
for c in cs:
if c["기호"] and c["기호"] not in syms:
syms[c["기호"]] = c["v"]
for i, ln in enumerate(lines):
lab = re.search(r"&L\d", ln["글"]) if ln["g"] else labor(ln["글"])
tr = trade_of(ln["글"]) if lab else None
qs = quotients(bodies[i], bool(lab), syms)
if tr:
tops = {q["몫"].split("/")[0] for q in qs}
for c in per_line[i][0]:
if fam(c["단위"]) == "사람" or f"{c['v']:g}" in tops:
c["직종"] = tr
for q in qs:
q["직종"] = tr
per_line[i][0] += qs
in_vals = {round(c["v"], 9) for _, ins in per_line for c in ins if c.get("기호")}
for cs, _ in per_line:
cs[:] = [c for c in cs if round(c["v"], 9) not in in_vals or c.get("몫")]
return {"lines": lines, "per_line": per_line, "secs": sections(lines) if lines else []}
def gather(per_line: list, ids: Any) -> tuple[list[dict], list[dict]]:
"""줄 묶음 → (상수 K, 입력) — 같은 수 · 율과 소수 쌍(2 % · 0.02)은 하나(단위 있는 쪽)."""
K: list[dict] = []
seen, ins, at = set(), [], {}
for i in ids:
cs, ii = per_line[i]
ins += ii
for c in cs:
vk = (round(c["v"], 9), c.get("직종"), bool(c.get("몫")))
if vk in at and fam(c["단위"]) != "율" and fam(K[at[vk]]["단위"]) != "율":
if not K[at[vk]]["단위"] and c["단위"]:
K[at[vk]] = {**c, "줄": i}
continue
key = (round(c["v"], 9), fam(c["단위"]), c.get("직종"))
twin = (
(round(c["v"] * 100, 9), "율", None)
if fam(c["단위"]) is None and c["v"] < 1
else ((round(c["v"] / 100, 9), None, None) if fam(c["단위"]) == "율" else None)
)
if key in seen or twin in seen:
continue
seen.add(key)
at.setdefault(vk, len(K))
K.append({**c, "줄": i})
return K, ins
def corpus(preps: list[dict]) -> list[list]:
"""보정 자료 — 블록들 상수(일반 뺌 · 같은 꼴 하나) [[v, d, 단위, 직종, 몫]]."""
keys: dict = {}
for pr in preps:
for c in gather(pr["per_line"], range(len(pr["lines"])))[0]:
if not c.get("일반"):
keys[bi.kkey(c)] = None
return [list(k) for k in keys]