"""가져온 식 ↔ 우리 M01 기초 연결 찾기(PLAN 810-4 · 요구 707 · 709 · 703) — 서버 단독. 가져온 식 문서(원파일 확정 정보 · 우리 단가로 셈) 하나를 M01 수식 문서 전부와 맞대어 「확정」 · 「근접」 · 「없음」 을 냄 — 지어 맞추기 없음(값 보정 · 허용 오차 없음 · 원 단위 같아야 확정). ① 후보 = 결과단위 같음 + 노임 키 ⊂ M01 노임 키 + 재료 · 기계 있으면 M01 에도 · 품셈 절 번호(가져온 「추정」 글 · M01 `출처`)가 겹치는 문서가 있으면 그것만. ② 옵션 풀이 = 그 문서의 선택 · 입력 변수 값을 줄마다 좁힘(줄 수량 · 단가가 의존하는 변수만 돌림 · 값이 가져온 줄 수량 · 단가 묶음에 있거나 0(꺼진 줄)인 값만 남김) → 남은 조합(상한 `CAP_ALL`)을 문서 셈 · 입력 변수 후보 = 기본값 · 0 · 가져온 식에 나온 수 · 선택은 가져온 글에 든 값부터. ③ 판정 = 줄 대조 = 로직(812-2 · 요구 712 — `M02_Doc_Match_Note`): 다른 항 = 자원 · 기계 규격 · 수량 · 비목 · 율 구조(대상 · 비목) · 줄 없음 · 이름 · 참고 = 단가 · 끝수 · 율 값 %(값 — 확정을 안 막음) — 다른 항 0 = 「확정」 · 같은 줄이 하나 넘고 핵심 줄 반 넘게 짝 = 「근접」(다른 항 목록) · 그 밖 「없음」. 결과 `{판정, 키, 이름, 옵션, 다른, 참고, 금액 {가져온, 우리}, 후보수}` = 가져온 식 원본 `연결후보`. """ from __future__ import annotations import functools import itertools import json import re from decimal import Decimal, InvalidOperation from typing import Any from M02_MasterTemplete import M02_Doc_Copy as dc from M02_MasterTemplete import M02_Doc_Match_Note as note md, mc, ml = dc.md, dc.mc, dc.ml if not hasattr( md.parse, "cache_info" ): # 식 글 → 나무 = 순수 함수 · 조합마다 같은 글을 다시 풀지 않음 md.parse = functools.lru_cache(maxsize=50000)(md.parse) KEY = "연결후보" SURE, NEAR, NONE = "확정", "근접", "없음" CAP_LINE = 3000 # 줄 하나 좁힐 때 돌리는 조합 상한(넘으면 그 줄은 좁힘에 안 씀) CAP_ALL = 192 # 문서 하나 끝 셈 조합 상한 CAP_INPUT = 12 # 입력 변수 후보 수 상한 NICE = (2, 3, 4, 5, 6, 8, 10, 12, 100, 1000) # 단위 환산으로 보는 배 _SEC = re.compile(r"(? Any: """비교 열쇠 — 수 = 유효 12 자리(Decimal 셈 자리 차이만 지움) · 묶음 = 비목 튜플.""" if isinstance(v, dict): return tuple(_key(v.get(k)) for k in md.COSTS) if isinstance(v, Decimal): try: return v.normalize() if v == 0 else Decimal(f"{v:.12g}").normalize() except InvalidOperation: return None return v def unit_of(text: Any) -> str: """결과단위 「원/㎡」 → 「㎡」(소문자 · m3 → ㎥ 등) · 돈 아닌 단위 = "".""" text = str(text or "").strip() if not text.startswith("원/"): return "" unit = text[2:].strip().lower().replace(" ", "") return _UNIT.get(unit, unit) def sections(texts: list[str]) -> set[str]: """품셈 절 번호(「6-3-1」) — 앞 둘 · 셋 마디 모두.""" out = set() for t in texts: for s in _SEC.findall(str(t or "")): parts = s.split("-") out |= {"-".join(parts[:n]) for n in range(2, len(parts) + 1)} return out # ── 줄 사실 ─────────────────────────────────────────────────────────── def _single(text: Any) -> str | None: try: node = md.parse(text) except md.DocError: return None return node[1] if node[0] == "var" else None def _what(doc: dict, line: dict) -> tuple[str, str]: """금액 줄 자원 → (갈래, 코드) · 갈래 = 노임 · 재료 · 기계 · 산출 · 율 · 수.""" sym = _single(line.get("단가")) var = next((v for v in doc.get("변수") or [] if sym and md.var_id(v) == sym), None) if var is None: return "율", "" link = var.get("연결") or {} kind, key = link.get("갈래"), str(link.get("키") or "") if kind == "요소": if key.startswith("LB"): return "노임", key if key.startswith("EQ"): return "기계", key return "재료", key if kind == "단가산출": given = link.get("입력") or {} code = str(given.get("기계") or "").strip("'") return ("기계", code) if "기계" in given else ("산출", key) if kind == "숫자" and isinstance(link.get("값"), dict): return "산출", "" if kind == "식": return "율", "" return "수", "" def facts(doc: dict, got: dict) -> list[dict]: """셈 답 줄 → 대조 사실 — 소계 보임(비목 없는 합 밖) · 꺼진 줄(수량 0 · 금액 0) 뺌.""" out = [] money = doc.get("금액") or [] for i, (line, row) in enumerate(zip(money, got.get("줄") or [])): if line.get("합밖") and not line.get("비목"): continue if row.get("금액") is not None and row["금액"] == 0 and row.get("수량") == 0: continue kind, code = _what(doc, line) if kind == "기계" and code in (got.get("값") or {}): # 옵션 기호 = 그 값(기계 코드) code = mc.plain_text(got["값"][code]) if kind == "율" or (kind == "수" and line.get("수량") in (None, "")): kind = "율" out.append( { "번": i, "이름": str(line.get("이름") or ""), "갈래": kind, "코드": code, "수량": _key(row.get("수량")) if kind != "율" else None, "단가": _key(row.get("단가")) if kind != "율" else None, "비목": {k: _key(v) for k, v in (row.get("비목") or {}).items() if v}, "금액": _key(row.get("금액")), "합밖": bool(row.get("합밖")), } ) note.rates(doc, got, out) return out def resources(doc: dict) -> dict[str, set]: """문서가 쓸 수 있는 자원(옵션 무관) — {노임 키, 재료, 기계}.""" out: dict[str, set] = {"노임": set(), "재료": set(), "기계": set()} for line in doc.get("금액") or []: kind, code = _what(doc, line) if kind == "노임": out["노임"].add(code) elif kind == "재료": out["재료"].add(code or "고름") elif kind in ("기계", "산출"): out["기계"].add(code or kind) return out # ── 연결 기억 ───────────────────────────────────────────────────────── class Memo: """M01 연결 + 셈 기억(요소 · 표 · 부른 단가산출) — 같은 값을 조합마다 다시 찾지 않음.""" def __init__(self, links: Any): self.links, self.hits, self.calls = links, {}, {} def _hit(self, tag: str, p: dict, fn: Any) -> Any: key = (tag, json.dumps(p, sort_keys=True, ensure_ascii=False, default=str)) if key not in self.hits: try: self.hits[key] = (True, fn(p)) except (mc.Gap, mc.Broken) as e: self.hits[key] = (False, e) ok, got = self.hits[key] if not ok: raise got return got def element(self, p: dict) -> Any: return self._hit("요소", p, self.links.element) def table(self, p: dict) -> Any: return self._hit("표", p, self.links.table) def doc(self, key: str) -> dict | None: return self.links.doc(key) # ── ① 후보 ──────────────────────────────────────────────────────────── class Index: """M01 수식 문서 색인(결과단위 · 자원 · 절) — 프로세스 안 한 번.""" def __init__(self, docs: dict[str, dict]): self.docs = docs self.by_unit: dict[str, list[str]] = {} self.res: dict[str, dict[str, set]] = {} self.secs: dict[str, set[str]] = {} self.regions: set[str] = set() for key, doc in docs.items(): for v in doc.get("변수") or []: if str(v.get("열쇠") or "").endswith("지역"): self.regions |= {str(x) for x in (v.get("연결") or {}).get("목록") or []} unit = unit_of(doc.get("결과단위")) if not unit or not doc.get("금액"): continue self.by_unit.setdefault(unit, []).append(key) self.res[key] = resources(doc) self.secs[key] = sections([doc.get("출처")]) def candidates(self, doc: dict) -> list[str]: mine = resources(doc) keys = [] for key in self.by_unit.get(unit_of(doc.get("결과단위")), []): have = self.res[key] if not mine["노임"] <= have["노임"]: continue if mine["재료"] and not have["재료"]: continue if mine["기계"] and not have["기계"]: continue if not (mine["노임"] or mine["재료"] or mine["기계"]): continue keys.append(key) secs = sections([x.get("글") for x in (doc.get("가져온") or {}).get("추정") or []]) hit = [k for k in keys if secs & self.secs[k]] return hit or keys # ── ② 옵션 풀이 ─────────────────────────────────────────────────────── def _closure(doc: dict) -> dict[str, set[str]]: """기호 → 그 기호가 (거쳐서) 부르는 이름 전부.""" direct: dict[str, set[str]] = {} for v in doc.get("변수") or []: direct[md.var_id(v)] = set(md.link_names(v.get("연결") or {})) for line in doc.get("식") or []: try: direct[str(line.get("기호"))] = set(md.names(md.parse(line.get("식")))) except md.DocError: direct[str(line.get("기호"))] = set() memo: dict[str, set[str]] = {} def reach(name: str, seen: tuple = ()) -> set[str]: if name in memo: return memo[name] out = set() for n in direct.get(name, ()): if n not in seen: out |= {n} | reach(n, (*seen, name)) memo[name] = out return out return {n: reach(n) for n in direct} def _uses(text: Any, close: dict) -> set[str] | None: try: names = md.names(md.parse(text)) if text not in (None, "") else [] except md.DocError: return None out = set(names) for n in names: out |= close.get(n, set()) return out def numbers_of(doc: dict) -> list[Decimal]: """가져온 식에 나온 수(숫자 변수 · 식 · 수량 글 · 규격 · 이름).""" got: list[Decimal] = [] texts = [doc.get("이름"), (doc.get("가져온") or {}).get("규격")] for v in doc.get("변수") or []: val = (v.get("연결") or {}).get("값") if not isinstance(val, dict): texts.append(val) texts += [x.get("식") for x in doc.get("식") or []] texts += [x.get("수량") for x in doc.get("금액") or []] texts += [x.get("규격") for x in doc.get("금액") or []] for t in texts: for s in _NUMS.findall(str(t or "")): d = Decimal(s).normalize() if d not in got: got.append(d) return got def _words(doc: dict) -> str: """가져온 식 글(이름 · 규격 · 줄 규격 · 부름 입력 값 — 줄 이름 뺌) — 선택 값의 글 근거.""" bits = [doc.get("이름"), (doc.get("가져온") or {}).get("규격")] bits += [x.get("규격") for x in doc.get("금액") or []] for v in doc.get("변수") or []: bits += [str(x) for x in ((v.get("연결") or {}).get("입력") or {}).values()] return " ".join(str(b or "") for b in bits).replace(" ", "") def domains( doc: dict, imported: dict, regions: set[str] = frozenset() ) -> tuple[dict[str, list], dict[str, set]]: """선택 · 입력 변수 → (돌릴 값, 글 근거) — 기본 먼저 · 가져온 글에 든 선택 값(근거) 먼저 · 글 입력 지역 = 가져온 글에 든 지역 이름.""" words, nums = _words(imported), numbers_of(imported) out: dict[str, list] = {} ev: dict[str, set] = {} for v in doc.get("변수") or []: link = v.get("연결") or {} kind, base = link.get("갈래"), link.get("값") if kind == "선택": opts = list(link.get("목록") or []) seen = [o for o in opts if str(o).replace(" ", "") in words and len(str(o)) > 1] seen.sort(key=lambda o: -len(str(o))) if seen: ev[md.var_id(v)] = set(seen) rest = [o for o in opts if o not in seen] first = [base] if base in rest else [] out[md.var_id(v)] = seen + first + [o for o in rest if o != base] elif kind == "입력": try: d = Decimal(str(base).replace(",", "")).normalize() except (InvalidOperation, ValueError): if str(v.get("열쇠") or "").endswith("지역"): seen = sorted((r for r in regions if len(r) > 1 and r in words), key=len) out[md.var_id(v)] = list(dict.fromkeys([base, *seen[::-1]])) continue # 그 밖 글 입력 = 기본 그대로 low, high = (mc.number(x) for x in (link.get("범위") or [None, None])) vals = [d, Decimal(0)] + nums vals = [x for x in vals if (low is None or x >= low) and (high is None or x <= high)] out[md.var_id(v)] = list(dict.fromkeys(vals))[:CAP_INPUT] return out, ev def _given(doc: dict, pick: dict) -> dict: ids = {md.var_id(v): v.get("열쇠") or md.var_id(v) for v in doc.get("변수") or []} return {ids[k]: v for k, v in pick.items()} def _eval(doc: dict, links: Any, pick: dict, text: Any, memo: dict) -> Any: run = mc._Run(doc, links, _given(doc, pick), (str(doc.get("키")),), links.calls) got = run.expr(text, "줄") if isinstance(got, Decimal) and got == 0: return 0 return _key(got) if isinstance(got, (Decimal, dict)) else None def narrow( doc: dict, links: Any, dom: dict, want: dict[str, set], ev: dict[str, set] | None = None ) -> dict[str, list]: """줄마다 의존 변수만 돌려 가져온 값(수량 · 단가) · 0 에 닿는 값만 남김(두 바퀴) · 글 근거 값(`ev`)은 안 지움(값이 달라도 그 조합을 셈 — 다른 항으로 드러냄).""" ev = ev or {} close = _closure(doc) memo: dict = {} base = {k: vs[0] for k, vs in dom.items()} jobs = [] for line in doc.get("금액") or []: for slot, field in (("수량", "수량"), ("단가", "단가")): uses = _uses(line.get(field), close) if uses is None or uses & (set(md.MONEY_NAMES) | _money_syms(doc)): continue free = sorted(u for u in uses if u in dom) if free: jobs.append((line.get(field), free, want[slot])) for _ in range(2): for text, free, targets in jobs: space = [dom[f] for f in free] if _size(space) > CAP_LINE: continue ok: dict[str, set] = {f: set() for f in free} for combo in itertools.product(*space): pick = {**base, **dict(zip(free, combo))} got = _eval(doc, links, pick, text, memo) if got == 0 or got in targets: for f, c in zip(free, combo): ok[f].add(c) if all(ok.values()): for f in free: ok[f] |= ev.get(f, set()) dom[f] = [x for x in dom[f] if x in ok[f]] base[f] = dom[f][0] return dom def _money_syms(doc: dict) -> set[str]: return {str(x["기호"]) for x in doc.get("금액") or [] if x.get("기호")} def _size(space: list[list]) -> int: n = 1 for s in space: n *= max(len(s), 1) return n def combos(doc: dict, dom: dict) -> list[dict]: """좁힌 값 조합(기본 쪽 먼저 · 상한 `CAP_ALL`) — 한 값뿐인 변수는 그 값.""" keys = [k for k, v in dom.items() if v] out = [] for combo in itertools.product(*(dom[k] for k in keys)): out.append(dict(zip(keys, combo))) if len(out) >= CAP_ALL: break return out # ── ③ 줄 대조 ───────────────────────────────────────────────────────── def _ratio(a: Any, b: Any) -> str | None: if not isinstance(a, Decimal) or not isinstance(b, Decimal) or not a or not b: return None for n in NICE: if _key(a * n) == b: return f"× {n}" if _key(b * n) == a: return f"÷ {n}" return None def compare(mine: list[dict], ours: list[dict]) -> dict[str, Any]: """가져온 줄(mine) ↔ M01 줄(ours) → {같음(로직 같은 줄), 짝, 핵심, 다른 [{항, 글}], 참고 [{항, 글}]}.""" left, right = list(mine), list(ours) same = paired = 0 diffs: list[dict] = [] def take(test) -> list[tuple[dict, dict]]: hits = [] for a in list(left): b = next((x for x in right if test(a, x)), None) if b is not None: left.remove(a) right.remove(b) hits.append((a, b)) return hits def core(a: dict, b: dict) -> bool: return a["갈래"] != "율" and b["갈래"] != "율" def cls(a: dict, b: dict) -> bool: return a["갈래"] == b["갈래"] or {a["갈래"], b["갈래"]} <= {"기계", "산출"} notes: list[dict] = [] exact = take( lambda a, b: ( core(a, b) and a["수량"] == b["수량"] and a["단가"] == b["단가"] and a["비목"] == b["비목"] and (a["코드"] == b["코드"] or not (a["코드"] and b["코드"])) # 코드 다름 = 짝 대조 ) ) same += len(exact) def twin(a: dict, b: dict) -> bool: return core(a, b) and cls(a, b) and a["수량"] == b["수량"] for a, b in take(lambda a, b: twin(a, b) and not note.pair(a, b)[0]): # 로직 같은 짝 먼저 same += 1 notes += note.pair(a, b)[1] for a, b in take(twin): bad, ref = note.pair(a, b) same, paired = (same + 1, paired) if not bad else (same, paired + 1) diffs += bad notes += ref for a, b in take(lambda a, b: core(a, b) and cls(a, b) and a["단가"] == b["단가"]): paired += 1 how = _ratio(a["수량"], b["수량"]) what = "단위 환산" if how else "수량" diffs.append({"항": what, "글": f"{a['이름']} 수량 {a['수량']} ↔ {b['수량']} {how or ''}"}) for a, b in take(lambda a, b: core(a, b) and a["갈래"] == b["갈래"] == "기계"): paired += 1 diffs.append( {"항": "기계 규격", "글": f"{a['이름']} {a['코드']} ↔ {b['코드']} · 수량 다름"} ) rate_diffs, rate_notes = note.rate_pairs(left, right, take) diffs += rate_diffs notes += rate_notes for side, rows in (("가져온 쪽만", left), ("우리 쪽만", right)): for x in rows: if x["갈래"] == "율": what = "공구손료 유무" if "공구" in x["이름"] else "율 줄" else: what = "줄 없음" diffs.append({"항": what, "글": f"{side} — {x['이름']}"}) n_core = sum(1 for a in mine if a["갈래"] != "율") return {"같음": same, "짝": same + paired, "핵심": n_core, "다른": diffs, "참고": notes} def _totals(got: dict) -> dict: return {k: _key(mc.number(got.get(k))) for k in md.BUNDLE} EXPLAINED = ( "이름 다름", "자원", "산출 단가", "율 대상", "단위 환산", "기계 규격", "비목", "율 줄", "공구손료 유무", ) def verdict( mine: list[dict], ours: list[dict], got_a: dict, got_b: dict, off: list[dict] = () ) -> dict: """확정 = 다른 항 0(옵션 글 근거까지 맞음 · 참고는 있어도 됨) · 근접 = 핵심 줄 반 넘게 짝 + (같은 줄 있음 또는 다른 항이 모두 까닭 있는 갈래 — 자원 · 단위 환산 · 기계 규격 · 비목 · 율) · 그 밖 없음 · 끝수 = 줄 로직 · 단가 모두 같은데 합만 다름(참고).""" res = compare(mine, ours) res["다른"] = [*off, *res["다른"]] ta, tb = _totals(got_a), _totals(got_b) if not res["다른"] and not res["참고"] and ta != tb: res["참고"].append( {"항": "끝수", "글": f"줄은 같고 합이 다름 {_sum_txt(ta)} ↔ {_sum_txt(tb)}"} ) if not res["다른"] and res["같음"]: res["판정"] = SURE elif res["짝"] * 2 >= res["핵심"] + 1 and ( res["같음"] or all(d["항"] in EXPLAINED for d in res["다른"]) ): res["판정"] = NEAR else: res["판정"] = NONE return res def _sum_txt(t: dict) -> str: return " · ".join(f"{k} {note.txt(v)}" for k, v in t.items() if v is not None) # ── 한 문서 ─────────────────────────────────────────────────────────── def _calc(doc: dict, links: Any, given: dict) -> dict | None: try: got = mc.calc(doc, links, given, (), getattr(links, "calls", None)) except Exception: # noqa: BLE001 — 못 셈 = 그 조합 버림 return None return got if got.get("셈됨") else None def try_doc( base: dict, links: Any, imported: dict, got_imp: dict, mine: list[dict], regions: set[str] ) -> dict: """M01 문서 하나 — 옵션 풀이 · 가장 나은 조합 판정(이름 · 절 근거 없으면 「이름 다름」 = 확정 아님).""" want = { "수량": {x["수량"] for x in mine if x["수량"] is not None}, "단가": {x["단가"] for x in mine if x["단가"] is not None}, } dom, ev = domains(base, imported, regions) dom = narrow(base, links, dom, want, ev) named = [] if same_name(imported, base) else [_unnamed(imported, base)] best: dict | None = None for pick in combos(base, dom) or [{}]: got = _calc(base, links, _given(base, pick)) if got is None: continue off = [_off(base, k, pick[k], ev[k]) for k in ev if k in pick and pick[k] not in ev[k]] off += named res = verdict(mine, facts(base, got), got_imp, got, off) rank = ( res["판정"] == SURE, -len(off), res["같음"], res["짝"], -len(res["다른"]), -len(res["참고"]), ) if best is None or rank > best["순위"]: best = {**res, "순위": rank, "옵션": pick, "금액": _totals(got)} if res["판정"] == SURE and not res["참고"]: break return best or { "판정": NONE, "같음": 0, "짝": 0, "다른": [], "참고": [], "순위": (False, -9, 0, 0, 0, 0), } def _off(doc: dict, vid: str, value: Any, seen: set) -> dict: """옵션 값이 가져온 글 근거와 다름 — 다른 항 「옵션 글」.""" var = next((v for v in doc.get("변수") or [] if md.var_id(v) == vid), {}) name = var.get("이름") or vid return {"항": "옵션 글", "글": f"{name} 글 {'·'.join(map(str, seen))} ↔ {value}"} def match(imported: dict, links_imp: Any, index: Index, links: Any) -> dict[str, Any]: """가져온 식 문서 → 연결후보 `{판정, 키, 이름, 옵션, 다른, 참고, 금액, 후보수}`.""" links = links if isinstance(links, Memo) else Memo(links) got_imp = _calc(imported, links_imp, {}) if got_imp is None: return {"판정": NONE, "까닭": "가져온 식을 우리 단가로 못 셈", "후보수": 0} mine = facts(imported, got_imp) keys = index.candidates(imported) best, best_key = None, None for key in keys: res = try_doc(index.docs[key], links, imported, got_imp, mine, index.regions) if best is None or res["순위"] > best["순위"]: best, best_key = res, key if res["판정"] == SURE and not res["참고"]: break if best is None: return {"판정": NONE, "후보수": 0} base = index.docs[best_key] if best["판정"] == NONE: # 가까운 후보(보이기만 — 단추 없음) near = {"키": best_key, "이름": base.get("이름"), "다른": best["다른"][:12]} return {"판정": NONE, "후보수": len(keys), "가까움": near} return { "판정": best["판정"], "키": best_key, "이름": base.get("이름"), "옵션": _shown(base, best["옵션"]), "다른": best["다른"], "참고": best["참고"], "금액": {"가져온": _plain(_totals(got_imp)), "우리": _plain(best["금액"])}, "후보수": len(keys), } def _shown(doc: dict, pick: dict) -> dict[str, Any]: """고른 옵션(기본과 다른 것) — {열쇠: 값}(수 = 수 · 선택 목록의 수 값과 같은 꼴 · SK 입력값).""" out = {} for v in doc.get("변수") or []: vid = md.var_id(v) if vid in pick and mc.plain_text(mc.number(pick[vid])) != mc.plain_text( mc.number((v.get("연결") or {}).get("값")) ): out[str(v.get("열쇠") or vid)] = ml._norm(mc.number(pick[vid])) return out _STOP = {"설치", "해체", "인력", "기계", "작업", "운반", "타설", "제작"} def _grams(texts: list[Any]) -> set[str]: """이름 낱말 — 한글 두 글자 묶음 · 영문 · 수 낱말(일반 낱말 뺌).""" out: set[str] = set() for t in texts: t = re.sub(r"\s|및", "", str(t or "")) for run in re.findall(r"[가-힣]+", t): out |= {run[i : i + 2] for i in range(len(run) - 1)} out |= {w.lower() for w in re.findall(r"[A-Za-z]{2,}", t)} return out - _STOP def same_name(imported: dict, base: dict) -> bool: """뜻 근거 — 품셈 절 번호가 같거나 이름 낱말이 겹침(값만 같은 남의 기초를 확정으로 안 묶음).""" secs = sections([x.get("글") for x in (imported.get("가져온") or {}).get("추정") or []]) if secs & sections([base.get("출처")]): return True return bool(_grams([imported.get("이름")]) & _grams([base.get("이름")])) def _unnamed(imported: dict, base: dict) -> dict: return {"항": "이름 다름", "글": f"{imported.get('이름')} ↔ {base.get('이름')}"} def _plain(d: dict) -> dict: return {k: (None if v is None else float(v)) for k, v in d.items()}