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:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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({"꼴": "엑셀꼴", "단산": []}, {}) == {}
|
||||
Reference in New Issue
Block a user