- 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>
379 lines
14 KiB
Python
379 lines
14 KiB
Python
"""근거 제안 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]
|