feat(800-2): 수식 변수 정의 영역 서버 — 정의 칸(안 탄 갈래 뺌 · 겹침 합침 · 열쇠 글 0) · 새 기호 연결 고침 · 후보 길

- 수식 응답 새 칸 정의 — 만약 탄 갈래 표지 걸림 그대로 · 같은 기호 한 줄 · 연결 사람 말 · 값 · 단위 · DB 열쇠 없음
- 고침 할일 연결(입력 · 표 · 요소 · 로직 · 상수) · 모르는 기호 까닭 · 후보 길 M01 · M02 equation/links
- 낡은 시험 test_796_1 「모르는 이름」 → 「모르는 기호」(까닭 문구 바뀜)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 16:46:39 +09:00
co-authored by Claude Sonnet 5.5
parent 972002417a
commit a262b43e83
10 changed files with 448 additions and 1 deletions
@@ -28,7 +28,9 @@ from pathlib import Path
import master_auto as ma
import master_cut as mcut
import master_equation_view as view
import master_formula as mf
import master_material as mm
from common_util.common_util_money import fmt
@@ -195,10 +197,18 @@ def _raw(node) -> str:
return me.raw_text(node)
def me_keyboard(ctx: _Ctx, node) -> str:
"""조건 식 마디 → 키보드 한 줄 글(변수 = 기호) — 정의 영역 「조건 규격 = 폼타이규격」."""
import master_equation_edit as me
return me.keyboard_of(_raw(node), ctx.row)
def _find(ctx: _Ctx, node, column: str) -> dict:
"""찾기(표, 조건).칸 → 찾기 변수 · 표 · 고른 행."""
raw = _raw(("field", node, column))
more = {"식": raw, "칸": column}
more["조건식"] = {k: me_keyboard(ctx, c) for k, c in node[2]}
try:
ref = mf.fill(node[1], ctx.env)
table_row = ctx.master.get(ref)
@@ -451,6 +461,11 @@ def export(master, key: str, given: dict | None = None, depth: int = 0) -> dict:
total = {k: cut.get(k) for k in mf.BUNDLE}
if depth == 0:
sub = {"소계": subtotals(row, cut, total)}
defs = (
{"정의": view.definitions(out_lines, row, table(row, master), _unused(row))}
if depth == 0
else {}
)
return _clean(
{
"열쇠": row.get("키"),
@@ -461,10 +476,18 @@ def export(master, key: str, given: dict | None = None, depth: int = 0) -> dict:
"변수표": table(row, master),
"합계": total,
**sub,
**defs,
}
)
def _unused(row: dict):
"""열쇠 → 어느 식에도 안 쓰이나(정의 영역 · 지울 수 있는 입력)."""
import master_equation_edit as me
return lambda key: key != mm.REGION and not me.uses(row, key)
def _item(ctx: _Ctx, at: int, item: dict, line: dict) -> dict:
"""호표 줄 = 「단가 × 수량식 = 금액」 · 로직 줄 단가 = 블록."""
name = str(item.get("이름") or "")
@@ -10,6 +10,7 @@
{할일: 지움, 열쇠} — 쓰는 식이 있으면 막고 쓰는 곳 목록
{할일: 변수, 열쇠, 이름?, 기호?, 위?, 아래?, 단위?} — 식은 열쇠로 부르니 안 깨짐
{할일: 표시, 열쇠, 표시: 0~6 | null}
{할일: 연결, 열쇠, 갈래: 입력|표|요소|로직|상수, …} — 새 기호의 연결 지정(`master_equation_link.py` 머리)
고친 중간 줄은 `표시` 없으면 `SHOW`(보이는 자리에서 잘라 셈 · 요구 658) — 고치지 않은 줄은 그대로.
키보드 한 줄 식 = 변수 글(기호_아래^위 · 이름 · 열쇠) · `× ÷ −` · 나머지는 엔진 식 말 그대로
(`[노임 …]` · `[별칭]` 은 M01 · M02 층이 `M02_Sangeun_Formula.to_engine` 으로 먼저 풂).
@@ -22,6 +23,7 @@ import re
from decimal import Decimal, InvalidOperation
import master_equation as meq
import master_equation_link as link
import master_formula as mf
KEY_FORM = re.compile(r"^[^\W\d][\w]{0,19}$") # 열쇠 = 글자로 시작 · 20 자까지
@@ -227,12 +229,39 @@ def _text_of(ed: dict, row: dict, words) -> tuple[str, list[str]]:
return words(text, row) if words and text else (text, [])
def unknown(text: str, row: dict) -> list[str]:
"""엔진 식 글 안 모르는 변수 글(변수 · 열쇠 · 예약어 · 함수 · 칸 · 인자 이름 · 요소 키 빼고)."""
known = set(_names(row)[0]) | _all_keys(row) | RESERVED
out, at = [], 0
while at < len(text):
if text[at] in "'[":
end = text.find("'" if text[at] == "'" else "]", at + 1)
at = len(text) if end < 0 else end + 1
continue
m = None if at and text[at - 1].isalnum() else _TOKEN.match(text, at)
if not m:
at += 1
continue
word, before, rest = m.group(), text[:at].rstrip(), text[m.end() :].lstrip()
at = m.end()
arg = rest.startswith("=") and not rest.startswith("==")
skip = before.endswith(".") or rest.startswith("(") or arg
if not (skip or word in known or mf.mk.KEY.fullmatch(word) or word in out):
out.append(word)
return out
def _set_formula(row: dict, ed: dict, why: list, spots: set, words=None) -> None:
spot = str(ed.get("자리") or "")
text, bad = _text_of(ed, row, words)
if bad:
why += [f"{spot} · {w}" for w in bad]
return
if not isinstance(ed.get("나무"), dict) and (miss := unknown(text, row)):
why.append(
f"{spot} · 모르는 기호 「{'」 「'.join(miss)}」 — 변수 정의 영역에서 연결을 지정"
)
return
if not text:
why.append(f"{spot} · 식이 빔")
return
@@ -383,6 +412,9 @@ def apply(master, row: dict, edits: list, words=None) -> tuple[dict, list[str],
_look(new, key, ed, why)
elif job == "표시":
_show(new, key, ed, why)
elif job == "연결":
add, bad = link.plan(master, ed)
_add(new, add, why, spots, words) if add else why.append(bad)
else:
why.append(f"모르는 할일 「{job}」")
except (mf.FormulaError, KeyError, TypeError, ValueError) as e:
@@ -0,0 +1,125 @@
# -*- coding: utf-8 -*-
"""수식 변수 연결 지정(PLAN 800-3 · 요구 660-5) — 새 기호가 무엇과 이어지는지 고르는 서버 몫.
고침 `{할일: 연결, 열쇠, 갈래, 이름?, 기호?, 위?, 아래?, 뒤?, …}` 을 기존 `더함` 한 건으로 풂(`plan`):
입력 {기본값?, 단위?} → 입력 변수(옵션 칸에 생김)
표 {표, 칸, 조건: {열: 식글}} → 중간 줄 「찾기(표, 열=식…).칸」
요소 {요소: 키} → 중간 줄 「키」(인력 · 재료 · 기계 단가)
로직 {로직: 키, 인자?: {칸: 식}} → 중간 줄 「로직(키, 칸=식…)」
상수 {값: 수} → 중간 줄 수
후보 목록 = `candidates`(표 · 요소 · 로직) — 읽기만 · 값 · 열쇠는 이름 옆 보관용(화면은 이름만 보임).
셈 · 검사는 `master_equation_edit.apply` 가 한 벌로(여기는 글만 짓고 막을 것만 막음).
"""
from __future__ import annotations
import re
from decimal import Decimal, InvalidOperation
LIMIT = 30
KINDS = ("입력", "표", "요소", "로직", "상수")
ELEMS = {"인력": ("LB",), "재료": ("MT", "MO"), "기계": ("EQ",)}
TABLES = ("QC", "QF")
_KEY = re.compile(r"[A-Z]{2}\d{6}")
_COL = re.compile(r"^[^\W\d][\w]*$")
def _q(word: str) -> str:
return word if _COL.match(word) else f"'{word}'"
def _row(master, key: str):
try:
return master.get(key)
except Exception: # noqa: BLE001 — 마스터 없는 열쇠 = 어떤 오류든 「없음」
return None
def plan(master, ed: dict) -> tuple[dict | None, str]:
"""연결 고침 → (더함 고침, 까닭) — 까닭이 있으면 더함 없음."""
kind = ed.get("갈래")
add = {
"할일": "더함",
**{k: ed[k] for k in ("열쇠", "이름", "기호", "위", "아래", "단위", "뒤") if k in ed},
}
if kind not in KINDS:
return None, f"연결 갈래 「{kind}」 — {' · '.join(KINDS)}"
if kind == "입력":
add |= {"갈래": "입력"} | (
{"기본값": ed["기본값"]} if ed.get("기본값") not in (None, "") else {}
)
return add, ""
add["갈래"] = "중간"
if kind == "상수":
try:
add["식"] = format(Decimal(str(ed.get("값"))).normalize(), "f")
except (InvalidOperation, ValueError):
return None, "상수는 수로 적어야 함"
elif kind == "요소":
key = str(ed.get("요소") or "")
if (
not _KEY.fullmatch(key)
or key[:2] not in sum(ELEMS.values(), ())
or not _row(master, key)
):
return None, "요소를 골라야 함(인력 · 재료 · 기계)"
add["식"] = key
elif kind == "로직":
key = str(ed.get("로직") or "")
if not _KEY.fullmatch(key) or not key.startswith("G") or not _row(master, key):
return None, "부를 로직을 골라야 함"
args = "".join(
f", {_q(k)}={v}" for k, v in (ed.get("인자") or {}).items() if str(v).strip()
)
add["식"] = f"로직({key}{args})"
else:
key, col = str(ed.get("표") or ""), str(ed.get("칸") or "")
table = _row(master, key) if _KEY.fullmatch(key) else None
if not table or "조건" not in table or col not in (table.get("값칸") or {}):
return None, "표와 칸을 골라야 함"
given = ed.get("조건") or {}
miss = [c for c in table["조건"] if not str(given.get(c) or "").strip()]
if miss:
return None, f"표 조건을 채워야 함 — {' · '.join(miss)}"
cond = "".join(f", {_q(c)}={given[c]}" for c in table["조건"])
add["식"] = f"찾기({key}{cond}).{_q(col)}"
return add, ""
def _hit(text: object, words: list[str]) -> bool:
low = str(text or "").lower()
return all(w in low for w in words)
def candidates(master, ask: dict) -> dict:
"""후보 {갈래: 표 | 요소 | 로직, 검색, 분류?(요소)} → {후보: [{키, 이름, …}]} — 최대 `LIMIT` 건."""
kind, words = ask.get("갈래"), str(ask.get("검색") or "").lower().split()
out: list[dict] = []
if kind == "표":
pools = [(k, r) for p in TABLES for k, r in master.index.get(p, {}).items() if "조건" in r]
for key, r in pools:
if _hit(r.get("이름"), words):
out.append(
{
"키": key,
"이름": r.get("이름"),
"조건": list(r["조건"]),
"칸": list(r.get("값칸") or {}),
}
)
elif kind == "요소":
for p in ELEMS.get(str(ask.get("분류") or "인력"), ()):
for key, r in master.index.get(p, {}).items():
if (words or p != "MT") and _hit(f"{r.get('이름')} {r.get('규격') or ''}", words):
out.append({"키": key, "이름": r.get("이름"), "규격": r.get("규격") or ""})
elif kind == "로직":
for key, r in master.index.get("GC", {}).items():
if words and _hit(r.get("이름"), words):
out.append(
{
"키": key,
"이름": r.get("이름"),
"입력": [s["이름"] for s in r.get("입력") or []],
}
)
return {"후보": out[:LIMIT], "더있음": len(out) > LIMIT}
@@ -0,0 +1,127 @@
# -*- coding: utf-8 -*-
"""수식 변수 정의 영역(PLAN 800-2 · 요구 660-2 · 3 · 6) — 수식 나무 → 오른쪽 「변수 정의」 목록.
줄 구분 없이 한 목록 · 같은 변수는 한 번만(겹침 합침) · 기호 : 국문 이름 — 연결(입력 · 표 찾기 ·
요소 · 로직 · 계산을 사람 말로) · 값 · 단위 · DB 열쇠 글은 담지 않음.
지금 고른 옵션 갈래만 — 만약의 탄 갈래 표지 `걸림`(1 참 · 2 거짓 · 0 모름 = 둘 다)을 그대로 씀(새 판정 없음) ·
`안씀` = 어느 식에도 안 쓰이는 입력(지울 수 있음) · 쓰는 변수 지움 막기는 `master_equation_edit._drop`.
"""
from __future__ import annotations
CAT = {"LB": "인력", "EQ": "기계", "MT": "재료", "MO": "재료"}
WHAT = {
"입력": "입력",
"중간": "계산",
"결과": "계산 결과",
"찾기": "표 찾기",
"요소": "요소",
"블록": "로직 부르기",
}
def visible(node: dict | None, out: list[dict]) -> list[dict]:
"""나무에서 보이는(탄 갈래) 변수 · 블록 마디를 차례대로."""
if not isinstance(node, dict):
return out
kind = node.get("꼴")
if kind in ("변수", "블록"):
out.append(node)
elif kind == "조건":
visible(node.get("조건"), out)
for key, at in (("참", 1), ("거짓", 2)):
if node.get("걸림") in (0, None, at):
visible(node.get(key), out)
else:
for key in ("좌", "우", "안", "위", "아래"):
visible(node.get(key), out)
for arg in node.get("인자") or []:
visible(arg, out)
return out
def _link(node: dict, spec: dict | None, leaf: dict | None) -> str:
"""연결 설명 — 사람 말(열쇠 · 값 · 단위 없음)."""
kind = node.get("갈래") or ("블록" if node.get("꼴") == "블록" else "")
if kind == "입력":
return "입력 — " + ("옵션에서 고름" if (spec or {}).get("고르기") else "직접 적음")
if kind == "중간" and leaf is not None and leaf is not node:
return _link(leaf, None, None)
if kind == "찾기":
cond = " · ".join(f"{k} = {v}" for k, v in (node.get("조건식") or {}).items())
head = f"표 찾기 — {node.get('표이름') or '표'}"
return head + (f" · 조건 {cond}" if cond else "") + f" · 칸 {node.get('칸') or ''}".rstrip()
if kind == "요소":
pick = node.get("고르기")
if isinstance(pick, dict):
return "요소 · 재료 고름 — " + " · ".join(str(v) for v in pick.values() if v)
return f"요소 · {CAT.get(str(node.get('식') or '')[:2], '단가')} — {node.get('이름') or ''}"
if kind == "블록":
return f"로직 부르기 — {node.get('이름') or ''}"
return WHAT.get(kind, "계산") + (" — 수식 영역의 식" if kind in ("중간", "결과") else "")
def definitions(lines: list[dict], row: dict, rows: list[dict], unused) -> list[dict]:
"""보이는 식의 변수 정의 목록 — `rows` = 변수 표 · `unused(열쇠)` = 어느 식에도 안 쓰임."""
specs = {str(s.get("이름")): s for k in ("입력", "중간") for s in row.get(k) or []}
table = {v["열쇠"]: v for v in rows}
got: dict[str, dict] = {}
leaf: dict[str, dict] = {}
for line in lines:
head, tree = line.get("결과"), line.get("이름식")
if line.get("갈래") in ("중간", "결과") and isinstance(tree, dict):
if tree.get("갈래") in ("찾기", "요소") or tree.get("꼴") == "블록": # 한 마디 식
leaf[str(line.get("열쇠"))] = tree
found = ([head] if head and line.get("갈래") in ("중간", "결과") else []) + visible(
tree, []
)
for node in found:
key = str(node.get("열쇠") or "")
if key and key not in got and node.get("갈래") in (*WHAT, "합", None):
got[key] = node
out: dict[tuple, dict] = {} # 같은 기호 글 = 같은 정의(겹침 합침) — 연결은 모아 보임
for key, node in got.items():
mine = table.get(key)
look = mine or node
kind = node.get("갈래") or "블록"
if kind == "합":
kind = "결과" if key == "결과" else "중간"
if key.startswith("줄."): # 위 호표 줄 금액 부름
name, link = key[2:].strip("'"), f"호표 줄 금액 — {key[2:].strip(chr(39))}"
else:
link = _link({**node, "갈래": kind}, specs.get(key), leaf.get(key))
name = (look.get("이름") or key) if mine else look.get("이름") or ""
name = node.get("칸") or name if kind == "찾기" else name
one = {
"열쇠": key if mine else "",
"기호": (look.get("기호") or name) if not key.startswith("줄.") else name,
"위": look.get("위") or "",
"아래": look.get("아래") or "",
"이름": name,
"갈래": kind,
"연결": [link],
"고침": bool(mine),
}
same = out.setdefault((one["기호"], one["위"], one["아래"]), one)
if same is not one:
same["연결"] += [x for x in one["연결"] if x not in same["연결"]]
if one["열쇠"] and not same["열쇠"]:
same |= {k: one[k] for k in ("열쇠", "이름", "갈래", "고침")}
out = list(out.values())
seen = {d["열쇠"] for d in out}
for v in rows: # 어느 식에도 안 쓰이는 입력 — 지울 수 있게
if v["갈래"] == "입력" and v["열쇠"] not in seen and unused(v["열쇠"]):
out.append(
{
"열쇠": v["열쇠"],
"기호": v["기호"],
"위": v["위"],
"아래": v["아래"],
"이름": v["이름"],
"갈래": "입력",
"연결": ["입력 — 쓰는 식 없음"],
"고침": True,
"안씀": True,
}
)
return out