Files
eomsangdonandClaude Opus 5.5 2101601f39 feat(794-2c): 근거 제안 엔진 후속 — 같은 말 묶음 · 옛 번호 대조표 · 기계 작업량 길 넓힘 · 섞기 오인 넷 고침
- 새 M02_San_Basis_Map — 옛 번호 대조표(건설 옛 11-3 → 8-2-3 · 3-7 → 3-9-1 · 8-2-35 → 8-2-32 · 근거 = 원문 표 값 · 실무 글) · 사방(옛) = 판 대응표 2018 통합 판 · 기계 상수 줄 글 읽기(여러 대입 한 줄 · 괄호 설명 · 끝값 없는 나눗셈) · 작업량당 기계경비 로직 낱말 표
- 엔진 — 같은 말(휠라 = 채움재 · 평떼 = 평잔디 · 육샹 = 육상) · 건설(옛) 같은 번호는 같은 절 + 제목 뜻 겹침만 · Q 식 절 기호 채움(고르는 차례는 채우기 전) · 값 · 제목 길이 품 검산으로 안 받쳐지면 그 기계 작업량당 기계경비(차례 「기계」 · 번호 길은 그대로)
- 검산 probe — 고르기가 범위뿐이면 용량 · 규격 값이 드는 범위 하나로(버킷용량 → 규격구분)
- 459 블록: 없음 85 → 58 · 확정 125 → 164 · 근접 135 → 145 · 사람 114 → 92 · 확정 · 근접 → 없음 0 · 섞기(GF000132) 오인 넷 → GC001148 · GC001189
- 시험 test_793_3a 넓힘 — 없음 ≤ 58 · 확정 ≥ 164 · 손 확인 견본 열 · 1/3 적용 지어 넣지 않음 · 상수 읽기 · 대조표
- 근거후보 꼴 그대로 · M01 · 지식DB 고침 없음

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

603 lines
25 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""근거 제안 엔진(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 근거).
자동 바꿈 없음 · 지어 넣는 값 없음(못 읽은 칸 · 글에 없는 감률 · 할증 = 입력필요).
794-2c — 같은 말 묶음(SYN) · 옛 번호 → 현행 절(`old_cands` · `M02_San_Basis_Map.OLD`) 번호 후보 ·
Q 식 절은 줄 글에서 기호 끝값을 더 읽고(`_fill`) 값 · 제목 길이 품 검산으로 안 받쳐지면 그 기계
「작업량당 기계경비」 로직(`by_machine` · 차례 「기계」).
"""
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_Map as bm
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()}
self.mach = bm.machines(self.logic)
_CTX: list = [None, None]
def ctx() -> Ctx:
idx = bi.get()
if _CTX[0] is not idx:
_CTX[:] = [idx, Ctx(idx)]
return _CTX[1]
# ── 제목 → 로직 이름 후보 ───────────────────────────────────────────
def syn(text: Any) -> str:
t = str(text or "")
for a, b in SYN:
t = re.sub(re.escape(a), b, t, flags=re.I)
return t
def grams(text: Any) -> set:
return clause._grams(re.sub(r"\d", " ", syn(text)))
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)
lines = [str(t) for t in blk.get("글") or []]
whole = {k: top[k] + solve._gather(nodes, k) for k in ("상수", "식", "선택값")}
_fill(whole, lines)
if len(works) < 2:
one = {"제목": "(블록 통째)", "번": None, "길": None, "항목": usable, "글줄": lines}
one |= 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],
"글줄": lines[w["글"][0] : _end(w) + 1] if w.get("글") else [],
}
part |= {k: top[k] + solve._gather([w], k) for k in ("상수", "식", "선택값")}
_fill(part, part["글줄"])
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 _end(node: dict) -> int:
return max([node["글"][1], *(_end(n) for n in node.get("아래") or [] if n.get("글"))])
def _fill(part: dict, lines: list[str]) -> None:
"""기계 작업량 절(Q 식 줄) — 못 읽은 기호(q · K · f · E · Cm)를 줄 글 끝값으로 더함 · 고르는
차례(`is_machine`)는 채우기 전 상수로."""
if bm.has_q(lines):
part["기계식"] = is_machine(part)
have = {chk.base_sym(x.get("기호") or "") for x in part["상수"]}
part["상수"] = part["상수"] + [
x for x in bm.consts(lines) if chk.base_sym(x["기호"]) not in have
]
def old_cands(c: Ctx, basis: dict, title: str, unit: Any) -> list[dict]:
"""옛 번호 → 현행 절 로직(`M02_San_Basis_Map`) · 건설품셈(옛) 같은 번호는 같은 절 · 제목과 뜻
겹침만 · 결과단위 성격 거름."""
mine, have, out = clause.nature(unit), {x["키"] for x in basis["후보"]}, []
for book, no in basis["출처"]:
if not book.endswith(clause.OLD):
continue
rows = bm.old_rows(book, no)
if not rows and book == "건설품셈" + clause.OLD:
rows = [
o
for o in clause.lookup("건설품셈", no)
if o["맞음"] == "같음" and dice(c, o["키"], title) > 0
]
for o in rows:
theirs = clause.nature(o["결과단위"])
if o["키"] not in have and not (mine and theirs and mine != theirs):
have.add(o["키"])
out.append({**o, "절": None})
return out
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 by_machine(c: Ctx, p: dict, S: list, how: str, title: str, check) -> tuple[list, str]:
"""기계 작업량 절(Q 식) — 번호 길이 아니고 고른 기초가 품 검산으로 안 받쳐지면 그 기계
「작업량당 기계경비」 로직(품 같음 · 또는 고른 것 없고 품 짝 있음)."""
if how.startswith("번호") or not bm.has_q(p["글줄"]) or not is_machine(p):
return S, how
names = " ".join(str(i.get("이름") or "") for i in p["항목"])
mk = bm.machine_key(c.mach, syn(f"{p['제목']} {title} {names}"))
if not mk or mk in S or (len(S) == 1 and _good(check(S[0]))):
return S, how
v = check(mk)
cnt = v.get("셈") or {}
if _good(v) or ("오류" not in v and (cnt.get(solve.SAME) or cnt.get(solve.DIFF))):
return [mk], "기계"
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()
basis["후보"] += old_cands(c, basis, title_b, blk.get("단위"))
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)), p.get("기계식", 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)))
S, how = by_machine(
c, p, S, how, title_b, 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