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>
This commit is contained in:
2026-11-05 18:19:26 +09:00
co-authored by Claude Opus 5.5
parent e3262d2b49
commit 532cc52c8e
10 changed files with 2499 additions and 0 deletions
+29
View File
@@ -5,6 +5,8 @@
SC · SK · 장부 · `M02_Hopyo_Make`) · 경로 층 = 파일 층(판정 · 검산 · `to` = 만들 층) / 만들 층(옮기기).
올림 = Book + 격자 + 엑셀 사전(`Row.lexicon`) + 블록 풀이 뒤 셈(`Judge.ensure` · 파생 저장본) ·
옛 맞춤 뒤 셈 · 시동 데움 없음 · [끝내기] = 저장본도 지움.
근거 제안(793-3a) = `hopyo-basis/{키}`(목록) · `hopyo-basis/{키}/{번}`(블록) · `M02_San_Basis_Store`
(올림 때 HP `숨은식` + 뒤 셈 · 파생 저장본 · 판정 길 `hopyo-judge` 칸은 그대로).
그대로 쓰는 길(목록 · 올림 · 엑셀 보기 머리 · 격자 · 지우기 · 장부 · 모두 고정 · 붙여넣기 · 다시 잇기 ·
구조물 조합 · 칠 = B09 · M01 이 씀) · 옛 맞춤 길(호표 하나 맞춤 · 미리 셈 · 실무 호표 · 확인 만들기 · 안 이은 줄 ·
단가산출 만들기)은 785-7 에서 지움.
@@ -36,6 +38,7 @@ from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Layer_Access as access
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_San_Basis_Store as san_basis
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root
router = APIRouter(prefix="/api/m02", tags=["M02 Hopyo"])
@@ -93,6 +96,28 @@ def hopyo_judge_block(
return _call(folder, lambda: hopyo_judge.block(folder, key, no, target))
@router.get("/layers/{layer}/hopyo-basis/{key}")
def hopyo_basis_list(
layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session)
) -> dict:
"""근거 제안(793-3a) — `{셈중, 블록 {번: 제안}, 보강}` · 셈 중 = 뒤 셈 시작(읽기만)."""
folder = _root(session, layer, owner)
return _call(folder, lambda: san_basis.listing(folder, key))
@router.get("/layers/{layer}/hopyo-basis/{key}/{no}")
def hopyo_basis_block(
layer: Layer,
key: str,
no: int,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""근거 제안 블록 하나 — 저장본 › 그 자리 셈(`M02_San_Basis.suggest` 꼴)."""
folder = _root(session, layer, owner)
return _call(folder, lambda: san_basis.block(folder, key, no))
class Pick(BaseModel):
"""고른 것(785-3 꼴) — 금액 · 품 칸 없음(서버가 셈) · 고정 단가 = 엑셀 줄 것."""
@@ -246,6 +271,7 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]:
hopyo_forget.sweep(store.root()) # 하루 지난 작업 중 HP 끝냄(743-1)
if same := next((r for r in hopyo_store.rows(store.root()) if r.get("지문") == mark), None):
hopyo_judge.ensure(store.root(), same["키"])
san_basis.ensure(store.root(), same["키"])
return {**same, "이어서": True}
try:
parsed = hopyo_excel.read_book(name, data)
@@ -257,8 +283,10 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]:
grid = None
sheets, _ = hopyo_excel._open(name, data, hopyo_row.SHEETS)
parsed["사전"] = hopyo_row.lexicon(sheets) # 엑셀 사전(785-3 · 1차 줄 판정)
parsed["숨은식"] = san_basis.hidden(parsed, sheets) # STmate 숨은 G 식(793-3a 근거 제안)
row = hopyo_store.save(store.root(), name, parsed, who, grid, mark)
hopyo_judge.ensure(store.root(), row["키"]) # 블록 풀이 뒤 셈(파생 저장본 · 화면 안 기다림)
san_basis.ensure(store.root(), row["키"]) # 근거 제안 뒤 셈(793-3a)
return row
@@ -411,6 +439,7 @@ def hopyo_delete(
folder = _root(session, layer, owner, write=True)
got = _call(folder, hopyo_forget.finish, folder, key)
hopyo_judge.forget(folder, key) # 블록 풀이 저장본(785-5)
san_basis.forget(folder, key) # 근거 제안 저장본(793-3a)
return got
+528
View File
@@ -0,0 +1,528 @@
"""근거 제안 엔진(PLAN 793-3a · 요구 648 · 649 · 652 갈래 C · 분석 648-F H1~H5 · 648-D2) — 서버
단독 · 읽기만.
산근 블록(HP `단산` 꼴) → 절마다 번호 후보(`M02_Hopyo_Clause.basis`) ∩ 값
후보(`M02_San_Basis_Value` ·
역색인 · 유의 4) ∩ 제목 후보(이름 두 글자 묶음 Dice ≥ 0.35 · 같은 말 묶음) · 기계 작업량 절(q · K
· f · E · Cm
둘+)은 일 갈래(번호 · 제목) 먼저 · 차례 = 분석 648-D2 1 장 · 후보 2~6 이면 엔진 품 대조로 하나만
맞으면 확정 ·
같은 기초 절은 묶어 검산(`M02_San_Basis_Check`) · 절 2+ 블록은 블록 통째 길도 셈해 나은 꼴.
꼴(648-D2) = 확정·전부 · 확정·일부 · 확정·일부(절 미확정) · 후보 2~3 · 없음.
단(근거후보 · 화면 · 793-2 SK) = 확정(기초 확정 · 엔진 품 = 엑셀 품 · 품 다름 0) · 근접(기초 확정
· 품 다름 ·
품 대조 못 함 · 까닭 표지) · 사람(후보 2~3) · 없음(M01 없음 의심 → `보강` = 794-2 근거).
자동 바꿈 없음 · 지어 넣는 값 없음(못 읽은 칸 · 글에 없는 감률 · 할증 = 입력필요).
"""
from __future__ import annotations
import re
from typing import Any
from M02_MasterTemplete import M02_Hopyo_Bind as hb
from M02_MasterTemplete import M02_Hopyo_Clause as clause
from M02_MasterTemplete import M02_Hopyo_Formula as formula
from M02_MasterTemplete import M02_Hopyo_Solve as solve
from M02_MasterTemplete import M02_San_Basis_Check as chk
from M02_MasterTemplete import M02_San_Basis_Const as bc
from M02_MasterTemplete import M02_San_Basis_Index as bi
from M02_MasterTemplete import M02_San_Basis_Value as bv
TITLE_MIN, TITLE_GAP, TITLE_TOP = 0.35, 0.15, 5
SYN = [
("B/H", "굴착기"),
("BACK-HOE", "굴착기"),
("BACKHOE", "굴착기"),
("백호", "굴착기"),
("굴삭", "굴착"),
("D/Z", "불도저"),
("도자", "불도저"),
("불도우저", "불도저"),
("D/P", "덤프"),
("브레카", "브레이커"),
("브레이카", "브레이커"),
("깍기", "깎기"),
("뒷채움", "뒤채움"),
("로울러", "롤러"),
("콤펙터", "콤팩터"),
("레미콘", "레디믹스트콘크리트"),
("컨테이너", "콘테이너"),
]
ORDER = ["확정·전부", "확정·일부", "확정·일부(절 미확정)", "후보 2~3", "없음"]
NONE = {"확정": [], "동점": [], "약함": []}
class Ctx:
"""역색인 + M01 Master + 로직 이름 묶음(역색인 지문마다 한 벌)."""
def __init__(self, idx: bi.Index):
self.idx, self.m = idx, hb.m01.cm.master()
self.logic = {
**self.m.index.get("GC", {}),
**self.m.index.get("GF", {}),
**self.m.index.get("GX", {}),
}
self.grams = {k: grams(r.get("이름")) for k, r in self.logic.items()}
_CTX: list = [None, None]
def ctx() -> Ctx:
idx = bi.get()
if _CTX[0] is not idx:
_CTX[:] = [idx, Ctx(idx)]
return _CTX[1]
# ── 제목 → 로직 이름 후보 ───────────────────────────────────────────
def grams(text: Any) -> set:
t = str(text or "")
for a, b in SYN:
t = re.sub(re.escape(a), b, t, flags=re.I)
return clause._grams(re.sub(r"\d", " ", t))
def title_cands(c: Ctx, text: str, unit: Any) -> list[tuple[str, float]]:
"""제목 ↔ 로직 이름 Dice ≥ 0.35 · 1등과 0.15 안 · 다섯까지 · 결과단위 성격 다르면 뺌."""
mine = grams(text)
if not mine:
return []
nat, scored = clause.nature(unit), []
for k, gs in c.grams.items():
theirs = clause.nature(c.logic[k].get("결과단위"))
if not gs or nat and theirs and nat != theirs:
continue
s = 2 * len(mine & gs) / (len(mine) + len(gs))
if s >= TITLE_MIN:
scored.append((k, round(s, 3)))
scored.sort(key=lambda x: (-x[1], x[0]))
return [x for x in scored if x[1] >= scored[0][1] - TITLE_GAP][:TITLE_TOP] if scored else []
def dice(c: Ctx, key: str, text: str) -> float:
a, b = grams(text), c.grams.get(key, set())
return 2 * len(a & b) / (len(a) + len(b)) if a and b else 0.0
# ── 블록 · 절 ───────────────────────────────────────────────────────
def _flat(t: Any) -> str:
return re.sub(r"[\s.()\[\]:·,]", "", str(t or ""))
def parts_of(blk: dict) -> tuple[list[dict], dict, list]:
"""일 절(`Formula.works`)마다 {제목, 번, 길, 항목, 상수, 식, 선택값, 몫} · 블록 통째 상수 · 쓸
항목.
"""
nodes = blk.get("절") or formula.tree(
blk.get("글") or [], blk.get("글행"), blk.get("항목") or []
)
top = formula.head(blk.get("글") or [], blk.get("글행"))
works = formula.works(nodes)
paths = {id(n): p for p, n in clause._paths(nodes)}
items = blk.get("항목") or []
usable = solve._usable(items)
whole = {k: top[k] + solve._gather(nodes, k) for k in ("상수", "식", "선택값")}
if len(works) < 2:
one = {"제목": "(블록 통째)", "번": None, "길": None, "항목": usable, **whole}
one["몫"] = works[0].get("몫") if works else None
return [one], whole, usable
out = []
for w in works:
at = [i for i in solve._gather([w], "항목") if not items[i].get("밖")]
part = {
"제목": f"{w['번']}. {w['제목']}",
"번": w["번"],
"길": paths.get(id(w)),
"항목": [items[i] for i in at],
}
part |= {k: top[k] + solve._gather([w], k) for k in ("상수", "식", "선택값")}
part["몫"] = w.get("몫")
out.append(part)
if not any(p["항목"] for p in out): # STmate — 글행 없음 → 항목 절 이름으로 붙임
for it in usable:
name = _flat(it.get("윗절") or it.get("절"))
hit = next(
(
p
for p in out
if name and (_flat(p["제목"]).endswith(name) or name in _flat(p["제목"]))
),
None,
)
if hit:
hit["항목"].append(it)
return out, whole, usable
def value_ev(rec: dict | None, parts: list[dict]) -> tuple[dict, list[dict], dict]:
"""값 길 — 블록 {확정, 동점, 약함} · 절마다 · 로직별 옵션."""
if rec is None:
return dict(NONE), [dict(NONE) for _ in parts], {}
opts: dict = {}
for b in rec.get("기초", []) + rec.get("동점", []):
opts.setdefault(b["로직"], {}).update(b.get("옵션") or {})
blk = {
"확정": [b["로직"] for b in rec.get("기초", [])] if rec["판정"] in ("U", "P") else [],
"동점": [b["로직"] for b in rec.get("동점", [])] if rec["판정"] == "A" else [],
"약함": [b["로직"] for b in rec.get("약한후보", [])[:1]],
}
secs, out = rec.get("절") or [], []
for i, p in enumerate(parts):
s = None
if len(parts) > 1:
head = lambda x: _flat(x["절"]).split(".")[-1][:6] # noqa: E731
s = (
secs[i]
if len(secs) == len(parts)
else next((x for x in secs if head(x) and head(x) in _flat(p["제목"])), None)
)
one = {"확정": [], "동점": [], "약함": []}
if s and s["판정"] in ("U", "P") and s.get("기초"):
one["확정"] = [s["기초"]["로직"]]
opts.setdefault(s["기초"]["로직"], {}).update(s["기초"].get("옵션") or {})
elif s and s["판정"] == "A":
one["동점"] = list(s.get("동점") or [])
out.append(one)
return blk, out, opts
def is_machine(part: dict) -> bool:
syms = {chk.base_sym(c.get("기호") or "") for c in part["상수"]} | {
chk.base_sym(f.get("기호") or "") for f in part["식"]
}
return len(syms - {""}) >= 2
def pick(
c: Ctx, N: list, T: list, Vb: dict, Vs: dict, machine: bool, single: bool, title: str
) -> tuple[list, str]:
"""일반: 번호∩값 › 값 › 번호∩제목 › 값동점∩제목 › 번호 › 제목 /
기계 절: 번호∩제목 › 번호∩값 › 제목∩값 › 번호 › 제목 › 값."""
Tk = [k for k, _ in T]
sure = [
k
for k in (Vs["확정"] or (Vb["확정"] if single else []))
if not title or dice(c, k, title) >= 0.2
]
anyv = list(dict.fromkeys(Vs["확정"] + Vs["동점"] + Vb["확정"] + Vb["동점"] + Vb["약함"]))
def inter(a, b):
return [x for x in a if x in set(b)]
if machine:
steps = [
("번호∩제목", inter(N, Tk)),
("번호∩값", inter(N, anyv)),
("제목∩값", inter(Tk, anyv)),
("번호", N),
("제목", Tk),
("값", sure),
]
else:
steps = [
("번호∩값", inter(N, anyv)),
("값", sure),
("번호∩제목", inter(N, Tk)),
("값동점∩제목", inter(Vs["동점"] + Vb["동점"], Tk)),
("번호", N),
("제목", Tk),
]
for name, s in steps:
if s:
return list(dict.fromkeys(s)), name
return [], "없음"
def _good(v: dict) -> bool:
return "오류" not in v and not v["셈"][solve.DIFF] and bool(v["셈"][solve.SAME])
def resolve(S: list, how: str, check) -> tuple[list, str]:
"""후보 2~6 → 검산(품 같음 있고 품 다름 없는 후보 하나)으로 거름."""
if 2 <= len(S) <= 6:
good = [k for k in S if _good(check(k))]
if len(good) == 1:
return good, how + "+검산"
return S, how
def kind_of(S: list) -> str:
return "확정" if len(S) == 1 else "후보 2~3" if 2 <= len(S) <= 3 else "없음"
def _found(c: Ctx, key: str, v: dict, mc: list, rec: dict | None, how: str) -> dict:
kind, missing, differ = chk.judge_one(v, mc)
return {
"꼴": kind,
"기초": key,
"기초이름": c.logic.get(key, {}).get("이름", ""),
"차례": how,
"옵션": v.get("입력값"),
"옵션길": v.get("길"),
"입력필요": v.get("입력필요"),
"0간주": v.get("0간주"),
"글에없음": missing,
"값다름": differ,
"기계상수": mc,
"셈": v.get("셈"),
"다름까닭": chk.causes(v, mc, rec) if differ else [],
}
def run_block(c: Ctx, blk: dict, line: dict | None, same: list, rec: dict | None) -> dict:
"""블록 하나 합친 길 — {꼴, 블록길, 절 [...], 통째, 기초 [키]} (648-D2 꼴)."""
basis = clause.basis(blk)
parts, whole, usable = parts_of(blk)
title_b = f"{blk.get('이름') or ''} {blk.get('규격') or ''}".strip()
src = {"줄": line, "같은줄": same, "블록": blk}
src["제목줄"] = {
"글": re.sub(r"[-_/()\[\],~]+", " ", f"{blk.get('이름') or ''} {blk.get('규격') or ''}"),
"행": None,
}
Vb, Vsecs, vopts = value_ev(rec, parts)
allN = list(dict.fromkeys(x["키"] for x in basis["후보"]))
memo: dict = {}
def check(key, items, cons, share):
tag = (key, id(items), share, repr(cons)) # 값에 items 를 쥐어 id 를 다시 안 씀
if tag not in memo:
memo[tag] = (items, chk.verify(c.m, key, items, cons, src, share, vopts.get(key, {})))
return memo[tag][1]
def share_of(p):
s = p.get("몫")
return (s.get("값") if isinstance(s, dict) else s) or 1
secs = []
for p, Vs in zip(parts, Vsecs, strict=True):
if p["길"]:
N = [
x["키"]
for x in basis["후보"]
if x.get("절") in (None, p["길"]) or str(p["길"]).startswith(f"{x.get('절')}.")
]
text = f"{p['제목']} {title_b}"
else:
N, text = allN, title_b
N, mach = list(dict.fromkeys(N)), is_machine(p)
T = title_cands(c, text, blk.get("단위"))
S, how = pick(c, N, T, Vb, Vs, mach, len(parts) == 1, text)
cons = {x: p[x] for x in ("상수", "식", "선택값")}
S, how = resolve(S, how, lambda k, p=p, cons=cons: check(k, p["항목"], cons, share_of(p)))
secs.append(
{
"절": p["제목"],
"길": p["길"],
"항목수": len(p["항목"]),
"기계식": mach,
"번호": N,
"제목후보": [k for k, _ in T],
"차례": how,
"후보": S,
"_p": p,
}
)
chosen = {i: s["후보"][0] for i, s in enumerate(secs) if len(s["후보"]) == 1}
bases = list(dict.fromkeys(chosen.values()))
group = {}
for b in bases: # 같은 기초 절끼리 묶어 검산(기초 하나뿐이면 블록 통째)
if len(bases) == 1:
items, cons, ps = usable, whole, list(parts)
else:
ps = [secs[i]["_p"] for i, k in chosen.items() if k == b]
items = [x for p in ps for x in p["항목"]]
cons = {x: [y for p in ps for y in p[x]] for x in ("상수", "식", "선택값")}
v = check(b, items, cons, share_of(ps[0]) if len(ps) == 1 else 1)
mc = (
chk.mach_check(c.idx, b, chk.mach_consts(cons))
if any(s["기계식"] for s in secs)
else []
)
group[b] = (v, mc)
for i, s in enumerate(secs):
s.pop("_p")
if (
i in chosen
and s["차례"] == "제목"
and not _good({"셈": group[chosen[i]][0].get("셈") or {solve.DIFF: 0, solve.SAME: 0}})
):
s |= {"꼴": "후보 2~3", "제목단독": True} # 제목 하나뿐 · 품 대조로 못 받침 = 사람 확인
elif i in chosen:
s |= _found(c, chosen[i], *group[chosen[i]], rec, s["차례"])
else:
s["꼴"] = kind_of(s["후보"])
kinds = [s["꼴"] for s in ([s for s in secs if s["항목수"]] or secs)]
if all(k == "확정·전부" for k in kinds):
bk = "확정·전부"
elif all(k.startswith("확정") for k in kinds):
bk = "확정·일부"
elif any(k.startswith("확정") for k in kinds):
bk = "확정·일부(절 미확정)"
else:
bk = "후보 2~3" if "후보 2~3" in kinds else "없음"
out = {"절": secs, "표지": basis["표지"], "출처": basis["출처"], "번호후보": allN}
wb = None
if len(parts) > 1: # 블록 통째 길 — 블록 제목 · 번호 · 블록 값으로 기초 하나 → 블록 전부로 검산
mach = any(s["기계식"] for s in secs)
S, how = pick(
c, allN, title_cands(c, title_b, blk.get("단위")), Vb, dict(NONE), mach, True, title_b
)
S, how = resolve(S, how, lambda k: check(k, usable, whole, 1))
if len(S) == 1:
v = check(S[0], usable, whole, 1)
mc = chk.mach_check(c.idx, S[0], chk.mach_consts(whole)) if mach else []
wb = _found(c, S[0], v, mc, rec, how)
if how == "제목" and not _good({"셈": v.get("셈") or {solve.DIFF: 0, solve.SAME: 0}}):
wb["꼴"] = "후보 2~3"
else:
wb = {"꼴": kind_of(S), "후보": S, "차례": how}
if wb and ORDER.index(wb["꼴"]) < ORDER.index(bk) and (wb.get("셈") or {}).get(solve.SAME):
out |= {"꼴": wb["꼴"], "블록길": "통째", "통째": wb, "기초": [wb["기초"]]}
else:
out |= {"꼴": bk, "블록길": "절 모음" if len(parts) > 1 else "통째(절 하나)", "통째": wb}
out["기초"] = list(dict.fromkeys(s["기초"] for s in secs if s.get("기초")))
return out
# ── 세 단 · 근거후보 ────────────────────────────────────────────────
def chosen_units(r: dict) -> list[dict]:
"""블록 판정에 쓰인 단위 — 통째 길이면 통째 하나 · 아니면 기초마다 첫 절."""
if r["블록길"] == "통째" and r.get("통째"):
return [r["통째"]]
out, seen = [], set()
for s in r["절"]:
if s.get("기초") and s["기초"] not in seen:
seen.add(s["기초"])
out.append(s)
return out
def checked(s: dict) -> str:
"""품 검산 — 다름 · 같음 · 짝 없음."""
cnt = s.get("셈") or {}
return "다름" if cnt.get(solve.DIFF) else "같음" if cnt.get(solve.SAME) else "짝 없음"
def _tier(s: dict) -> str:
return "확정" if checked(s) == "같음" and not s["값다름"] else "근접"
def cands(c: Ctx, r: dict) -> list[dict]:
"""근거후보 [{기초, 이름, 원문번호, 옵션, 옵션길, 입력필요, 품검산, 단, 까닭, 글에없음,
값다름}] ·
사람 = 후보 2~3(각 후보 · 옵션 없음) · 없음 = []."""
if r["꼴"].startswith("확정"):
out = []
for s in chosen_units(r):
opts = {k: v for k, v in (s.get("옵션") or {}).items()}
row = c.logic.get(s["기초"], {})
out.append(
{
"기초": s["기초"],
"이름": row.get("이름", ""),
"원문번호": row.get("원문번호"),
"차례": s["차례"],
"옵션": opts,
"옵션길": s.get("옵션길") or {},
"입력필요": list(s["입력필요"] or []) + list(s["0간주"] or []),
"품검산": checked(s),
"단": _tier(s),
"까닭": [[chk.CAUSE[t], x] for t, x in s["다름까닭"]],
"글에없음": s["글에없음"],
"값다름": s["값다름"],
}
)
return out
if r["꼴"] == "후보 2~3":
keys = list(
dict.fromkeys(
k for s in r["절"] if s["꼴"] == "후보 2~3" for k in (s.get("후보") or [])
)
)
return [
{
"기초": k,
"이름": c.logic.get(k, {}).get("이름", ""),
"원문번호": c.logic.get(k, {}).get("원문번호"),
"옵션": {},
"입력필요": [],
"품검산": None,
"단": "사람",
"까닭": [],
}
for k in keys[:6]
]
return []
def lack(blk: dict, r: dict) -> dict:
"""없음 까닭 — a 기초 없음(번호 확인) · a′ 품목 없음 의심 · f 후보 4+ · 증거 흩어짐 · 794-2
보강 근거.
"""
if r["표지"] == "기초 없음":
why = "a 기초 없음(번호 확인)"
elif any(s.get("후보") for s in r["절"]):
why = "f 후보 4+ · 증거 흩어짐"
else:
why = "a′ 품목 없음 의심"
name = re.sub(r"\(.*?\)|\[.*?\]|[^가-힣]", "", f"{blk.get('이름') or ''}")[:8]
return {"까닭": why, "이름": name, "품셈": [f"{b} {n}" for b, n in r["출처"]]}
def suggest(
blk: dict,
kind: str,
line: dict | None = None,
same: list | None = None,
g: list[str] | None = None,
) -> dict:
"""산근 블록 하나 → 근거 제안 {단, 꼴, 근거후보, 절, 보강?, 값판정}.
`kind` = 책 꼴(STmate · 엑셀꼴) · `line` · `same` = 부르는 첫 줄 · 그 호표 다른 줄(B1 · B6) ·
`g` = STmate 숨은 G 식(없으면 보이는 글)."""
c = ctx()
pr = bc.prep(blk, kind, g)
rec = bv.value(c.idx, pr)
r = run_block(c, blk, line, same or [], rec)
out = {**r, "값판정": rec["판정"], "근거후보": cands(c, r)}
if r["꼴"].startswith("확정"):
tiers = {x["단"] for x in out["근거후보"]}
outside = [
m[6:] for x in out["근거후보"] for m in x["글에없음"] if m.startswith("기초 밖 줄 ")
]
out["까닭"] = [["기초 밖 줄", ", ".join(outside)]] if outside else [] # 엑셀에만 있는 줄
sure = tiers == {"확정"} and r["꼴"] != "확정·일부(절 미확정)"
out["단"] = "확정" if sure else "근접"
else:
out["단"] = {"후보 2~3": "사람"}.get(r["꼴"], "없음")
weak = (rec.get("약한후보") or [None])[0]
title = f"{blk.get('이름') or ''} {blk.get('규격') or ''}"
if (
weak
and weak["로직"] not in {x["기초"] for x in out["근거후보"]}
and dice(c, weak["로직"], title) >= 0.2
):
row = c.logic.get(
weak["로직"], {}
) # 값 길 약한 1 등(유의 4 미만) — 제목과 뜻 겹치면 사람 후보 맨 앞
opts = {k: v for k, v in weak["옵션"].items()}
out["근거후보"].insert(
0,
{
"기초": weak["로직"],
"이름": row.get("이름", ""),
"원문번호": row.get("원문번호"),
"차례": "값약함",
"옵션": opts,
"옵션길": dict.fromkeys(opts, "V"),
"입력필요": [],
"품검산": None,
"단": "사람",
"까닭": [],
},
)
out["단"] = "사람"
if out["단"] == "없음":
out["보강"] = lack(blk, r)
return out
+324
View File
@@ -0,0 +1,324 @@
"""근거 제안 4 — 기초 하나 검산 · 옵션 · 까닭(PLAN 793-3a · 분석 648-D2 1 장) · 읽기만 · 지어 넣는
값 없음.
옵션 = 기존 `M02_Hopyo_Bind.bind`(B1 부르는 줄 규격 · B2 선택값 + 블록 제목 낱말 · B3 기호표 · B4
대입식 ·
B5 표 거꾸로 · B6 레미콘 규격) → 못 읽은 칸 = 값 길 옵션(「V」) → 그래도 고르기 칸이면 고르기
값마다 넣어
엑셀 품과 맞댐(조합 60 까지 · 다 같음 하나 = 「검산」 · ±15 % 근접 하나 = 「근접」) → 남은 칸 =
입력 필요.
감률 · 할증 · 가산 · 보정 · 구멍땜 수 칸은 셈 때만 0(검산 몫) · 옵션으로 안 냄(입력 필요 · 요구
647-1).
품 대조 = `M02_Hopyo_Solve.pair`(엑셀 표시 자릿수 반올림 폭) · 기계 상수(q · K · f · E · E0 · Cm) =
그 로직(하위 포함)이 닿는 표의 같은 기호 칸 · 같은 기호 줄 값과 대조(`mach_check`).
까닭(`causes`) = b 판 차이 · c 현장 값 · d 감률 · 할증 배수 · e 단위 기준 차이 · f 그 밖(분석
648-D2 5-2 규칙).
"""
from __future__ import annotations
import itertools
import math
import re
from typing import Any
from M02_MasterTemplete import M02_Hopyo_Bind as hb
from M02_MasterTemplete import M02_Hopyo_Solve as solve
from M02_MasterTemplete import M02_San_Basis_Index as bi
RATIOS = {
8: "일↔시간 ×8",
1 / 8: "일↔시간 ÷8",
100: "% ×100",
0.01: "% ÷100",
1000: "t↔kg ×1000",
0.001: "t↔kg ÷1000",
10: "×10",
0.1: "÷10",
60: "분↔시간 ×60",
1 / 60: "분↔시간 ÷60",
}
CAUSE = {
"b": "판 차이",
"c": "현장 값",
"d": "감률 · 할증 배수",
"e": "단위 기준 차이",
"f": "그 밖",
}
ZERO = re.compile(r"감률|할증|가산|구멍땜|보정|증가|추가")
# ── 기계 상수 ↔ 그 로직 표 ──────────────────────────────────────────
def _norm(s: Any) -> str:
s = re.sub(r"\(.*?\)", "", str(s or "").replace("㎝", "cm").lower())
return re.sub(r"[^a-z0-9]", "", s)
def base_sym(s: str) -> str:
m = re.match(r"(cm|e0|q|k|f|e)\d*$", _norm(s))
return m.group(1) if m else ""
def mach_consts(cons: dict) -> list[dict]:
out = list(cons.get("상수", []))
for f in cons.get("식", []): # 식 끝값(Cm = t1+…+t4 = 10.7)은 셈 결과 — 대입 수만
out += [{"기호": k, "값": v} for k, v in (f.get("대입") or {}).items()]
return out
def mach_check(idx: bi.Index, key: str, consts: list[dict]) -> list[dict]:
out, seen = [], set()
tables = idx.item_tables.get(key, set())
for c in consts:
sym, v = base_sym(c.get("기호") or ""), c.get("값")
if not sym or not isinstance(v, (int, float)) or (c.get("기호"), v) in seen:
continue
seen.add((c.get("기호"), v))
pool = []
for t in tables:
tb = idx.tables[t]
for row in tb.get("줄", []):
row_sym = any(isinstance(x, str) and base_sym(x) == sym for x in row.values())
for col in tb.get("값칸", {}):
if base_sym(col) == sym or row_sym:
pool += bi.nums_of(row.get(col))
if not pool:
out.append({"기호": c["기호"], "값": v, "대조": "칸 없음"})
continue
d = len(str(v).split(".")[1]) if "." in str(v) else 0
near = min(pool, key=lambda x: abs(x - v))
hit = abs(near - v) <= max(0.5 * 10 ** (-d), abs(v) * 0.005)
out.append(
{
"기호": c["기호"],
"값": v,
"대조": "표에 있음" if hit else "표에 없음",
"가까운": near,
}
)
return out
# ── 옵션 · 검산 ─────────────────────────────────────────────────────
def zeroable(m: Any, key: str, name: str) -> bool:
"""글에 없으면 0(없음)으로 볼 만한 수 칸 — 고르기 없음 · 이름이 감률 · 할증 · 가산 · 보정 류."""
spec = next((x for x in hb.specs_of(m.logic(key)) if x["이름"] == name), {})
return not spec.get("고르기") and bool(ZERO.search(name))
def _conv(v: Any) -> Any:
if isinstance(v, list):
ends = [x for x in v if isinstance(x, (int, float))]
return sum(ends) / len(ends) if ends else None
return v
def probe(
m: Any, key: str, specs: dict, hard: list, base: dict, items: list, share: Any
) -> dict | None:
"""못 읽은 고르기 칸을 고르기 값마다 넣어 엑셀 품과 맞댐 — 「검산」 · 「근접」 옵션."""
if not hard or not all(specs.get(n, {}).get("고르기") for n in hard):
return None
opts = [specs[n]["고르기"] for n in hard]
if math.prod(len(o) for o in opts) > 60:
return None
exact, close = [], []
def sig(rr):
return tuple(round(r["우리"]["품"] or 0, 6) for r in rr if r.get("우리"))
for combo in itertools.product(*opts):
g2 = dict(zip(hard, combo))
try:
rr = solve.pair(hb.quantities(m, key, {**base, **g2}), solve._usable(items), share)
except Exception: # noqa: BLE001 — 그 조합은 셈 못 함
continue
same = sum(r["판정"] == solve.SAME for r in rr)
if any(r["판정"] == solve.NEED for r in rr) or not same:
continue
ratios = [
r["엑셀"]["품"] / r["우리"]["품"]
for r in rr
if r["판정"] == solve.DIFF and r["엑셀"]["품"] and r["우리"]["품"]
]
if not ratios:
exact.append((g2, rr))
elif all(abs(x - 1) <= 0.15 for x in ratios):
close.append((same, -len(ratios), g2, rr))
if len(exact) == 1:
return {"값": exact[0][0], "줄": exact[0][1], "길": "검산"}
if len(exact) > 1 and len({sig(rr) for _, rr in exact}) == 1:
return {"값": exact[0][0], "줄": exact[0][1], "길": "검산(같은 품 여럿)"}
if not exact and close:
close.sort(key=lambda x: (-x[0], -x[1]))
top = [c for c in close if (c[0], c[1]) == (close[0][0], close[0][1])]
if len(top) == 1 or len({sig(c[3]) for c in top}) == 1:
return {
"값": close[0][2],
"줄": close[0][3],
"길": "근접" + ("" if len(top) == 1 else "(같은 품 여럿)"),
}
return None
def verify(m: Any, key: str, items: list, consts: dict, src: dict, share: Any, vopts: dict) -> dict:
"""기초 하나 검산 — bind(B1~B6) · 값 길 옵션 · 고르기 맞댐 · 엑셀 품 대조."""
mine = {**src, **consts}
if src.get("제목줄"):
mine["선택값"] = list(consts.get("선택값") or []) + [src["제목줄"]]
try:
b = hb.bind(m, key, mine, solve.fitter(m, key, items, share))
specs = {s["이름"]: s for s in hb.specs_of(m.logic(key))}
given = {**b["입력값"], **{n: v[0]["값"] for n, v in b["엇갈림"].items()}}
fromv = {
n: _conv(vopts[n])
for n in b["입력필요"] + b["안씀"]
if n in vopts and _conv(vopts[n]) is not None
}
need = [n for n in b["입력필요"] if n not in fromv]
zero = {n: 0 for n in need if zeroable(m, key, n)}
idle = {
n: b["기본"][n] for n in b["안씀"] if b["기본"].get(n) is not None and n not in fromv
}
base = {**idle, **given, **fromv, **zero}
rows = solve.pair(hb.quantities(m, key, base), solve._usable(items), share)
got = probe(m, key, specs, [n for n in need if n not in zero], base, items, share)
if got:
fromv |= got["값"]
need = [n for n in need if n not in got["값"]]
rows = got["줄"]
same_q = [] # 엇갈림인데 어느 값이든 품이 같음 = 문제 아님
for n, alts in b["엇갈림"].items():
sigs = set()
for alt in alts:
q = hb.quantities(m, key, {**idle, **given, **fromv, **zero, n: alt["값"]})
sigs.add(
tuple(round(float(x["품"]), 6) if x.get("품") is not None else None for x in q)
)
if len(sigs) == 1:
same_q.append(n)
except Exception as e: # noqa: BLE001 — 검산 못 함 = 오류 표지(확정 안 함)
return {"키": key, "오류": repr(e)[:160]}
blk = src["블록"]
text = " ".join(
[str(blk.get("이름") or ""), str(blk.get("규격") or "")]
+ [str(t) for t in blk.get("글") or []]
)
words = {"소량": "소량|소형|소규모", "높이": "높이|수직고"}
absorbed = [
[n, hb.plain(given[n])]
for n, w in b["출처"].items()
if w["길"] == "B5"
and n.endswith("감률")
and n in given
and not re.search(words.get(n.replace("감률", ""), n.replace("감률", "") or "소량"), text)
]
cnt = {
v: sum(r["판정"] == v for r in rows)
for v in (solve.SAME, solve.DIFF, solve.NEED, solve.EXCEL, solve.OURS)
}
diff = []
for r in rows:
if r["판정"] == solve.DIFF:
ex, us = r["엑셀"]["품"], r["우리"]["품"]
diff.append(
{
"줄": r["엑셀"].get("이름") or r["우리"].get("이름"),
"엑셀": ex,
"우리": us,
"배": round(ex / us, 4) if ex and us else None,
"묶음": r["엑셀"].get("묶음"),
}
)
outside = [
r["엑셀"].get("이름")
for r in rows
if r["판정"] == solve.EXCEL and r["엑셀"] and r["엑셀"].get("품")
]
way = {n: alts[0]["길"] for n, alts in b["엇갈림"].items()} # 엇갈림 = 첫 값
way |= {k: v["길"] for k, v in b["출처"].items()}
way |= {k: "V" for k in fromv if not got or k not in got["값"]}
way |= {k: got["길"] for k in got["값"]} if got else {}
return {
"키": key,
"셈": cnt,
"기초밖": outside,
"감률흡수": absorbed,
"입력값": {
**{k: hb.plain(v) for k, v in given.items()},
**{k: hb.plain(v) for k, v in fromv.items()},
},
"길": way,
"입력필요": [n for n in need if n not in zero],
"0간주": list(zero),
"엇갈림": {
k: [hb.plain(x["값"]) for x in v] for k, v in b["엇갈림"].items() if k not in same_q
},
"품다름": diff,
}
def judge_one(v: dict, mach: list[dict]) -> tuple[str, list, list]:
"""(확정·전부 | 확정·일부, 글에 없음, 값이 M01 과 다름)."""
if "오류" in v:
return "확정·일부", [], [f"검산 오류 {v['오류'][:60]}"]
missing = list(v["입력필요"]) + [f"옵션 여럿 {k} {vals}" for k, vals in v["엇갈림"].items()]
missing += [f"기초 밖 줄 {x}" for x in v.get("기초밖", [])]
differ = [
f"품 {d['줄']} 엑셀 {d['엑셀']:.4g} ≠ 우리 {d['우리']:.4g}"
for d in v["품다름"]
if d["엑셀"] and d["우리"]
]
differ += [
f"기계 {x['기호']}={x['값']} 표에 없음(가까운 {x['가까운']:g})"
for x in mach
if x["대조"] == "표에 없음"
]
differ += [f"감률 {n}={x} 로 흡수(글에 그 말 없음) — 품 배수" for n, x in v.get("감률흡수", [])]
differ += [
f"근접 옵션 {k}={v['입력값'].get(k)}"
for k, w in (v.get("길") or {}).items()
if str(w).startswith("근접")
]
if not v["셈"].get(solve.SAME) and not v["셈"].get(solve.DIFF):
missing.append("(품 짝 없음)")
return ("확정·전부" if not missing and not differ else "확정·일부"), missing, differ
def causes(v: dict, mach: list[dict], rec: dict | None) -> list[list[str]]:
"""값 다름 → [갈래 b~f, 글] 【추정 규칙 · 648-D2 5-2】."""
out = []
user = bool(rec) and (
bool(rec.get("입력"))
or any(
"kg/" in str(k.get("단위", "")).lower() or "㎏/" in str(k.get("단위", ""))
for k in rec.get("K", [])
)
)
labor = [d["배"] for d in v.get("품다름", []) if d["배"] and d["묶음"] in ("L", "X")]
uniform = len(labor) >= 2 and all(abs(x / labor[0] - 1) <= 0.02 for x in labor)
for d in v.get("품다름", []):
r = d["배"]
if not r:
out.append(["f", f"{d['줄']} 품 0"])
continue
norm = next((name for k, name in RATIOS.items() if abs(r / k - 1) <= 0.01), None)
if norm:
out.append(["e", f"{d['줄']} {norm}"])
elif uniform and abs(r - 1) > 0.15:
out.append(["d", f"{d['줄']} 같은 배 ×{r:g}"])
elif abs(r - 1) <= 0.15:
out.append(["c" if user else "b", f"{d['줄']} ×{r:g}"])
else:
out.append(["c" if user else "f", f"{d['줄']} ×{r:g}"])
for n, x in v.get("감률흡수", []):
out.append(["b" if isinstance(x, (int, float)) and x < 15 else "d", f"감률 {n}={x} 흡수"])
for x in mach:
if x["대조"] == "표에 없음":
out.append(
[
"b" if x["가까운"] and abs(x["값"] / x["가까운"] - 1) <= 0.15 else "c",
f"기계 {x['기호']}={x['값']}",
]
)
return out
+378
View File
@@ -0,0 +1,378 @@
"""근거 제안 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]
+611
View File
@@ -0,0 +1,611 @@
"""근거 제안 1 — M01 값 역색인 · 숫자 정규화 한 벌(PLAN 793-3a · 요구 648 · 분석 648-D 1 장) ·
읽기만.
칸(`cells`) = 표 칸 하나 {v, 표, 줄, 칸, 단위, 종류 val(값칸) | cond(조건 수) | ratio(「a 인 / b」
몫) |
기준(「10㎡」 당) | eq(기계 요소 값)} · 쓰임(`uses`) = 표 → [(로직, 부름, 칸 | None, 글자 거름,
입력 묶음)]
(하위 로직 3 겹까지 펼침 · 인자 이름 바꿈) · 식 속 수(`inline`) · 호표 수량 속 수(`inline_trade` ·
직종 표지).
정규화(`lookup`) — N1 표시 자릿수 반올림 · N2 % ↔ 소수 · N3 t ↔ kg · N4 인/양 몫 · N5 직종 묶음 ·
N6 표 기준 · N7 식 속 수 · N8 단위 성격 · N9 일반 수 · N10 입력 뺌(N9 · N10 =
`M02_San_Basis_Const`).
우연 보정(`q_of` · `pb_sig`) = 보정 자료(실무 여덟 파일 상수 · `M02_San_Basis_corpus.json`) 몫 ·
포아송 이항 꼬리 −log10.
짓기 = M01 폴더 지문(`stamp`)마다 한 번(`get` · 프로세스 안 캐시 · 지문 바뀌면 다시).
"""
from __future__ import annotations
import ast
import bisect
import glob
import json
import math
import re
import threading
import unicodedata
from collections import defaultdict
from functools import lru_cache
from pathlib import Path
from typing import Any
from common_util import common_util_derived_store as derived
from M02_MasterTemplete import M02_Hopyo_Bind as hb
from M02_MasterTemplete import M02_Hopyo_ReadEst as est
mf, ma = hb.mf, hb.ma
CORPUS = Path(__file__).with_name("M02_San_Basis_corpus.json")
INLINE_MAX = 20 # 식 속 수가 이보다 많은 로직(표를 식으로 펼친 것)은 식 속 수 맞음을 안 셈
PERSON = ("인",)
# ── 단위 성격(N8) ─────────────────────────────────────────────────────
FAM = [
x.split(":")
for x in (
"인:사람 ㎡:넓이 m2:넓이 ㎥:부피 m3:부피 ℓ:부피 km:길이 ㎝:길이 cm:길이 ㎜:길이 mm:길이 "
"m:길이 ㎏:무게 kg:무게 ton:무게 톤:무게 t:무게 g:무게 %:율 hr:시간 h:시간 시간:시간 "
"분:시간 min:시간 초:시간 sec:시간 일:시간 회:수 본:수 개:수 매:수 조:수 대:수 ea:수"
).split()
]
def fam(unit: Any) -> str | None:
u = unicodedata.normalize("NFKC", str(unit or "")).strip().lower().split("/")[0].strip()
if "%" in u:
return "율"
for a, f in FAM:
if u == a or u.startswith(a) and a not in ("m", "t", "h", "g"):
return f
return None
def unit_ok(tu: Any, cu: Any) -> bool:
a, b = fam(tu), fam(cu)
return a is None or b is None or a == b
_OPS = {
ast.Add: float.__add__,
ast.Sub: float.__sub__,
ast.Mult: float.__mul__,
ast.Div: float.__truediv__,
}
def _calc(node: ast.AST) -> float:
"""수 · 사칙 · 부호만(그 밖 = ValueError) — eval 없음."""
if isinstance(node, ast.Expression):
return _calc(node.body)
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return float(node.value)
if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd)):
x = _calc(node.operand)
return -x if isinstance(node.op, ast.USub) else x
if isinstance(node, ast.BinOp) and type(node.op) in _OPS:
return _OPS[type(node.op)](_calc(node.left), _calc(node.right))
raise ValueError(node)
def _arith(s: str) -> float | None:
"""「480 - 30」 · 「1/1.30」 → 수 · 못 셈 None."""
if not re.fullmatch(r"[\d.\s+\-*/()]+", s) or not re.search(r"\d", s):
return None
try:
return _calc(ast.parse(s.strip(), mode="eval"))
except (SyntaxError, ValueError, ZeroDivisionError):
return None
def nums_of(v: Any) -> list[float]:
"""칸 값 → [수] — 수 · [아래, 위] · 식 글자(셈 값 + 낱 수)."""
if isinstance(v, bool) or v is None:
return []
if isinstance(v, (int, float)):
return [float(v)]
if isinstance(v, list):
return [float(x) for x in v if isinstance(x, (int, float)) and not isinstance(x, bool)]
if isinstance(v, str):
got = _arith(v)
if got is None:
return []
parts = [float(x) for x in re.findall(r"\d+(?:\.\d+)?", v)]
return [got] + [p for p in parts if p != got]
return []
# ── 직종(N5) ─────────────────────────────────────────────────────────
@lru_cache(maxsize=None)
def trade(name: str) -> str | None:
return est.trade(name)
def _flat(t: Any) -> str:
return re.sub(r"[\s()()_\-·.]", "", re.sub(r"\(.*?\)|_.*$", "", str(t or "")))
def canon(t: str) -> str:
return trade(t) or trade(_flat(t)) or _flat(t)
def same_trade(a: str, b: str) -> bool:
x, y = canon(a), canon(b)
return bool(x and y) and (x == y or len(y) >= 2 and len(x) >= 2 and (x in y or y in x))
# ── M01 읽기 · 로직 훑기 ─────────────────────────────────────────────
def folder() -> Path:
return Path(hb.m01.FOLDER)
def stamp() -> str:
"""M01 지문 — 첫 층 파일 내용(`M02_Hopyo_Judge._stamp` 와 같은 몫) + 보정 자료."""
files = {
p.name: derived.file_digest(p)
for p in sorted(folder().glob("*.json"))
if not p.name.startswith("_")
}
return derived.derived_key("san-basis-index", files, derived.file_digest(CORPUS))
def load(root: Path) -> tuple[dict, dict, dict, dict]:
tables, logic, eq = {}, {}, {}
for f in glob.glob(str(root / "소요량_*.json")) + glob.glob(str(root / "계수_*.json")):
for t in json.loads(Path(f).read_text(encoding="utf-8")).get("표", []):
tables[t["키"]] = t
for f in glob.glob(str(root / "로직_*.json")):
for it in json.loads(Path(f).read_text(encoding="utf-8"))["줄"]:
logic[it["키"]] = it
for r in json.loads((root / "기계.json").read_text(encoding="utf-8"))["줄"]:
eq[r["키"]] = r
by_no: dict = {}
for k, t in tables.items():
by_no.setdefault((k[:2], str(t.get("원문번호"))), []).append(k)
return tables, logic, eq, by_no
def _resolve(ref: str, tables: dict, by_no: dict) -> str | None:
if "{" in ref:
return None
if ref in tables:
return ref
tid, _, no = ref.partition(":")
hit = by_no.get((tid, no)) or []
return hit[0] if len(hit) == 1 else None
def scan(item: dict, tables: dict, by_no: dict) -> tuple:
"""로직 하나 → (찾기 부름, 식 속 수, 하위 로직 부름, 기계 부름, 호표 수량 속 수)."""
names = {s["이름"] for s in item.get("입력", [])}
finds, nums, calls, eqs = [], [], [], []
def arg_of(node):
if node[0] == "str":
return ("lit", node[1])
if node[0] == "num":
return ("lit", float(node[1]))
if node[0] == "name":
return ("name", node[1]) if node[1] in names else ("expr", node[1])
return ("expr", str(node)[:80])
def visit(node, field=None):
kind = node[0]
if kind == "field":
visit(node[1], node[2])
elif kind in ("find", "logic"):
if kind == "find" and (ref := _resolve(node[1], tables, by_no)):
finds.append((ref, field, {c: arg_of(a) for c, a in node[2]}))
if kind == "logic" and isinstance(node[1], str) and "{" not in node[1]:
calls.append((node[1], {c: arg_of(a) for c, a in node[2]}))
for _, a in node[2]:
if a[0] not in ("str", "num", "name"):
visit(a)
elif kind == "ref":
if node[1].startswith("EQ") and field:
eqs.append((node[1], field))
elif kind == "num":
nums.append(float(node[1]))
elif kind == "bin":
for side in (node[2], node[3]): # 비교 글자(「SCH == 20」)는 셈 수가 아님
if node[1] in mf._OPS and side[0] == "num":
continue
visit(side)
elif kind == "neg":
visit(node[1])
elif kind == "call":
for a in node[2]:
visit(a)
for text in ma._texts(item):
if any(text is h["수량"] for h in item.get("호표", [])):
continue
try:
visit(mf.parse(text))
except mf.FormulaError:
pass
trade_nums = [] # (호표 이름, 수량 식 속 수)
for h in item.get("호표", []):
before = len(nums)
try:
visit(mf.parse(h["수량"]))
except mf.FormulaError:
continue
trade_nums += [(h.get("이름") or "", x) for x in nums[before:]]
return finds, nums, calls, eqs, trade_nums
def _cells(tables: dict, eq: dict) -> list[dict]:
cells: list[dict] = []
for tk, t in tables.items():
vcols = t.get("값칸", {}) or {}
base = re.match(r"\s*(\d+(?:\.\d+)?)\s*(\S*)", str(t.get("기준") or ""))
if base and float(base.group(1)) not in (0, 1): # 「10㎡」 · 「100m」 당 — 표 기준 수(N6)
cells.append(
{
"v": float(base.group(1)),
"표": tk,
"줄": 0,
"칸": "기준",
"단위": base.group(2),
"종류": "기준",
}
)
for ri, row in enumerate(t.get("줄", [])):
got = {}
for col, unit in vcols.items():
for x in nums_of(row.get(col)):
cells.append(
{
"v": x,
"표": tk,
"줄": ri,
"칸": col,
"단위": row.get("단위") or unit,
"종류": "val",
}
)
got.setdefault(col, x)
for col, kind in (t.get("조건", {}) or {}).items():
x = row.get(col)
if kind == "수" and isinstance(x, (int, float)) and not isinstance(x, bool):
cells.append(
{"v": float(x), "표": tk, "줄": ri, "칸": col, "단위": "", "종류": "cond"}
)
for a, ua in vcols.items(): # 인/양 몫(N4 · 「5인 / 45㎡」 = 0.111)
if not str(row.get("단위") or ua).startswith(PERSON) or a not in got:
continue
for b, ub in vcols.items():
ub = str(row.get("단위") or ub)
if b == a or b not in got or ub.startswith(PERSON) or "%" in ub or not got[b]:
continue
cells.append(
{
"v": got[a] / got[b],
"표": tk,
"줄": ri,
"칸": f"{a}/{b}",
"단위": "인",
"종류": "ratio",
}
)
for ek, r in eq.items(): # 기계 요소 수(가격 · 글 뺌)
for col, x in (r.get("값") or {}).items():
if col in ("가격", "주연료종류", "조종원직종") or col.endswith("가격"):
continue
unit = (r.get("단위") or {}).get(col, "") if isinstance(r.get("단위"), dict) else ""
cells += [
{"v": y, "표": ek, "줄": 0, "칸": col, "단위": unit or "", "종류": "eq"}
for y in nums_of(x)
]
return cells
# ── 역색인 ───────────────────────────────────────────────────────────
class Index:
def __init__(self, root: Path | None = None):
self.tables, self.logic, self.eq, by_no = load(root or folder())
self.cells = _cells(self.tables, self.eq)
self.scans = {k: scan(it, self.tables, by_no) for k, it in self.logic.items()}
order = sorted(range(len(self.cells)), key=lambda i: self.cells[i]["v"])
self.vals = [self.cells[i]["v"] for i in order]
self.ids = order
self.uses = defaultdict(list) # 표 → [(로직, 부름id, 칸 | None, 거름, 묶음)]
self.eq_uses = defaultdict(list) # 기계 칸 → [(로직, 부름id, (고정 | 입력, 값))]
self.inline = defaultdict(set)
self.inline_trade = defaultdict(set)
self.eq_no = {str(r.get("원문번호")): k for k, r in self.eq.items()} | {
k: k for k in self.eq
}
self.callers = defaultdict(set) # 하위 → 부르는 위 로직(어느 깊이든)
for k in self.logic:
self._expand(k, k, {}, {}, 0, k)
self.inl_index = defaultdict(set)
for k, xs in self.inline.items():
for x in xs:
self.inl_index[round(x, 9)].add(k)
self.trade_index = defaultdict(set)
for k, xs in self.inline_trade.items():
for _, x in xs:
self.trade_index[round(x, 9)].add(k)
self.cells_of_table = defaultdict(list)
for c in self.cells:
self.cells_of_table[c["표"]].append(c)
self.item_tables = defaultdict(set)
for t, us in self.uses.items():
for u in us:
self.item_tables[u[0]].add(t)
self._hits: dict = {}
self._look: dict = {}
self._df: dict = {}
self.prior()
def _expand(self, top, key, rename, lit, depth, path):
finds, nums, calls, eqs, tnums = self.scans[key]
for name, x in tnums:
self.inline_trade[top].add((name, x))
for ref, field, args in finds:
filt, bind = {}, {}
for col, (kind, val) in args.items():
if kind == "lit":
filt[col] = val
elif kind == "name" and depth == 0:
bind[col] = val
elif kind == "name" and val in rename:
bind[col] = rename[val]
elif kind == "name" and val in lit:
filt[col] = lit[val]
else: # 중간 · 식 · 안 넘긴 입력 — 같은 인자를 쓰는 부름끼리 같은 줄(가짜 입력 §)
bind[col] = f"§{path}:{val}"
if not filt and not bind: # 조건 없는 부름 — 같은 표 부름끼리 같은 줄
bind["#row"] = f"§{path}:{ref}"
gid = f"{path}|{ref}|{sorted(filt.items(), key=str)}|{sorted(bind.items())}"
self.uses[ref].append((top, gid, field, filt, bind))
self.inline[top].update(nums)
for ref, field in eqs:
m = re.match(r"EQ:\{(\w+)\}", ref)
if m:
arg = m.group(1)
inputs = {sp["이름"] for sp in self.logic[key].get("입력", [])}
if depth == 0 and arg in inputs:
use = ("입력", arg)
elif depth > 0 and arg in rename:
use = ("입력", rename[arg])
elif depth > 0 and arg in lit and self.eq_no.get(str(lit[arg])):
use = ("고정", self.eq_no[str(lit[arg])])
else:
use = ("입력", f"§{path}:EQ:{arg}")
self.eq_uses[field].append((top, f"{path}@{ref}", use))
elif ref.startswith("EQ0"):
self.eq_uses[field].append((top, f"{path}@{ref}", ("고정", ref)))
if depth >= 3:
return
for j, (sub, args) in enumerate(calls):
if sub not in self.logic:
continue
self.callers[sub].add(top)
ren, lt = {}, {}
for name, (kind, val) in args.items():
if kind == "lit":
lt[name] = val
elif kind == "name":
if depth == 0:
ren[name] = val
elif val in rename:
ren[name] = rename[val]
elif val in lit:
lt[name] = lit[val]
self._expand(top, sub, ren, lt, depth + 1, f"{path}>{sub}.{j}")
@staticmethod
def _pass(row: dict, filt: dict) -> bool:
"""칸 하나가 줄 거름을 통과?"""
for col, want in filt.items():
have = row.get(col)
if have is None:
continue
if isinstance(have, list):
if isinstance(want, float) and len(have) == 2:
lo, hi = have
if (lo is not None and want < lo) or (hi is not None and want > hi):
return False
continue
if isinstance(want, float):
try:
if float(have) != want:
return False
except (TypeError, ValueError):
return False
elif str(have) != str(want):
return False
return True
@staticmethod
def _trade_ok(name: str, c: dict) -> bool:
if c["종류"] == "eq":
return False
if c["종류"] != "ratio" and fam(c["단위"]) not in (None, "사람"):
return False
return same_trade(name, c["칸"].split("/")[0])
def lookup(self, k: dict) -> list[tuple[int, str]]:
"""상수 → [(칸 id, 꼴 eq | pct | kgt)] — N1 · N2 · N3 · N4 · N5 · N8(캐시)."""
key = kkey(k)
if key not in self._look:
self._look[key] = self._lookup(k)
return self._look[key]
def _lookup(self, k: dict) -> list[tuple[int, str]]:
v, d, unit = k["v"], k["d"], k["단위"]
sig = len(re.sub(r"^0+|\.", "", f"{v:.{d}f}").lstrip("0"))
tol = 0.5 * 10 ** (-d) if d >= 1 and sig >= 2 else 1e-9
if k.get("몫"):
tol = abs(v) * 1e-6
forms = [(v, tol, "eq")]
tf = fam(unit)
if tf in (None, "율"):
forms += [(v * 100, tol * 100, "pct"), (v / 100, tol / 100, "pct")]
if tf == "무게":
forms += [(v * 1000, tol * 1000, "kgt"), (v / 1000, tol / 1000, "kgt")]
out = []
for x, t, how in forms:
t = max(t, abs(x) * 1e-9)
for j in range(
bisect.bisect_left(self.vals, x - t), bisect.bisect_right(self.vals, x + t)
):
cid = self.ids[j]
c = self.cells[cid]
if c["종류"] == "cond":
continue # 조건 칸(관경 · SCH)은 옵션 값 — 상수 설명에 안 씀
if k.get("직종") and not self._trade_ok(k["직종"], c):
continue
if c["종류"] == "ratio" and how != "eq":
continue
if how == "pct" and not (tf == "율" or "%" in str(c["단위"])):
continue
if how == "kgt" and not (tf == "무게" or fam(c["단위"]) == "무게"):
continue
if (
how == "eq"
and abs(c["v"] - v) > 1e-9
and c["종류"] != "ratio"
and tol < 1e-6
and not k.get("몫")
):
continue
if not unit_ok(unit, c["단위"]) and how == "eq":
continue
out.append((cid, how))
return out
def hits_of(self, cid: int) -> list[tuple]:
"""칸 → [(로직, 부름id, 줄, 묶음)](캐시)."""
got = self._hits.get(cid)
if got is None:
got = self._hits[cid] = self._hits_of(cid)
return got
def _hits_of(self, cid: int) -> list[tuple]:
c = self.cells[cid]
out = []
if c["종류"] == "기준":
return [(u[0], f"{u[1]}|기준", 0, {}) for u in self.uses.get(c["표"], [])]
if c["종류"] == "eq":
for item, callid, (kind, val) in self.eq_uses.get(c["칸"], []):
if kind == "고정" and val != c["표"]:
continue
out.append(
(
item,
callid,
c["표"],
{val: json.dumps(c["표"])} if kind == "입력" and val else {},
)
)
return out
row = self.tables[c["표"]]["줄"][c["줄"]]
cols = c["칸"].split("/") if c["종류"] == "ratio" else [c["칸"]]
for item, callid, field, filt, bind in self.uses.get(c["표"], []):
if field is not None and field not in cols and c["종류"] != "cond":
continue
if not self._pass(row, filt):
continue
b = {}
for col, name in bind.items():
if col == "#row":
b[name] = json.dumps(c["줄"])
elif row.get(col) is not None:
b[name] = json.dumps(row[col], ensure_ascii=False)
out.append((item, callid, c["줄"], b))
return out
def explain(self, k: dict) -> list[tuple]:
"""상수 하나 → [(로직, 부름id | None, 줄, 묶음, 꼴, 닻)] ·
닻 = 표 칸 · 직종 식 수로 맞음."""
out = []
for cid, how in self.lookup(k):
if self.cells[cid]["종류"] == "eq":
continue # 기계 요소 값(상각비계수 · 잡재료율 …)은 산근 글에 거의 안 나옴
out += [(item, call, row, bd, how, True) for item, call, row, bd in self.hits_of(cid)]
x = round(k["v"], 9)
if k.get("직종"):
for item in self.trade_index.get(x, ()):
if any(
same_trade(k["직종"], n) for n, y in self.inline_trade[item] if round(y, 9) == x
):
out.append((item, None, None, {}, "식", True))
else:
forms = (
(x, round(k["v"] * 100, 9), round(k["v"] / 100, 9))
if fam(k["단위"]) in (None, "율")
else (x,)
)
for form in forms:
for item in self.inl_index.get(form, ()):
if len(self.inline[item]) <= INLINE_MAX:
out.append((item, None, None, {}, "식", False))
return out
# ── 우연 보정 ────────────────────────────────────────────────────
def df(self, k: dict) -> int:
key = kkey(k)
if key not in self._df:
self._df[key] = max(1, len({x[0] for x in self.explain(k)}))
return self._df[key]
def prior(self) -> None:
"""보정 자료 상수마다 설명할 수 있는 로직 → 로직 몫 p · 평균 df."""
keys = [
dict(zip(("v", "d", "단위", "직종", "몫"), row))
for row in json.loads(CORPUS.read_text(encoding="utf-8"))
]
cnt: dict = defaultdict(int)
for k in keys:
for it in {x[0] for x in self.explain(k)}:
cnt[it] += 1
self.prom = {it: (cnt[it] + 1) / (len(keys) + 2) for it in self.logic}
self.mean_df = sum(self.df(k) for k in keys) / max(len(keys), 1)
def q_of(self, item: str, k: dict) -> float:
"""로직 item 이 상수 k 를 우연히 설명할 확률 ≈ df_k · p_item / 평균 df."""
p = self.prom.get(item, 1 / max(len(self.prom), 1))
return min(0.95, max(1e-6, self.df(k) * p / self.mean_df))
def kkey(k: dict) -> tuple:
return (k["v"], k["d"], k["단위"], k.get("직종"), bool(k.get("몫")))
def pb_sig(qs: list[float], c: int) -> float:
"""포아송 이항 꼬리 P(X ≥ c) 의 -log10."""
if c <= 0:
return 0.0
dp = [1.0] + [0.0] * len(qs)
for q in qs:
for j in range(len(dp) - 1, 0, -1):
dp[j] = dp[j] * (1 - q) + dp[j - 1] * q
dp[0] *= 1 - q
return round(-math.log10(max(sum(dp[c:]), 1e-300)), 2)
_LOCK = threading.Lock()
_CACHE: dict[str, Index] = {}
def get() -> Index:
"""M01 지문마다 한 번 지은 역색인(프로세스 안 · 지문 바뀌면 다시)."""
key = stamp()
with _LOCK:
got = _CACHE.get(key)
if got is None:
trade.cache_clear()
_CACHE.clear()
got = _CACHE[key] = Index()
return got
def warm() -> None:
"""서버 시작 때 한 번(뒤에서) — 실패 = 다음 부름에서 다시."""
try:
get()
except Exception: # noqa: BLE001
pass
+134
View File
@@ -0,0 +1,134 @@
"""근거 제안 길 · 저장본(PLAN 793-3a) — 서버 단독 · 읽기만(HP 는 안 고침).
올림 = HP 에 `숨은식 {번: [G 식]}`(STmate 단가산출근거 숨은 G 열 · `hidden`) + 뒤 셈(`ensure`).
저장본 = HP 폴더 `_derived/san_basis_{키}` · 입력 지문 = HP(지문 · 판 · 올린때) + M01 역색인 지문
+ 판 ·
지문 다를 때만 다시 셈 · GET 은 안 지움 · [끝내기] = `forget`.
답 꼴 = `listing` {셈중, 블록 {번: 제안}, 보강 [{이름, 품셈, 까닭, 블록 [번]}]} · `block` = 제안
하나
(`M02_San_Basis.suggest` 꼴 — 단 · 꼴 · 근거후보 · 절 · 보강?).
"""
from __future__ import annotations
import shutil
import threading
from pathlib import Path
from typing import Any
from common_util import common_util_derived_store as derived
from M02_MasterTemplete import M02_Hopyo_Judge as judge
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_San_Basis as basis
from M02_MasterTemplete import M02_San_Basis_Index as bi
KIND = "san_basis_"
VERSION = "793-3a.1" # 제안 꼴이 바뀌면 올림
SHEET = "단가산출근거"
_LOCK, _RUNNING = threading.Lock(), set()
def hidden(book: dict[str, Any], sheets: dict[str, list[list[Any]]]) -> dict[str, list[str]]:
"""STmate 블록마다 숨은 G 열 원문 식(「_x0007_」 머리 줄만) · 그 밖 = {}."""
rows = sheets.get(SHEET) or []
if book.get("꼴") != "STmate" or not rows:
return {}
out = {}
for blk in book.get("단산") or []:
start, end = blk["행"]
got = []
for r in range(start, end + 1):
row = rows[r - 1] if 0 < r <= len(rows) else []
v = str(row[6]) if len(row) > 6 and row[6] is not None else ""
if v.startswith("_x0007_") and v != "_x0007_":
got.append(v.replace("_x0007_", "").strip())
out[str(blk["번"])] = got
return out
def one(book: dict[str, Any], no: int) -> dict[str, Any]:
blk = next(b for b in book.get("단산") or [] if int(b["번"]) == int(no))
line, same = judge._caller(book, int(no))
g = (book.get("숨은식") or {}).get(str(no))
return basis.suggest(blk, book.get("꼴") or "", line, same, g)
def _stamp(key: str, book: dict[str, Any]) -> str:
return derived.derived_key(
VERSION, key, book.get("지문"), book.get("판"), book.get("올린때"), bi.stamp()
)
def solved(folder: Path, key: str) -> dict[str, Any] | None:
book = hopyo_store.read(folder, key)
got = derived.read_derived(hopyo_store._dir(folder), KIND + key, _stamp(key, book))
return got["value"] if got else None
def solve_all(folder: Path, key: str) -> dict[str, Any]:
book = hopyo_store.read(folder, key)
out = {
str(b["번"]): one(book, int(b["번"]))
for b in book.get("단산") or []
if b.get("번") is not None
}
derived.write_derived(hopyo_store._dir(folder), KIND + key, _stamp(key, book), out, keep=1)
return out
def _work(folder: Path, key: str) -> None:
try:
solve_all(folder, key)
except Exception: # noqa: BLE001 — 뒤 셈 실패 = 다음 읽기에서 다시(파생 저장본)
pass
finally:
with _LOCK:
_RUNNING.discard(f"{folder}|{key}")
def ensure(folder: Path, key: str) -> bool:
"""저장본 있음 = 참 · 없으면 뒤 셈 시작(같은 파일 한 번) = 거짓."""
if solved(folder, key) is not None:
return True
tag = f"{folder}|{key}"
with _LOCK:
if tag in _RUNNING:
return False
_RUNNING.add(tag)
threading.Thread(target=_work, args=(Path(folder), key), daemon=True).start()
return False
def shortfall(got: dict[str, Any]) -> list[dict[str, Any]]:
"""없음 블록 → M01 보강 목록(794-2 근거) — 이름 · 품셈 묶음마다 블록 번."""
out: dict[tuple, dict] = {}
for no, r in got.items():
if r.get("보강"):
b = r["보강"]
one_ = out.setdefault((b["이름"], tuple(b["품셈"])), {**b, "블록": []})
one_["블록"].append(int(no))
return sorted(out.values(), key=lambda x: (-len(x["블록"]), x["이름"]))
def listing(folder: Path, key: str) -> dict[str, Any]:
"""{셈중, 블록 {번: 제안}, 보강} — 셈 중 = 블록 {} · 뒤 셈 시작."""
got = solved(folder, key)
if got is None:
ensure(folder, key)
return {"키": key, "셈중": True, "블록": {}, "보강": []}
return {"키": key, "셈중": False, "블록": got, "보강": shortfall(got)}
def block(folder: Path, key: str, no: int) -> dict[str, Any]:
"""블록 하나 — 저장본 › 그 자리 셈."""
got = (solved(folder, key) or {}).get(str(no))
if got is not None:
return got
book = hopyo_store.read(folder, key)
if not any(b.get("번") is not None and int(b["번"]) == int(no) for b in book.get("단산") or []):
raise judge._bad(f"없는 블록 「단산{no}」", 404)
return one(book, no)
def forget(folder: Path, key: str) -> None:
shutil.rmtree(derived.derived_dir(hopyo_store._dir(folder), KIND + key), ignore_errors=True)
+307
View File
@@ -0,0 +1,307 @@
"""근거 제안 3 — 값 길: 상수 지문 → M01 로직 후보(PLAN 793-3a · 분석 648-D 1-3) · 읽기만.
로직마다 입력 값 조합(400 넘으면 입력마다 혼자 가장 많이 덮는 값) → 찾기마다 줄 하나(새로 덮는
상수가
가장 많은 줄부터) → 덮은 상수 · 우연 보정 유의(−log10 꼬리 확률) · 판정 U 유일 · 다 설명 · 옵션
하나 /
P 유일 / A 동점(유의 1 안) / N 유의 4 미만 / N0 상수 없음 · 동점이 모두 한 하위 로직을 부르면 그
하위(핵).
블록 = 일 절 2+ · 상수 2+ 주 절이 있으면 주 절 판정 모음 + 절 밖 상수 · 그 밖 블록 통째 + 덧 기초.
"""
from __future__ import annotations
import itertools
import json
import math
from collections import defaultdict
from typing import Any
from M02_MasterTemplete import M02_San_Basis_Const as bc
from M02_MasterTemplete import M02_San_Basis_Index as bi
TIE = 1.0 # 동점 폭(유의)
SIG_MIN = 4.0 # 1등 유의가 이 아래면 N — 로직 1,515 중 우연 1등 ≈ 10^-3.2
fam = bi.fam
def solve(hits: list[tuple]) -> dict:
"""hits = [(로직, 부름id, 줄, 묶음)] · 식 속 수 = 부름id None → 덮음 · 옵션 · 옵션여럿."""
inl = {h[0] for h in hits if h[1] is None}
bound = [h for h in hits if h[1] is not None]
inputs = sorted({i for h in bound for i in h[3] if not i.startswith("§")})
vals = {i: sorted({h[3][i] for h in bound if i in h[3]}) for i in inputs}
by_call: dict = defaultdict(lambda: defaultdict(set))
row_bind = {}
for k, call, row, b in bound:
by_call[call][row].add(k)
row_bind[(call, row)] = b
order = sorted(by_call, key=lambda c: -max(len(x) for x in by_call[c].values()))
def run(combo: dict):
cov, fixed, pick = set(inl), dict(combo), {}
while True:
best = None
for call in order:
if call in pick:
continue
for r, ks in by_call[call].items():
gain = len(ks - cov)
if (
gain
and (best is None or gain > best[0])
and all(
fixed.get(i, v) == v if i.startswith("§") else combo.get(i) == v
for i, v in row_bind[(call, r)].items()
)
):
best = (gain, call, r, ks)
if best is None:
break
_, call, r, ks = best
cov |= ks
pick[call] = r
fixed |= {i: v for i, v in row_bind[(call, r)].items() if i.startswith("§")}
return cov, pick, -len(pick)
if (math.prod(len(v) for v in vals.values()) if vals else 1) <= 400:
combos = [dict(zip(inputs, c)) for c in itertools.product(*[vals[i] for i in inputs])] or [
{}
]
else: # ponytail: 입력 값 조합이 많으면 입력마다 혼자 가장 많이 덮는 값 — 상호작용 놓칠 수 있음
cnt = defaultdict(set)
for h in bound:
for i, v in h[3].items():
cnt[(i, v)].add(h[0])
combos = [{i: max(vals[i], key=lambda v: len(cnt[(i, v)])) for i in inputs}]
best, outs = (-1, -1), []
for combo in combos:
cov, pick, ev = run(combo)
score = (len(cov), ev)
if score > best:
best, outs = score, [(combo, cov, pick)]
elif score == best:
outs.append((combo, cov, pick))
used = {
i
for _, _, pick in outs
for call, r in pick.items()
for i in row_bind[(call, r)]
if not i.startswith("§")
}
resolved, opt_amb = {}, {}
for i in sorted(used):
seen = sorted({o[0].get(i) for o in outs if o[0].get(i) is not None})
if len(seen) == 1:
resolved[i] = json.loads(seen[0])
elif seen:
opt_amb[i] = [json.loads(x) for x in seen]
same_cov = len({frozenset(o[1]) for o in outs}) == 1
return {"덮음": outs[0][1], "옵션": resolved, "옵션여럿": opt_amb, "조합같은덮음": same_cov}
def rank(idx: bi.Index, K: list[dict]) -> list[dict]:
"""상수 목록 → 로직 후보(유의 차례)."""
per_item, anchored, how_of = defaultdict(list), set(), defaultdict(dict)
for ki, k in enumerate(K):
for item, call, row, bd, how, anc in idx.explain(k):
per_item[item].append((ki, call, row, bd))
how_of[item].setdefault(ki, how)
if anc:
anchored.add(item)
real = sorted(i for i, k in enumerate(K) if not k.get("일반"))
ub = []
for it, hs in per_item.items():
if it in anchored:
c = len({h[0] for h in hs} & set(real))
ub.append((bi.pb_sig([idx.q_of(it, K[i]) for i in real], c), c, it))
ub.sort(key=lambda x: (-x[0], -x[1], x[2]))
out, best = [], 0.0
for s_ub, c, item in ub:
if c == 0 or s_ub < best - TIE or len(out) >= 200:
break
got = solve(per_item[item])
got_real = got["덮음"] & set(real)
s_val = bi.pb_sig([idx.q_of(item, K[i]) for i in real], len(got_real))
best = max(best, s_val)
out.append({"로직": item, **got, "꼴": how_of[item], "실수": len(got_real), "유의": s_val})
out.sort(key=lambda o: (-o["유의"], -o["실수"], o["로직"]))
return out
def collapse(idx: bi.Index, tied: list[dict]) -> tuple[list[dict], str]:
"""동점 정리 — 다른 동점이 모두 그 로직을 부르면 그 하위(핵) · 위 로직 하나면 그 위 로직 ·
그 밖 = 동점 위 로직이 덮는 하위는 뺌."""
for c in tied:
others = [o for o in tied if o is not c]
if others and all(o["로직"] in idx.callers.get(c["로직"], set()) for o in others):
return (
(others, "위 로직 하나")
if len(others) == 1
else ([c], f"핵 하위 로직(위 로직 {len(others)})")
)
keep = []
for o in tied:
if not [
p
for p in tied
if p is not o
and p["로직"] in idx.callers.get(o["로직"], set())
and o["덮음"] <= p["덮음"]
]:
keep.append(o)
return keep, ""
def judge(idx: bi.Index, K: list[dict]) -> dict:
"""한 단위(절 · 블록) 판정 — U · P · A · N · N0."""
real = {i for i, k in enumerate(K) if not k.get("일반")}
if not real:
return {"판정": "N0", "까닭": "지문 없음(상수 0)", "후보": [], "K": K}
cands = [o for o in rank(idx, K) if o["실수"]]
if not cands:
return {"판정": "N", "까닭": "맞는 표 없음", "후보": [], "K": K}
top = cands[0]
if top["유의"] < SIG_MIN:
return {
"판정": "N",
"까닭": f"유의 못 미침(1등 {top['실수']}/{len(real)} · 유의 {top['유의']})",
"후보": cands[:5],
"K": K,
}
tied, note = collapse(idx, [o for o in cands if o["유의"] >= top["유의"] - TIE])
if len(tied) >= 2:
return {
"판정": "A",
"까닭": f"동점 {len(tied)}",
"후보": tied[:12],
"n": len(tied),
"K": K,
"남음": sorted(real - top["덮음"]),
}
top = tied[0]
left = sorted(real - top["덮음"])
full = not left and not top["옵션여럿"] and top["조합같은덮음"]
rest = [o for o in cands if o is not top]
return {
"판정": "U" if full else "P",
"까닭": note,
"후보": [top] + rest[:4],
"K": K,
"남음": left,
}
def cover(idx: bi.Index, K: list[dict], first: dict) -> list[dict]:
"""블록 덮기 — 첫 기초 뒤 남은 상수로 다음 기초(새 상수 2+ · 유일 · 넷까지)."""
picks, done = [first], set(first["덮음"])
for _ in range(3):
rest = [k if i not in done else {**k, "일반": True} for i, k in enumerate(K)]
if all(k.get("일반") for k in rest):
break
got = judge(idx, rest)
if got["판정"] not in ("U", "P"):
break
o = got["후보"][0]
if len(o["덮음"] - done) < 2:
break
picks.append({**o, "동점": 1})
done |= o["덮음"]
return picks
def assemble(idx: bi.Index, bK: list[dict], mains: list) -> dict:
"""절 2+ 블록 — 주 절 판정 모음 + 절 밖 상수(머리 · 비목 절)는 고른 기초가 덮는지 ·
남으면 따로."""
picks, verdicts, covered = [], [], set()
def key(c):
return (round(c["v"], 9), fam(c["단위"]), c.get("직종"))
for _, _, K, sj in mains:
verdicts.append(sj["판정"])
if sj["판정"] in ("U", "P"):
o = sj["후보"][0]
mine = {key(K[j]) for j in o["덮음"]}
picks.append({**o, "덮음": {i for i, c in enumerate(bK) if key(c) in mine}})
covered |= {key(c) for c in K}
rest = [c if key(c) not in covered else {**c, "일반": True} for c in bK]
left = {i for i, c in enumerate(rest) if not c.get("일반")}
if left and picks:
chosen = {p["로직"] for p in picks}
for o in rank(idx, rest):
if o["로직"] not in chosen:
continue
got = o["덮음"] & left
for p in picks:
if p["로직"] != o["로직"]:
continue
p["덮음"] = p["덮음"] | got
if got: # 절 밖 상수가 정한 옵션도 얹음(같은 입력 다른 값 = 여럿)
for kk, vv in o["옵션"].items():
if kk in p["옵션"] and p["옵션"][kk] != vv:
p["옵션여럿"] = {**p["옵션여럿"], kk: [p["옵션"][kk], vv]}
else:
p["옵션"] = {**p["옵션"], kk: vv}
left -= got
if len(left) >= 2:
extra = judge(idx, [c if i in left else {**c, "일반": True} for i, c in enumerate(bK)])
if extra["판정"] in ("U", "P"):
picks.append(extra["후보"][0])
left -= extra["후보"][0]["덮음"]
verdicts.append(extra["판정"])
if not picks:
out = {
"판정": "A" if "A" in verdicts else ("N" if "N" in verdicts else "N0"),
"후보": [],
"K": bK,
}
a = next((x[3] for x in mains if x[3]["판정"] == "A"), None)
return out | ({"후보": a["후보"], "n": a["n"], "K": a["K"]} if a else {})
allu = not left and all(not p["옵션여럿"] for p in picks)
return {
"판정": "U" if allu else "P",
"후보": picks,
"K": bK,
"기초목록": picks,
"남음": sorted(left),
}
def _show(o: dict) -> dict:
return {"로직": o["로직"], "옵션": o["옵션"], "옵션여럿": o["옵션여럿"]}
def value(idx: bi.Index, pr: dict) -> dict:
"""블록 준비(`Const.prep`) → 값 판정 {판정 U|P|A|N|N0, 기초 · 동점 · 약한후보 [{로직, 옵션}],
절 [{절, 판정, 기초?, 동점?}], K, 입력}."""
lines, per_line, secs = pr["lines"], pr["per_line"], pr["secs"]
bK, bIns = bc.gather(per_line, range(len(lines)))
sec_res = []
for title, ids in secs:
K, _ = bc.gather(per_line, ids)
sec_res.append((title, ids, K, judge(idx, K)))
mains = [x for x in sec_res if len([k for k in x[2] if not k.get("일반")]) >= 2]
multi = len(secs) >= 2 and len(mains) >= 1
bj = assemble(idx, bK, mains) if multi else judge(idx, bK)
rec: dict[str, Any] = {
"판정": bj["판정"],
"K": [{"단위": c["단위"]} for c in bK],
"입력": [c["v"] for c in bIns],
}
if bj["판정"] in ("U", "P"):
rec["기초"] = [_show(p) for p in (bj.get("기초목록") or cover(idx, bK, bj["후보"][0]))]
elif bj["판정"] == "A":
rec["동점"] = [_show(o) for o in bj["후보"]]
rec["n"] = bj["n"]
elif bj["판정"] == "N" and bj.get("후보"):
rec["약한후보"] = [_show(o) for o in bj["후보"][:3]]
rec["절"] = []
for title, _, _, sj in sec_res:
one = {"절": title, "판정": sj["판정"]}
if sj["판정"] in ("U", "P"):
one["기초"] = _show(sj["후보"][0])
elif sj["판정"] == "A":
one["동점"] = [o["로직"] for o in sj["후보"]]
rec["절"].append(one)
return rec
File diff suppressed because one or more lines are too long
+4
View File
@@ -13,6 +13,7 @@ import asyncio
import logging
import os
import time
import threading
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
@@ -97,6 +98,7 @@ from E03_StabSection.E03_StabSection_Router_Prob import router as e03_stab_prob_
from E06_StabRock.E06_StabRock_Router import router as e06_stab_rock_router
from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router
from M02_MasterTemplete.M02_Hopyo_Router import router as m02_hopyo_router
from M02_MasterTemplete import M02_San_Basis_Index as san_basis_index
from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router
from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_master_template_router
from M02_MasterTemplete.M02_MasterTemplete_Router_Combos import router as m02_combos_router
@@ -281,6 +283,8 @@ async def lifespan(app: FastAPI):
cleanup_task = asyncio.create_task(cleanup_expired_sessions())
resource_task = asyncio.create_task(sample_resources_loop())
temp_cleanup_task = asyncio.create_task(cleanup_expired_temp_uploads_loop())
# 793-3a 근거 제안 M01 값 역색인 — 뒤에서 한 번 지어 둠(M01 지문 바뀌면 다음 부름에서 다시)
threading.Thread(target=san_basis_index.warm, daemon=True).start()
yield
+183
View File
@@ -0,0 +1,183 @@
"""793-3a 지킴 — 근거 제안 엔진(`M02_San_Basis*`) · 실무 여덟 파일 산근 459 블록.
판정 수 ≥ 분석 648-D2 합친 길(기초 짚음 260 · 옵션 전부 52 · 품 검산 맞음 141 · 없음 94) ·
손 확인 견본(합판거푸집 넷 · 철근 Type-Ⅱ 가공 + 조립 · 레미콘 소량감률 50 · 터파기 #51 ·
야면석소운반 GC001140 석재류 · 불량 · 큰돌쌓기 ÷10 근접) · 지어 넣은 값 0 · 보정 자료 = 여덟 파일 ·
저장본 길(임시 폴더). 엑셀 = `helper_m02_hopyo_golden.FILES`(읽기만 · 없으면 skip) ·
STmate 숨은 G 식 = 올림 길과 같은 `M02_San_Basis_Store.hidden`.
"""
from __future__ import annotations
import json
import time
from collections import Counter
from functools import lru_cache
import helper_m02_hopyo_golden as golden
import pytest
from M02_MasterTemplete import M02_Hopyo_Judge as judge
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_San_Basis as sb
from M02_MasterTemplete import M02_San_Basis_Const as bc
from M02_MasterTemplete import M02_San_Basis_Index as bi
from M02_MasterTemplete import M02_San_Basis_Store as store
from M02_MasterTemplete import M02_San_Book as san_book
WAYS = {
"B1",
"B2",
"B3",
"B4",
"B5",
"B6",
"V",
"검산",
"검산(같은 품 여럿)",
"근접",
"근접(같은 품 여럿)",
}
@lru_cache(maxsize=1)
def books() -> list[tuple]:
"""[(파일, HP 꼴 책 + 숨은식)] — 없는 파일이면 skip."""
out = []
for tag, path in golden.FILES.items():
book = golden.read(tag) if path.exists() else None
if book is None:
pytest.skip(f"실무 엑셀 없음 {tag}")
sheets = san_book.sheets(path.name, path.read_bytes()) if book["꼴"] == "STmate" else {}
out.append((tag, {**book, "숨은식": store.hidden(book, sheets)}))
return out
@lru_cache(maxsize=1)
def results() -> tuple[dict, float]:
sb.ctx()
t0 = time.time()
got = {}
for tag, book in books():
for blk in book["단산"]:
got[f"{tag}#{blk['번']}"] = store.one(book, int(blk["번"]))
return got, time.time() - t0
def test_counts_at_least_d2():
got, sec = results()
kind = Counter(r["꼴"] for r in got.values())
tier = Counter(r["단"] for r in got.values())
same = sum(
1
for r in got.values()
if r["꼴"].startswith("확정")
and "다름" not in (st := [sb.checked(s) for s in sb.chosen_units(r)])
and "같음" in st
)
print(f"\n블록 {len(got)} · 꼴 {dict(kind)} · 단 {dict(tier)} · 품 같음 {same} · {sec:.1f} 초")
assert len(got) == 459
assert sum(v for k, v in kind.items() if k.startswith("확정")) >= 260
assert kind["확정·전부"] >= 52
assert same >= 141
assert tier["없음"] <= 94 and kind["없음"] <= 94
def _first(bid: str) -> dict:
return results()[0][bid]["근거후보"]
def test_plywood_four_same():
for bid in ("북부청임도#24", "북부청사방#66", "거창#41", "동부청영월#1841"):
(c,) = _first(bid)
assert (c["기초"], c["단"], c["품검산"]) == ("GC000211", "확정", "같음"), bid
assert {c["옵션"][k] for k in ("사용횟수", "유형")} == {"4회", "보통"}, bid
assert {"높이감률", "구멍땜"} <= set(c["입력필요"]), bid # 글에 없는 감률 = 입력 필요
def test_rebar_type2_cut_and_join():
got = {c["기초"]: c for c in _first("북부청임도#72")}
assert set(got) == {"GC000206", "GC000207"}
assert got["GC000206"]["옵션"]["Type"] == got["GC000207"]["옵션"]["Type"] == "Type-Ⅱ"
assert all(c["단"] == "확정" for c in got.values())
def test_remicon_small_50():
(c,) = _first("북부청임도#21")
assert c["기초"] == "GC000198" and c["단"] == "확정"
assert c["옵션"]["소량감률"] == 50 and c["옵션길"]["소량감률"] == "B5"
def test_excavation_51():
(c,) = _first("북부청임도#51")
assert (c["기초"], c["단"], c["옵션"]["Type"], c["옵션"]["깊이"]) == (
"GC000089",
"확정",
"TypeⅢ",
"5m 이하",
)
def test_rubble_carry_gc001140():
r = results()[0]["동부청영월#1466"]
c = r["근거후보"][0]
assert r["단"] == "사람" and c["기초"] == "GC001140"
assert c["옵션"] == {"재료갈래": "석재류", "운반로": "불량"}
assert set(c["옵션길"].values()) == {"V"}
def test_big_stone_near_unit_basis():
r = results()[0]["북부청임도#32"]
(c,) = r["근거후보"]
assert (r["단"], c["기초"], c["단"], c["품검산"]) == ("근접", "GF000226", "근접", "다름")
assert {t for t, _ in c["까닭"]} == {"단위 기준 차이"} and "÷10" in c["까닭"][0][1]
def test_no_made_up_values():
"""옵션마다 출처 꼬리표 · 입력 필요 · 0 간주 칸은 옵션에 없음 ·
없음 단은 근거후보 없이 보강 까닭만."""
for bid, r in results()[0].items():
for c in r["근거후보"]:
assert set(c["옵션"]) <= set(c.get("옵션길") or {}), bid
assert set((c.get("옵션길") or {}).values()) <= WAYS, bid
assert not set(c["옵션"]) & set(c["입력필요"]), bid
assert all(v is not None for v in c["옵션"].values()), bid
assert c["단"] in ("확정", "근접", "사람")
if r["단"] == "없음":
assert r["근거후보"] == [] and r["보강"]["까닭"], bid
def test_corpus_is_eight_files():
"""보정 자료 = 여덟 파일 상수(일반 뺌) — 뽑기 규칙을 바꾸면 다시 지어 넣음."""
preps = [
bc.prep(blk, book["꼴"], (book["숨은식"] or {}).get(str(blk["번"])))
for _, book in books()
for blk in book["단산"]
]
shipped = json.loads(bi.CORPUS.read_text(encoding="utf-8"))
assert sorted(map(json.dumps, bc.corpus(preps))) == sorted(map(json.dumps, shipped))
def test_store_round_trip(tmp_path):
"""임시 폴더 HP → 뒤 셈 저장본 · 목록 · 블록 하나 · 끝내기."""
_, book = books()[0]
small = {**book, "단산": [b for b in book["단산"] if b["번"] in (21, 51)]}
row = hopyo_store.save(tmp_path, "시험.xls", small, {"id": 0, "이름": "시험"})
key = row["키"]
assert store.solved(tmp_path, key) is None
got = store.solve_all(tmp_path, key)
assert set(got) == {"21", "51"} and store.solved(tmp_path, key) == json.loads(json.dumps(got))
assert store.listing(tmp_path, key)["셈중"] is False
assert store.block(tmp_path, key, 51)["근거후보"][0]["기초"] == "GC000089"
with pytest.raises(Exception):
store.block(tmp_path, key, 999)
store.forget(tmp_path, key)
assert store.solved(tmp_path, key) is None
judge.forget(tmp_path, key)
def test_hidden_g_lines():
book = {"꼴": "STmate", "단산": [{"번": "3", "행": [2, 4]}]}
rows = [[None] * 7, [None] * 6 + ["_x0007_L=20"], [None] * 6 + ["_x0007_"], [None] * 6 + ["x"]]
assert store.hidden(book, {store.SHEET: rows}) == {"3": ["L=20"]}
assert store.hidden({"꼴": "엑셀꼴", "단산": []}, {}) == {}