feat(796-1): 수식 모델 — 엔진 로직 ↔ 수식 나무 양방향 · 변수 표 · 추가 · 삭제 · 이름 전파 · 검사 · M01 길

- scripts/master_equation.py: 로직 줄 → 그림용 나무(이름식 · 값식 · 분수 · 퍼센트 · 조건 · 블록) · 풀이 · 변수 표 · 원문 「여기서」 기호 후보
- scripts/master_equation_edit.py: 나무 · 키보드 한 줄 식 → 엔진 식 · 더함 · 지움(쓰는 곳 막음) · 이름 · 기호 · 첨자(변수표 칸 · 식 그대로) · 고친 중간 줄 표시 4 자리 · 검사
- M01 길 셋(/logic/equation · preview · save — 기존 /save · 개정확인) · M02 사본 이음 export_row · edit_row
- _틀.md 로직 열 차례 끝 변수표 · 셈 값 바뀜 0(791-1 지문 같음)
- 시험 test_796_1_formula_model.py 17

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 11:14:25 +09:00
co-authored by Claude Opus 5.5
parent 785f5793a7
commit 771e89c983
6 changed files with 1430 additions and 1 deletions
+97
View File
@@ -0,0 +1,97 @@
"""M01 수식 탭 서버 몫(PLAN 796-1 · 요구 655 · 657) — 수식 나무 읽기 · 고침 미리 셈 · 저장.
모델 = `scripts/master_equation.py`(나무 · 변수 표) · `master_equation_edit.py`(고침 · 검사) ·
키보드 글 번역 · 상한 = M02 단가산출 식 칸과 한 벌(`M02_Sangeun_Formula` 불러 씀 · 고치지 않음) ·
셈 = 엔진 한 벌(③ 서버 단독 · 브라우저 값 받아 적지 않음) · 저장 = 기존 M01 `/save`(개정확인) ·
M02 사본(795-1)은 `export_row` · `edit_row` 를 사본 줄로 부름 · 시험 `test_796_1_formula_model.py`.
"""
from __future__ import annotations
from typing import Any
from M01_MasterData import M01_MasterData_Store as store
from M01_MasterData import M01_MasterData_Store_Make as make
_ERR = (store.cm.mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError)
import master_equation as meq # noqa: E402 — `store` 가 scripts 를 길에 둔 뒤
import master_equation_edit as me # noqa: E402
def _fx() -> Any:
from M02_MasterTemplete import M02_Sangeun_Formula as fx # M02 가 M01 을 부름 — 늦게
return fx
def words(text: str, row: dict) -> tuple[str, list[str]]:
"""층 번역 — 상한(길이 · 괄호) · `[노임 …]` · `[별칭]` → 엔진 식."""
fx = _fx()
if bad := fx.limits(text):
return text, bad
return fx.to_engine(text, row)
def _given(inputs: dict | None) -> dict:
return {k: store._dec(v) for k, v in (inputs or {}).items()}
def _typed(eq: dict, row: dict) -> dict:
"""줄마다 `입력글` = 키보드 한 줄 식(이름 꼴 · 변수 글) — 식 칸 처음 글."""
fx = _fx()
for line in eq.get("줄") or []:
if line.get("식"):
line["입력글"] = me.keyboard_of(fx.to_name(str(line["식"]), row), row)
return eq
def export_row(whole, row: dict, inputs: dict | None = None) -> dict:
"""로직 줄(정본 · 사본) → 수식 — 마스터에 그 줄을 얹어 셈."""
eq = meq.export(me.swap(whole, row), str(row.get("키")), _given(inputs))
return _typed(eq, row)
def edit_row(whole, row: dict, edits: list) -> tuple[dict, list[str]]:
"""고침 → (새 줄, 까닭) — 까닭 있으면 저장 안 됨(M01 · M02 사본 같은 함수)."""
new, why, _ = me.apply(whole, row, edits, words)
return new, why
def read(key: str, inputs: dict | None = None) -> dict:
whole = store.loaded()[1]
try:
return {"ok": True, "수식": export_row(whole, whole.logic(key), inputs)}
except _ERR as e:
return {"ok": False, "reason": str(e) or type(e).__name__}
def preview(key: str, edits: list, inputs: dict | None = None) -> dict:
"""고침 미리 셈(저장 없음) — {되나, 까닭, 줄, 수식, 전, 후}."""
whole = store.loaded()[1]
row = whole.logic(key)
new, why = edit_row(whole, row, edits)
if why:
return {"되나": False, "까닭": why}
try:
before = meq.export(whole, key, _given(inputs))["합계"]
eq = export_row(whole, new, inputs)
except _ERR as e:
return {"되나": False, "까닭": [f"셈 막힘 — {e}"]}
return {"되나": True, "까닭": [], "줄": new, "수식": eq, "전": before, "후": eq["합계"]}
def save(
key: str, edits: list, inputs: dict | None = None, version: str = "", confirmed: bool = False
) -> dict:
"""고침 저장 — 검사 통과 · 기존 M01 `/save`(정본 = 개정확인 · 판 다르면 409)."""
got = store.logic(key)
if version and version != got["version"]:
raise store.StoreError(409, {"stale": [got["file"]]})
new, why = edit_row(store.loaded()[1], got["logic"], edits)
if why:
raise store.StoreError(400, {"까닭": why})
change = {"op": "edit", "key": key, "row": new}
part = {"file": got["file"], "version": got["version"], "changes": [change]}
out = make.save([part], confirmed)
return {**out, **read(key, inputs)}
+27
View File
@@ -12,6 +12,7 @@ from typing import Any, Literal
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from M01_MasterData import M01_MasterData_Equation as equation
from M01_MasterData import M01_MasterData_Sheet as line_sheet
from M01_MasterData import M01_MasterData_Store as store
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
@@ -344,6 +345,32 @@ def post_logic_sheet_check(body: SheetCheck) -> dict:
return _call(line_sheet.check, body.row, body.갈래, body.차례, body.식)
class EquationBody(BaseModel):
key: str
inputs: dict[str, Any] = {}
고침: list[dict[str, Any]] = []
version: str = ""
개정확인: bool = False
@router.post("/logic/equation")
def post_logic_equation(body: EquationBody) -> dict:
"""수식 탭(796-1) — 줄마다 이름식 · 값식 나무 · 풀이 · 변수표 · 합계(셈 · 쓰기 없음)."""
return _call(equation.read, body.key, body.inputs)
@router.post("/logic/equation/preview")
def post_logic_equation_preview(body: EquationBody) -> dict:
"""수식 고침 미리 셈 — {되나, 까닭, 줄, 수식, 전, 후} · 저장 안 함."""
return _call(equation.preview, body.key, body.고침, body.inputs)
@router.post("/logic/equation/save")
def post_logic_equation_save(body: EquationBody) -> dict:
"""수식 고침 저장 — 검사 통과 뒤 `/save` 길(정본 = 개정확인 · 판 다르면 409)."""
return _call(equation.save, body.key, body.고침, body.inputs, body.version, body.개정확인)
# ── 일위대가 조합 — 조합 길은 M02 층 길(`/api/m02/layers/{layer}/combos` · PLAN 27-2a) ──
@router.get("/logic/combos")
def get_logic_combos(key: str) -> dict:
+2 -1
View File
@@ -145,7 +145,7 @@
```
- 장 파일 줄은 갈래를 스스로 지님 — `구분` = 원문 + 부문(「산림품셈」 · 「건설품셈 공통」 · 「건설품셈 토목」 …) · `상세구분` = 「NN장 장 제목」(파일 이름 그대로 · 「13장 구조물」). 파일 머리의 `부문` · `차례` 는 목록 차례로 남김.
- 열 차례 — 표는 키 · 원문번호 · 구분 · 상세구분 · 이름 · 기준 · 출처 · 비고 · 용도 · 조건 · 범위규칙 · 값칸 · 줄 · 주 · `그룹`(소요량 파일 안의 계수 표 표시 · 맨 뒤) · 로직은 키 · 원문번호 · 구분 · 상세구분 · 이름 · 결과단위 · 출처 · 개정 · 소유 · 비고 · 입력 · 중간 · 호표 · 덧줄 · 결과 · 끝수 · 단계 · 끝전 · 조정.
- 열 차례 — 표는 키 · 원문번호 · 구분 · 상세구분 · 이름 · 기준 · 출처 · 비고 · 용도 · 조건 · 범위규칙 · 값칸 · 줄 · 주 · `그룹`(소요량 파일 안의 계수 표 표시 · 맨 뒤) · 로직은 키 · 원문번호 · 구분 · 상세구분 · 이름 · 결과단위 · 출처 · 개정 · 소유 · 비고 · 입력 · 중간 · 호표 · 덧줄 · 결과 · 끝수 · 단계 · 끝전 · 조정 · 변수표.
- 갈래 목록은 서버가 장 파일에서 모아 줌(`GET /subs?group=…` · 장 차례대로) — 화면이 이름을 품지 않음.
- 건설품셈 부문은 `구분` 칸 — 「건설품셈 건축」 · 원문번호는 절 번호만(「1-1-1」).
- 조건 종류 — `수`(같은 값) · `고르기`(글자) · `범위`(줄에 `[아래, 위]` · 끝 열림은 `null` · 포함 여부는 `범위규칙` 에 「초과·이하」처럼).
@@ -217,6 +217,7 @@
- `끝전`(로직 칸) = `비목버림`(없음과 같음 · 비목마다 원 버림) | `큰나머지`(계 원 버림 · 모자란 원을 원 미만 큰 비목부터 1 원씩 · 같으면 경 › 재 › 노).
- `조정`(로직 칸) = `{배율: {비목: 수}, 옮김: 비목}`(둘 다 골라 씀) — 끝전 뒤 비목 × 배율 → 계 전부를 `옮김` 비목으로(「경비로 적용」) → 원 버림.
- `표시`(중간 칸) = 0~6 — 값을 그 자리 반올림해 뒤 줄에 넘김(STmate `X = 식 =`).
- `변수표`(로직 칸) = 수식 탭 변수 꼴 `{열쇠: {이름?, 기호?, 위?, 아래?}}` — 열쇠 = 입력 · 중간 이름(식이 부름 · 안 바뀜) · 바꾼 것만 · 셈에 안 씀 · 엔진 `scripts/master_equation.py`.
- 날 셈(`master_formula.run`)은 번지 · 단계 · 합밖 · 조정 · 표시까지 · 줄 자리 · 끝전은 끝수 셈(`master_cut.engine`).
- 계약단가 · 낙찰률 · 운전사 노임 5/24 는 이 의미 밖(B09 · 기계).
재료 고르기 — 호표 재료 줄의 `요소` 를 키 대신 조건 묶음으로 적음:
@@ -0,0 +1,469 @@
# -*- coding: utf-8 -*-
"""수식 나무(PLAN 796-1 · 요구 655 · 657 · 요구서_655 0 장) — 로직 줄 → 그림용 나무 · 변수 표.
수식 = 계산 로직 그 자체 — 나무는 엔진 식 나무(`master_formula.parse`)를 그대로 옮긴 것이고
`master_equation_edit.engine_text` 가 나무를 엔진 식 글로 되돌림(왕복 = 같은 금액).
줄마다 `이름식`(변수 = 이름 · 기호 · 첨자) · `값식`(같은 나무의 값 꼴) · `값` · 오른쪽 `풀이`(입력 ·
찾기 · 요소 값 + 표 · 행) · 하위 로직 부르기 = `블록`(접힘 · 노재경 소계 보이기만 · 금액 그대로).
나무 마디(`꼴`):
수 {글, 값} · 글 {글} · 변수 {열쇠, 이름, 기호, 위, 아래, 단위, 갈래, 값, 글, 식?}
(갈래 = 입력 · 중간 · 찾기 · 요소 · 합 — 찾기 · 요소 `식` = 엔진 원문 · 찾기 `표` · `행` · `칸`)
연산 {기호, 좌, 우} · 분수 {위, 아래, 사슬?} · 괄호 {안} · 퍼센트 {안} · 음 {안}
조건 {조건, 참, 거짓, 걸림(1 참 · 2 거짓 · 0 모름), 글} · 함수 {이름, 인자}
블록 {열쇠, 이름, 결과단위, 칸, 값, 묶음, 식, 접힘, 나무}
변수 표 = 입력 · 중간마다 {열쇠, 이름, 기호, 위, 아래, 단위, 갈래, 기본값, 설명} — 열쇠 = 엔진 이름
(안 바뀜) · 이름 · 기호 · 첨자 고침은 로직 줄 새 칸 `변수표` {열쇠: {이름?, 기호?, 위?, 아래?}}
(바꾼 것만 · 셈에 안 씀) · 기호 초기값 = 품셈 원문 「여기서 q : 버킷용량(㎥)」 후보(`symbols`).
셈 = `master_formula.run` · 끝수 `master_cut.engine` 한 벌 · eval 없음.
"""
from __future__ import annotations
import functools
import re
from decimal import Decimal
from pathlib import Path
import master_auto as ma
import master_cut as mcut
import master_formula as mf
from common_util.common_util_money import fmt
FIELD = "변수표" # 로직 줄 새 칸(`_틀.md` 2 장 열 차례 끝)
LOOK = ("이름", "기호", "위", "아래") # 변수표에 두는 꼴 칸
SHOW = 4 # 값 칸 보이는 자리 — 고친 중간 줄 `표시`(보이는 자리에서 잘라 셈 · 요구 658)
DEEP = 1 # 블록 안 나무를 펼칠 깊이(그 아래는 값 · 묶음만)
PREC = {"또는": 1, "그리고": 2, "==": 3, "!=": 3, "<": 3, "<=": 3, ">": 3, ">=": 3}
PREC |= {"+": 4, "-": 4, "*": 5, "/": 5}
SIGN = {"*": "×", "/": "÷", "-": "−"}
_ERR = (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError, IndexError)
_BOOK = Path(mf.mk.MASTER).parents[1] / "resources/knowledge/original/원가계산"
_DEF = re.compile(r"^(?:여기서\s*[,:]?\s*)?([A-Za-z][A-Za-z0-9′'ˊ]{0,5})\s*[::]\s*(.+?)\s*$")
# ── 변수 표 ────────────────────────────────────────────────────────────
def _norm(text: str) -> str:
"""원문 풀이 · 변수 이름 맞대기 — 괄호 · 빈칸 · 「1일 · 1회」 머리 뺌 · 「횟수」 = 「회수」."""
text = re.sub(r"\([^)]*\)|([^)]*)|\s", "", str(text or ""))
return re.sub(r"^(1일|1회|일|회당)", "", text).replace("횟수", "회수")
@functools.lru_cache(maxsize=256)
def _section(book: str, number: str) -> tuple[tuple[str, str], ...]:
"""원문 절의 「여기서 기호 : 풀이」 목록 — 절 머리(`### 8-2-3 …`) 아래 다음 머리까지."""
root = _BOOK / ("산림_표준품셈/본문" if book == "산림품셈" else "건설공사_표준품셈/본문")
head = re.compile(rf"^#+\s*{re.escape(number)}\s")
out: list[tuple[str, str]] = []
for path in sorted(root.rglob("*.md")) if number else []:
try:
text = path.read_text(encoding="utf-8")
except OSError:
continue
if number not in text:
continue
inside, block = False, False
for line in text.splitlines():
if line.startswith("#"):
if inside:
break
inside = bool(head.match(line))
continue
if not inside:
continue
line = re.sub(r"<!--.*?-->", "", line).strip()
m = _DEF.match(line) if (block or line.startswith("여기서")) else None
block = bool(m) or (block and not line)
if m:
out.append((m.group(1), m.group(2)))
if out:
break
return tuple(out)
def symbols(row: dict) -> dict[str, str]:
"""{열쇠: 원문 기호 후보} — 이름 · 설명 「(q)」 · 풀이 맞대기가 하나일 때만(796-3 확정 전)."""
book = "산림품셈" if str(row.get("구분") or "").startswith("산림") else "건설품셈"
defs = _section(book, str(row.get("원문번호") or ""))
keys = {str(s.get("이름")) for k in ("입력", "중간") for s in row.get(k) or []}
got: dict[str, list[tuple[str, str]]] = {} # 기호 → [(열쇠, 갈래)]
for kind in ("입력", "중간"):
for spec in row.get(kind) or []:
key = str(spec.get("이름"))
if re.fullmatch(r"[A-Za-z][\w′']{0,5}", key):
continue # 이름이 이미 기호(K · E · f · cm)
said = re.findall(
rf"{re.escape(key)}\(([A-Za-z][\w′']{{0,5}})\)", str(spec.get("설명") or "")
)
hits = {s for s, d in defs if s in said} or {
s for s, d in defs if _norm(d) == _norm(key)
}
if len(hits) == 1 and (sign := hits.pop()) not in keys:
got.setdefault(sign, []).append((key, kind))
out = {}
for sign, owners in got.items(): # 한 기호는 한 변수 — 여럿이면 중간 하나일 때만
mids = [k for k, kind in owners if kind == "중간"]
one = owners[0][0] if len(owners) == 1 else mids[0] if len(mids) == 1 else None
if one:
out[one] = sign
if "결과" in row and defs and defs[0][0] not in keys and defs[0][0] not in out.values():
out["결과"] = defs[0][0] # 「여기서 Q : 시간당 작업량」 — 첫 풀이 = 결과
return out
def table(row: dict, master=None) -> list[dict]:
"""변수 표 — 입력 · 중간 차례 · 기본값 = 입력 `기본` › 자동 제안(`master_auto.defaults`)."""
saved = row.get(FIELD) if isinstance(row.get(FIELD), dict) else {}
auto = ma.defaults(master, row) if master is not None else {}
found = symbols(row)
out = []
for kind in ("입력", "중간"):
for spec in row.get(kind) or []:
key = str(spec.get("이름"))
look = saved.get(key) if isinstance(saved.get(key), dict) else {}
out.append(
{
"열쇠": key,
"이름": look.get("이름") or key,
"기호": look.get("기호") or found.get(key) or key,
"위": look.get("위") or "",
"아래": look.get("아래") or "",
"단위": spec.get("단위") or "",
"갈래": kind,
"기본값": spec.get("기본", auto.get(key)) if kind == "입력" else None,
"설명": spec.get("설명") or "",
**({"표시": spec["표시"]} if spec.get("표시") is not None else {}),
}
)
return out
def token(var: dict) -> str:
"""키보드 한 줄 식의 변수 글 — 기호(_아래)(^위) · 기호가 열쇠와 같고 첨자 없으면 열쇠."""
out = str(var.get("기호") or var["열쇠"])
if var.get("아래"):
out += f"_{var['아래']}"
if var.get("위"):
out += f"^{var['위']}"
return out
# ── 나무 ──────────────────────────────────────────────────────────────
class _Ctx:
def __init__(self, master, row: dict, env: dict, depth: int):
self.master, self.row, self.env, self.depth = master, row, env, depth
self.vars = {v["열쇠"]: v for v in table(row)}
self.used: dict[str, dict] = {} # 풀이 — 쓰인 입력 · 찾기 · 요소
def value(self, node):
try:
got = mf.evaluate(node, self.env, self.master, self.depth)
except _ERR:
return None
return got
def _num(value) -> dict:
if isinstance(value, Decimal):
return {"꼴": "수", "글": fmt(value), "값": value}
return {"꼴": "글", "글": "" if value is None else str(value)}
def _var(ctx: _Ctx, key: str, kind: str, value, **more) -> dict:
look = ctx.vars.get(key) or {"열쇠": key, "이름": key, "기호": key, "위": "", "아래": ""}
node = {
"꼴": "변수",
"열쇠": key,
**{k: look.get(k, "") for k in LOOK},
"단위": look.get("단위", more.pop("단위", "")),
"갈래": kind,
"값": value if not isinstance(value, dict) else None,
"글": "" if value is None or isinstance(value, dict) else fmt(value),
**more,
}
if kind in ("입력", "찾기", "요소"):
ctx.used.setdefault(more.get("식") or key, {k: v for k, v in node.items() if k != "꼴"})
return node
def _raw(node) -> str:
"""마디 → 엔진 식 글(찾기 · 요소 · 로직 부르기 원문 보관용)."""
import master_equation_edit as me # 서로 부름 — 늦게
return me.raw_text(node)
def _find(ctx: _Ctx, node, column: str) -> dict:
"""찾기(표, 조건).칸 → 찾기 변수 · 표 · 고른 행."""
raw = _raw(("field", node, column))
more = {"식": raw, "칸": column}
try:
ref = mf.fill(node[1], ctx.env)
table_row = ctx.master.get(ref)
conds = {k: mf.evaluate(v, ctx.env, ctx.master, ctx.depth) for k, v in node[2]}
more |= {"표": ref, "표이름": table_row.get("이름") or "", "행": conds}
more["단위"] = (table_row.get("값칸") or {}).get(column) or ""
except _ERR:
pass
name = f"{more.get('표이름') or node[1]}.{column}"
look = {"열쇠": raw, "이름": name, "기호": column, "위": "", "아래": ""}
ctx.vars.setdefault(raw, look)
return _var(ctx, raw, "찾기", ctx.value(("field", node, column)), **more)
def _block(ctx: _Ctx | None, node, column: str | None) -> dict:
"""로직 부르기 → 블록 — 묶음(노 · 재 · 경 · 계) · 결과 수 · 접힌 하위 나무(`DEEP` 까지)."""
raw = _raw(node if column is None else ("field", node, column))
out = {"꼴": "블록", "열쇠": node[1], "칸": column, "식": raw, "접힘": True}
if ctx is None:
return out
try:
ref = mf.fill(node[1], ctx.env)
sub = ctx.master.logic(ref)
given = {k: mf.evaluate(v, ctx.env, ctx.master, ctx.depth) for k, v in node[2]}
out |= {"열쇠": ref, "이름": sub.get("이름") or ref, "결과단위": sub.get("결과단위") or ""}
except _ERR:
return out
got = ctx.value(node)
if isinstance(got, dict):
out["묶음"] = got
got = got.get(column) if column else got.get("계")
elif column is not None:
got = None
out |= {"값": got, "글": fmt(got) if isinstance(got, Decimal) else ""}
if ctx.depth < DEEP:
try:
out["나무"] = export(ctx.master, ref, given, ctx.depth + 1)
except _ERR:
out["나무"] = None
return out
def _paren(child: dict, op: str, right: bool) -> dict:
"""연산 자식 괄호 — 낮은 세기 · 오른쪽 같은 세기(엔진은 왼쪽부터 묶음 — 나무 그대로)."""
if child.get("꼴") != "연산":
return child
mine, up = PREC[child["_op"]], PREC[op]
if mine < up or (mine == up and right):
return {"꼴": "괄호", "안": child}
return child
def build(node, ctx: _Ctx | None) -> dict:
"""엔진 식 마디 → 나무 마디(이름 꼴) · ctx 없으면 값 · 풀이 없이 꼴만."""
kind = node[0]
if kind == "num":
return _num(node[1])
if kind == "str":
return {"꼴": "글", "글": node[1], "따옴": True}
if kind == "name":
key = node[1]
if ctx is None:
return {"꼴": "변수", "열쇠": key, "갈래": "합"}
where = ctx.vars.get(key, {}).get("갈래") or "합"
return _var(ctx, key, where, ctx.env.get(key) if ctx else None)
if kind == "ref":
if ctx is None:
return {"꼴": "변수", "열쇠": node[1], "갈래": "요소", "식": node[1]}
return _elem(ctx, node[1])
if kind == "neg":
inner = build(node[1], ctx)
return {"꼴": "음", "안": {"꼴": "괄호", "안": inner} if inner["꼴"] == "연산" else inner}
if kind == "field" and node[1][0] == "find":
if ctx is None:
return {"꼴": "변수", "갈래": "찾기", "식": _raw(node), "열쇠": _raw(node)}
return _find(ctx, node[1], node[2])
if kind == "field" and node[1][0] == "logic":
return _block(ctx, node[1], node[2])
if kind == "logic":
return _block(ctx, node, None)
if kind in ("field", "find"): # 그 밖의 칸 꺼냄(요소 묶음 · 찾기 줄째) — 원문 그대로 변수
raw = _raw(node)
if ctx is None:
return {"꼴": "변수", "갈래": "요소", "식": raw, "열쇠": raw}
return _var(ctx, raw, "요소", ctx.value(node), 식=raw)
if kind == "call" and node[1] == "만약":
cond, yes, no = (build(x, ctx) for x in node[2])
hit = ctx.value(node[2][0]) if ctx else None
at = 0 if hit is None else 1 if hit else 2
return {"꼴": "조건", "조건": cond, "참": yes, "거짓": no, "걸림": at}
if kind == "call":
return {"꼴": "함수", "이름": node[1], "인자": [build(x, ctx) for x in node[2]]}
if kind == "bin":
return _bin(node, ctx)
raise mf.FormulaError(f"모르는 마디 {kind}")
def _elem(ctx: _Ctx, ref: str) -> dict:
import master_text as mt
value = ctx.value(("ref", ref))
name = mt._elem_name(ctx.master, ref, ctx.env)
look = {"열쇠": ref, "이름": name, "기호": name, "위": "", "아래": ""}
ctx.vars.setdefault(ref, look)
return _var(ctx, ref, "요소", value, 식=ref)
def _bin(node, ctx: _Ctx | None) -> dict:
_, op, left, right = node
if op == "/" and right == ("num", Decimal(100)): # 「… / 100」 = 퍼센트
inner = _paren(build(left, ctx), "*", False)
return {"꼴": "퍼센트", "안": inner}
if op == "/":
if left[0] == "bin" and left[1] == "/" and left[3] != ("num", Decimal(100)):
top, mid = build(left[2], ctx), build(left[3], ctx) # a/b/c → a/(b×c) 모양만
low = {"꼴": "연산", "기호": "×", "_op": "*", "좌": mid, "우": build(right, ctx)}
low["좌"], low["우"] = _paren(mid, "*", False), _paren(low["우"], "*", True)
return {"꼴": "분수", "위": top, "아래": low, "사슬": True}
return {"꼴": "분수", "위": build(left, ctx), "아래": build(right, ctx)}
a, b = build(left, ctx), build(right, ctx)
return {
"꼴": "연산",
"기호": SIGN.get(op, op),
"_op": op,
"좌": _paren(a, op, False),
"우": _paren(b, op, True),
}
def values(tree: dict) -> dict:
"""이름 꼴 나무 → 값 꼴 나무 — 변수 · 블록은 값 수로(열쇠 남김) · 조건은 걸린 쪽만."""
kind = tree.get("꼴")
if kind in ("변수", "블록"):
value = tree.get("값")
got = _num(value) if value is not None else {"꼴": "글", "글": "?"}
return {**got, "열쇠": tree.get("열쇠"), "갈래": tree.get("갈래") or "블록"}
if kind == "조건":
if tree.get("걸림"):
return values(tree["참" if tree["걸림"] == 1 else "거짓"])
return {**tree, **{k: values(tree[k]) for k in ("조건", "참", "거짓")}}
out = dict(tree)
for k in ("좌", "우", "위", "아래", "안"):
if k in tree:
out[k] = values(tree[k])
if "인자" in tree:
out["인자"] = [values(x) for x in tree["인자"]]
return out
def _clean(tree):
"""밖으로 내보낼 나무 — 안쪽 칸(`_op`) 뺌."""
if isinstance(tree, dict):
return {k: _clean(v) for k, v in tree.items() if k != "_op"}
if isinstance(tree, list):
return [_clean(x) for x in tree]
return tree
# ── 로직 한 줄 → 수식 ─────────────────────────────────────────────────
def given_of(master, row: dict, given: dict | None = None) -> dict:
"""셈 입력 — 자동값 · 기본 제안 · 시험입력 · 받은 값 차례로 덮음."""
out = ma.values(master, row)
out.update({k: v for k, v in ma.defaults(master, row).items() if v is not None})
out.update(row.get("시험입력") or {})
out.update(given or {})
names = {s["이름"] for s in row.get("입력") or []}
return {k: v for k, v in out.items() if k in names}
def _line(ctx: _Ctx, kind: str, key: str, text: str, value, **more) -> dict:
try:
tree = build(mf.parse(text), ctx)
except mf.FormulaError as e:
return {"갈래": kind, "열쇠": key, "식": text, "까닭": str(e), **more}
head = _var(ctx, key, kind if kind in ("중간",) else "합", value)
if kind == "결과":
head |= {"이름": "결과", "기호": symbols(ctx.row).get("결과") or "결과"}
return {
"갈래": kind,
"열쇠": key,
"식": text,
"결과": head,
"이름식": tree,
"값식": values(tree),
"값": value,
"글": fmt(value) if isinstance(value, Decimal) else "",
**more,
}
def export(master, key: str, given: dict | None = None, depth: int = 0) -> dict:
"""로직 키 → 수식 {열쇠, 이름, 결과단위, 줄, 풀이, 변수표, 합계 | 결과} — 셈은 엔진 한 벌."""
row = master.logic(key)
given = given_of(master, row, given) if depth == 0 else dict(given or {})
raw = mf.run(master, key, given, depth)
env = {k: v for k, v in (raw.get("중간") or {}).items()}
ctx = _Ctx(master, row, env, depth)
out_lines = []
for step in row.get("중간") or []:
name = step["이름"]
more = {"표시": step["표시"]} if step.get("표시") is not None else {}
out_lines.append(_line(ctx, "중간", name, step["식"], env.get(name), **more))
total: dict = {}
if "결과" in row:
out_lines.append(_line(ctx, "결과", "결과", row["결과"]["식"], raw["결과"]))
total = {"결과": raw["결과"]}
else:
cut = mcut.engine(master, key, raw) if depth == 0 else raw
lines = raw.get("줄") or []
for at, item in enumerate(row.get("호표") or []):
out_lines.append(_item(ctx, at, item, lines[at] if at < len(lines) else {}))
for at, extra in enumerate(row.get("덧줄") or []):
got = env.get(extra["이름"])
one = _line(ctx, "덧줄", extra["이름"], extra["식"], got, 차례=at)
out_lines.append(one | {"비목": extra.get("비목")})
total = {k: cut.get(k) for k in mf.BUNDLE}
return _clean(
{
"열쇠": row.get("키"),
"이름": row.get("이름") or "",
"결과단위": row.get("결과단위") or "",
"줄": out_lines,
"풀이": list(ctx.used.values()),
"변수표": table(row, master),
"합계": total,
}
)
def _item(ctx: _Ctx, at: int, item: dict, line: dict) -> dict:
"""호표 줄 = 「단가 × 수량식 = 금액」 · 로직 줄 단가 = 블록."""
name = str(item.get("이름") or "")
try:
qty = build(mf.parse(item["수량"]), ctx)
except mf.FormulaError as e:
return {"갈래": "호표", "차례": at, "열쇠": name, "식": item["수량"], "까닭": str(e)}
if item.get("종류") == "로직":
price = build(mf.parse(str(item["요소"])), ctx)
elif isinstance(item.get("요소"), str):
price = _elem(ctx, item["요소"])
price |= {"값": line.get("단가"), "글": fmt(line["단가"]) if line.get("단가") else ""}
else: # 재료 고르기 조건 — 단가는 엔진 고름 값
key, label = f"단가:{name}", f"{name}단가"
ctx.vars.setdefault(key, {"열쇠": key, "이름": label, "기호": label, "위": "", "아래": ""})
price = _var(ctx, key, "요소", line.get("단가"), 고르기=item["요소"])
tree = {
"꼴": "연산",
"기호": "×",
"_op": "*",
"좌": price,
"우": _paren(qty, "*", True),
}
head = {"꼴": "변수", "열쇠": name, "이름": name, "기호": name, "갈래": "합"}
head |= {"값": line.get("금액"), "단위": item.get("단위") or ""}
return {
"갈래": "호표",
"차례": at,
"열쇠": name,
"식": item["수량"],
"결과": head,
"이름식": tree,
"값식": values(tree),
"값": line.get("금액"),
"글": fmt(line["금액"]) if isinstance(line.get("금액"), Decimal) else "",
"수량": line.get("수량"),
"비목": line.get("비목") or {},
**({"까닭": line["까닭"]} if line.get("까닭") else {}),
}
@@ -0,0 +1,471 @@
# -*- coding: utf-8 -*-
"""수식 고침 → 로직(PLAN 796-1 · 요구 657) — 나무 · 키보드 한 줄 식 → 엔진 식 · 변수 추가 · 삭제 ·
이름 · 기호 · 첨자 바꿈 · 표시 자리 · 식 검사.
로직 줄(dict) 한 벌만 고침 — 저장은 부르는 쪽(M01 = `/save` · M02 사본 = 795-1 사본 길)이 함.
고침 요청 `고침` = 목록 · 차례대로:
{할일: 식, 자리: 중간:<열쇠> | 결과 | 호표:<n> | 덧줄:<n>, 식: 글 | 나무: 나무}
{할일: 더함, 갈래: 입력 | 중간, 열쇠, 이름?, 기호?, 위?, 아래?, 단위?, 설명?, 기본값?(입력),
식(중간), 뒤?(중간 열쇠 · "" = 맨 위 · 없으면 끝)}
{할일: 지움, 열쇠} — 쓰는 식이 있으면 막고 쓰는 곳 목록
{할일: 변수, 열쇠, 이름?, 기호?, 위?, 아래?, 단위?} — 식은 열쇠로 부르니 안 깨짐
{할일: 표시, 열쇠, 표시: 0~6 | null}
고친 중간 줄은 `표시` 없으면 `SHOW`(보이는 자리에서 잘라 셈 · 요구 658) — 고치지 않은 줄은 그대로.
키보드 한 줄 식 = 변수 글(기호_아래^위 · 이름 · 열쇠) · `× ÷ −` · 나머지는 엔진 식 말 그대로
(`[노임 …]` · `[별칭]` 은 M01 · M02 층이 `M02_Sangeun_Formula.to_engine` 으로 먼저 풂).
"""
from __future__ import annotations
import copy
import re
from decimal import Decimal, InvalidOperation
import master_equation as meq
import master_formula as mf
KEY_FORM = re.compile(r"^[^\W\d][\w]{0,19}$") # 열쇠 = 글자로 시작 · 20 자까지
SIGN_FORM = re.compile(r"^[^\W\d_]\w{0,9}$")
SUB_FORM = re.compile(r"^[\w′'*]{1,6}$")
RESERVED = {"찾기", "로직", "그리고", "또는", "계", "줄", "번지", "단계", *mf.FUNCS, *mf.COST_ITEMS}
_TOKEN = re.compile(r"[^\W\d][\w]*(?:\^[^\s()+\-*/×÷−,=<>\[\]]+)?")
_BARE = re.compile(r"^[^\W\d][\w]*$")
_OPS = {"×": "*", "÷": "/", "−": "-"}
# ── 엔진 마디 · 나무 → 엔진 식 글 ──────────────────────────────────────
def _q(word: str) -> str:
return word if _BARE.match(word) else f"'{word}'"
def raw_text(node) -> str:
"""엔진 식 마디 → 엔진 식 글(괄호는 세기대로만)."""
kind = node[0]
if kind == "num":
return mf._plain(node[1])
if kind == "str":
return f"'{node[1]}'"
if kind in ("name", "ref"):
return node[1]
if kind == "neg":
inner = raw_text(node[1])
return f"-({inner})" if node[1][0] == "bin" else f"-{inner}"
if kind == "field":
return f"{raw_text(node[1])}.{_q(node[2])}"
if kind in ("find", "logic"):
head = "찾기" if kind == "find" else "로직"
args = "".join(f", {_q(k)}={raw_text(v)}" for k, v in node[2])
return f"{head}({node[1]}{args})"
if kind == "call":
return f"{node[1]}({', '.join(raw_text(x) for x in node[2])})"
_, op, left, right = node
return (
f"{_wrap(raw_text(left), left, op, False)} {op} {_wrap(raw_text(right), right, op, True)}"
)
def _wrap(text: str, node, op: str, right: bool) -> str:
if node[0] != "bin":
return text
mine, up = meq.PREC[node[1]], meq.PREC[op]
return f"({text})" if mine < up or (mine == up and right) else text # 왼쪽 묶임 그대로
def engine_text(tree: dict) -> str:
"""나무(이름 꼴) → 엔진 식 글 — 변수 = 열쇠 · 찾기 · 요소 · 블록 = 원문 식 · 조건 = 만약 셋."""
kind = tree.get("꼴")
if kind == "수":
return mf._plain(Decimal(str(tree["값"])))
if kind == "글":
return f"'{tree['글']}'" if tree.get("따옴") else str(tree["글"])
if kind == "변수":
return str(tree.get("식") or tree["열쇠"])
if kind == "블록":
return str(tree["식"])
if kind == "괄호":
return f"({engine_text(tree['안'])})"
if kind == "음":
return f"-{_side(tree['안'], '*', True)}"
if kind == "퍼센트":
return f"{_side(tree['안'], '/', False)} / 100"
if kind == "분수":
low = tree["아래"]
if tree.get("사슬") and low.get("꼴") == "연산": # a/(b×c) 모양 = 원래 a / b / c
parts = [tree["위"], low["좌"], low["우"]]
first = _side(parts[0], "/", False)
return " / ".join([first, *(_side(p, "/", True) for p in parts[1:])])
return f"{_side(tree['위'], '/', False)} / {_side(low, '/', True)}"
if kind == "조건":
return f"만약({', '.join(engine_text(tree[k]) for k in ('조건', '참', '거짓'))})"
if kind == "함수":
return f"{tree['이름']}({', '.join(engine_text(x) for x in tree['인자'])})"
if kind == "연산":
op = _OPS.get(tree["기호"], tree["기호"])
return f"{_side(tree['좌'], op, False)} {op} {_side(tree['우'], op, True)}"
raise mf.FormulaError(f"모르는 나무 마디 「{kind}」")
def _strength(tree: dict) -> int | None:
kind = tree.get("꼴")
if kind == "연산":
return meq.PREC[_OPS.get(tree["기호"], tree["기호"])]
return meq.PREC["/"] if kind in ("분수", "퍼센트") else None
def _side(tree: dict, op: str, right: bool) -> str:
text, mine = engine_text(tree), _strength(tree)
if mine is None:
return text
up = meq.PREC[op]
return f"({text})" if mine < up or (mine == up and right) else text # 왼쪽 묶임 그대로
# ── 키보드 한 줄 식 ↔ 엔진 식 ─────────────────────────────────────────
def _names(row: dict) -> tuple[dict, dict]:
"""(변수 글 → 열쇠, 열쇠 → 변수 글) — 겹치는 글은 뺌(열쇠로 적어야 함)."""
rows = meq.table(row)
want: dict[str, set] = {}
for v in rows:
for word in {v["열쇠"], meq.token(v), v["이름"]}:
want.setdefault(word, set()).add(v["열쇠"])
to_key = {w: next(iter(ks)) for w, ks in want.items() if len(ks) == 1}
to_word = {v["열쇠"]: meq.token(v) for v in rows if to_key.get(meq.token(v)) == v["열쇠"]}
return to_key, to_word
def _swap_words(text: str, table: dict) -> str:
"""따옴표 · 대괄호 밖 변수 글만 바꿈 — 찾기 · 로직 인자 이름(「조건=」)은 그대로."""
out, at = [], 0
while at < len(text):
ch = text[at]
if ch in "'[":
end = text.find("'" if ch == "'" else "]", at + 1)
end = len(text) if end < 0 else end + 1
out.append(text[at:end])
at = end
continue
m = _TOKEN.match(text, at) if not (text[at - 1 : at].isalnum() and at) else None
if not m:
out.append(ch)
at += 1
continue
word, rest = m.group(), text[m.end() :].lstrip()
arg = rest.startswith("=") and not rest.startswith("==")
out.append(table.get(word, word) if not arg else word)
at = m.end()
return "".join(out)
def keyboard(text: str, row: dict) -> str:
"""키보드 한 줄 식 → 엔진 식 — 변수 글(첨자 `^'` 포함) → 열쇠 먼저 · 따옴표 밖 `× ÷ −`."""
bits = _swap_words(str(text), _names(row)[0]).split("'")
return "'".join(
"".join(_OPS.get(c, c) for c in b) if i % 2 == 0 else b for i, b in enumerate(bits)
)
def keyboard_of(text: str, row: dict) -> str:
"""엔진 · 이름 꼴 식 → 키보드 한 줄 식(열쇠 → 변수 글)."""
return _swap_words(str(text), _names(row)[1])
# ── 쓰는 곳 ───────────────────────────────────────────────────────────
def _spots(row: dict):
"""(자리 글, 자리 열쇠, 식 글) — 모든 식."""
for step in row.get("중간") or []:
yield f"중간 {step['이름']}", f"중간:{step['이름']}", step["식"]
if "결과" in row:
yield "결과", "결과", row["결과"]["식"]
for at, item in enumerate(row.get("호표") or []):
where = f"호표 {at + 1} {item.get('이름', '')}"
yield f"{where} 수량", f"호표:{at}", item["수량"]
elem = item.get("요소")
if isinstance(elem, dict):
yield f"{where} 요소", f"호표:{at}", " ".join(str(v) for v in elem.values())
else:
yield f"{where} 요소", f"호표:{at}", str(elem)
for at, extra in enumerate(row.get("덧줄") or []):
yield f"덧줄 {extra['이름']}", f"덧줄:{at}", extra["식"]
def _used(text: str) -> set[str]:
out = set(mf._HOLE.findall(text))
try:
for part in mf.walk(mf.parse(text)):
if part[0] == "name":
out.add(part[1])
except (mf.FormulaError, RecursionError):
pass
return out
def uses(row: dict, key: str) -> list[str]:
"""열쇠를 쓰는 식 자리(제 줄 빼고)."""
return [
where for where, spot, text in _spots(row) if spot != f"중간:{key}" and key in _used(text)
]
# ── 고침 ──────────────────────────────────────────────────────────────
def _all_keys(row: dict) -> set[str]:
return {str(x.get("이름")) for k in ("입력", "중간", "덧줄") for x in row.get(k) or []}
def _key_ok(row: dict, key: str) -> str | None:
if not KEY_FORM.match(key) or mf.mk.KEY.fullmatch(key) or key in RESERVED:
return f"열쇠 꼴 아님 「{key}」(글자로 시작 · 20 자까지 · 예약어 · 요소 키 안 됨)"
if key in _all_keys(row):
return f"같은 열쇠 「{key}」 가 이미 있음"
return None
def _text_of(ed: dict, row: dict, words) -> tuple[str, list[str]]:
"""고침 식 → 엔진 식 — 나무 · 키보드 글(`words` = 층 번역 `[노임 …]` · `[별칭]` · 상한)."""
if isinstance(ed.get("나무"), dict):
return engine_text(ed["나무"]), []
text = keyboard(str(ed.get("식") or "").strip(), row)
return words(text, row) if words and text else (text, [])
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 text:
why.append(f"{spot} · 식이 빔")
return
part, _, at = spot.partition(":")
if part == "중간":
step = next((s for s in row.get("중간") or [] if s["이름"] == at), None)
if step is None:
why.append(f"없는 자리 「{spot}」")
return
step["식"] = text
step.setdefault("표시", meq.SHOW)
elif part == "결과" and "결과" in row:
row["결과"]["식"] = text
elif part in ("호표", "덧줄") and at.isdigit() and int(at) < len(row.get(part) or []):
row[part][int(at)]["수량" if part == "호표" else "식"] = text
else:
why.append(f"없는 자리 「{spot}」")
return
spots.add(spot)
def _look(row: dict, key: str, ed: dict, why: list) -> None:
"""이름 · 기호 · 첨자 → 변수표 · 단위 → 입력 · 중간 칸."""
spec = next((s for k in ("입력", "중간") for s in row.get(k) or [] if s["이름"] == key), None)
if spec is None:
why.append(f"없는 변수 「{key}」")
return
look = dict((row.get(meq.FIELD) or {}).get(key) or {})
for field, form in (("이름", None), ("기호", SIGN_FORM), ("위", SUB_FORM), ("아래", SUB_FORM)):
if field not in ed:
continue
got = str(ed[field] or "").strip()
if got and form is not None and not form.match(got):
why.append(f"「{key}」 {field} 꼴 아님 「{got}」")
return
if field == "이름" and len(got) > 40:
why.append(f"「{key}」 이름 40 자까지")
return
if got and got != (key if field in ("이름", "기호") else ""):
look[field] = got
else:
look.pop(field, None)
if "단위" in ed:
unit = str(ed["단위"] or "").strip()
spec.pop("단위", None) if not unit else spec.__setitem__("단위", unit)
table = dict(row.get(meq.FIELD) or {})
table.pop(key, None) if not look else table.__setitem__(key, look)
if table:
row[meq.FIELD] = table
else:
row.pop(meq.FIELD, None)
seen: dict[str, str] = {}
for v in meq.table(row): # 변수 글 겹침 — 키보드 식이 갈리지 않게
for word in {meq.token(v), v["이름"]}:
if seen.get(word, v["열쇠"]) != v["열쇠"]:
why.append(f"「{word}」 이 「{seen[word]}」 · 「{v['열쇠']}」 둘에 겹침")
seen.setdefault(word, v["열쇠"])
def _add(row: dict, ed: dict, why: list, spots: set, words=None) -> None:
kind, key = ed.get("갈래"), str(ed.get("열쇠") or ed.get("이름") or "").strip()
if kind not in ("입력", "중간"):
why.append(f"더함 갈래 「{kind}」 — 입력 · 중간")
return
if bad := _key_ok(row, key):
why.append(bad)
return
if kind == "입력":
spec: dict = {"이름": key}
for field in ("설명", "단위"):
if ed.get(field):
spec[field] = str(ed[field])
if ed.get("기본값") not in (None, ""):
try:
spec["기본"] = Decimal(str(ed["기본값"]))
except InvalidOperation:
spec["기본"] = str(ed["기본값"])
row.setdefault("입력", []).append(spec)
else:
mids = row.setdefault("중간", [])
after = ed.get("뒤")
at = len(mids) if after is None else 0 if after == "" else None
if at is None:
at = next((i + 1 for i, s in enumerate(mids) if s["이름"] == after), None)
if at is None:
why.append(f"더함 자리 「{after}」 없는 중간")
return
mids.insert(
at, {"이름": key, "식": "0", **({"단위": ed["단위"]} if ed.get("단위") else {})}
)
job = {"자리": f"중간:{key}", **{k: ed[k] for k in ("식", "나무") if k in ed}}
_set_formula(row, job, why, spots, words)
look = {k: ed[k] for k in ("이름", "기호", "위", "아래") if ed.get(k)}
if look:
_look(row, key, look, why)
def _drop(row: dict, key: str, why: list) -> None:
users = uses(row, key)
if users:
why.append(f"「{key}」 를 쓰는 곳이 있어 못 지움 — {' · '.join(users)}")
return
for kind in ("입력", "중간"):
got = [s for s in row.get(kind) or [] if s["이름"] != key]
if len(got) != len(row.get(kind) or []):
row[kind] = got
break
else:
why.append(f"없는 변수 「{key}」")
return
for field in (meq.FIELD, "시험입력"):
if isinstance(row.get(field), dict) and key in row[field]:
row[field] = {k: v for k, v in row[field].items() if k != key}
if not row[field]:
row.pop(field)
def _show(row: dict, key: str, ed: dict, why: list) -> None:
step = next((s for s in row.get("중간") or [] if s["이름"] == key), None)
show = ed.get("표시")
if step is None:
why.append(f"표시는 중간 줄만 — 「{key}」")
elif show is None:
step.pop("표시", None)
elif not isinstance(show, int) or isinstance(show, bool) or not 0 <= show <= 6:
why.append(f"「{key}」 표시는 0~6 자리")
else:
step["표시"] = show
def apply(master, row: dict, edits: list, words=None) -> tuple[dict, list[str], set]:
"""고침 목록 → (새 로직 줄, 까닭, 식 고친 자리) — 까닭 있으면 저장 안 됨.
`words(글, 줄) → (엔진 식, 까닭)` = 층 번역(`M02_Sangeun_Formula` 상한 · to_engine)."""
new, why, spots = copy.deepcopy(row), [], set()
if not isinstance(edits, list) or not edits:
return new, ["고침 없음"], spots
for ed in edits:
ed = ed if isinstance(ed, dict) else {}
job, key = ed.get("할일"), str(ed.get("열쇠") or "")
try:
if job == "식":
_set_formula(new, ed, why, spots, words)
elif job == "더함":
_add(new, ed, why, spots, words)
elif job == "지움":
_drop(new, key, why)
elif job == "변수":
_look(new, key, ed, why)
elif job == "표시":
_show(new, key, ed, why)
else:
why.append(f"모르는 할일 「{job}」")
except (mf.FormulaError, KeyError, TypeError, ValueError) as e:
why.append(f"{job} · {e}")
if not why:
why += check(master, row, new, spots)
return new, why, spots
# ── 검사 ──────────────────────────────────────────────────────────────
def swap(master, row: dict):
"""그 로직만 고친 줄로 바꾼 마스터 사본(얕은 · 다른 줄은 같이 씀)."""
out = copy.copy(master)
key = str(row.get("키"))
out.index = dict(master.index)
out.index[key[:2]] = {**master.index.get(key[:2], {}), key: row}
return out
def _loop(row: dict, spots: set) -> list[str]:
"""고친 중간 줄 — 자기 · 아래 줄 · 순환."""
mids = [s["이름"] for s in row.get("중간") or []]
deps = {s["이름"]: _used(s["식"]) & set(mids) for s in row.get("중간") or []}
out = []
for i, name in enumerate(mids):
if f"중간:{name}" not in spots:
continue
for used in sorted(deps[name] - set(mids[:i])):
if used == name:
out.append(f"중간 {name} · 순환 — 「{name}」 이 자기 자신을 씀")
else:
path = _path(deps, used, name)
out.append(
f"중간 {name} · 순환 — {' → '.join([name, *path])}"
if path
else f"중간 {name} · 「{used}」 은 아래 줄 — 위로 옮길 것"
)
return out
def _path(deps: dict, start: str, goal: str) -> list[str] | None:
seen, todo = {start}, [[start]]
while todo:
path = todo.pop()
if path[-1] == goal:
return path
for nxt in sorted(deps.get(path[-1], ())):
if nxt not in seen:
seen.add(nxt)
todo.append([*path, nxt])
return None
def _zero(text: str) -> bool:
try:
node = mf.parse(text)
except (mf.FormulaError, RecursionError):
return False
return any(n[0] == "bin" and n[1] == "/" and n[3] == ("num", 0) for n in mf.walk(node))
def check(master, old: dict, new: dict, spots: set) -> list[str]:
"""새로 생긴 문제만 — 식 못 읽음 · 없는 이름 · 0 나눔 · 순환 · 엔진 검사 · 견본 셈 막힘."""
texts = {spot: text for _, spot, text in _spots(new)}
out = [f"{s} · 0 으로 나눔" for s in sorted(spots) if _zero(texts.get(s, ""))]
out += _loop(new, spots)
head = f"{new.get('키')} {new.get('원문번호')} "
base = set(mf.check_logic(master, old)[0])
out += [w.removeprefix(head) for w in mf.check_logic(master, new)[0] if w not in base]
if out:
return out
before, after = _trial(master, old), _trial(swap(master, new), new)
if after and after != before:
out.append(f"견본 셈 막힘 — {after}")
return out
def _trial(master, row: dict) -> str | None:
"""견본 입력 셈 — 막히면 까닭(빈 입력 빼고)."""
try:
mf.run(master, str(row.get("키")), meq.given_of(master, row))
except mf.EmptyInput:
return None
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
return str(e) or type(e).__name__
return None
@@ -0,0 +1,364 @@
"""796-1 수식 모델 지킴 — 엔진 로직 ↔ 수식 나무 양방향 · 변수 표 · 추가 · 삭제 · 이름 전파 · 검사.
① 예시 셋(요구서_655 · `_분석_655-B` 2 장) — GC001148 굴착기(블록 GC001147 Q = 81 · GC000267) ·
GC001140 지게(N · M · 42,917) · GC000211 거푸집(시공량 45 · 형틀목공 5/45 · 공구손료 1 %).
② 왕복 — M01 로직 식 전부: 엔진 식 → 나무 → 엔진 식 = 같은 식 나무 · 예시 셋 = 같은 금액.
③ 고침 — 식 고침 = 금액 바뀜 · 변수 더함 · 지움(쓰는 곳 막음) · 이름 · 기호 · 첨자 전파(식 그대로) ·
표시 자리 셈(고친 줄만) · 검사 까닭(없는 이름 · 괄호 · 순환 · 0 나눔 · 상한).
④ M01 길 — 읽기 · 미리 셈 · 저장(개정확인 · 임시 사본 폴더 · 정본 안 건드림).
변수표 칸 셈 값 바뀜 0 · M01 1,519 지문은 `test_791_1_engine_semantics.py`.
"""
from __future__ import annotations
import copy
import importlib
import json
import shutil
from decimal import Decimal as D
from functools import lru_cache
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from M01_MasterData import M01_MasterData_Equation as eq_m01
from M01_MasterData import M01_MasterData_Router as router_module
from M01_MasterData import M01_MasterData_Store as store
mf = importlib.import_module("master_formula")
mcut = importlib.import_module("master_cut")
meq = importlib.import_module("master_equation")
me = importlib.import_module("master_equation_edit")
revise = importlib.import_module("M01_MasterData.M01_MasterData_Store_Revise")
REAL = store.FOLDER
EXCAVATOR, AFRAME, FORM = "GC001148", "GC001140", "GC000211"
GIVEN = {AFRAME: {"운반회수": 0}, FORM: {"유형": "보통", "높이감률": 0}, EXCAVATOR: {}}
@lru_cache(maxsize=1)
def whole():
return store.loaded()[1]
def export(key: str, row: dict | None = None) -> dict:
w = whole()
return meq.export(me.swap(w, row) if row else w, key, GIVEN[key])
def line(eq: dict, key: str, kind: str = "중간") -> dict:
return next(x for x in eq["줄"] if x["열쇠"] == key and x["갈래"] == kind)
def raw(key: str, row: dict | None = None) -> dict:
w = me.swap(whole(), row) if row else whole()
r = row or w.logic(key)
return mf.run(w, key, meq.given_of(w, r, GIVEN[key]))
def vars_in(tree) -> list[dict]:
if isinstance(tree, dict):
own = [tree] if tree.get("꼴") == "변수" else []
return own + [v for x in tree.values() for v in vars_in(x)]
if isinstance(tree, list):
return [v for x in tree for v in vars_in(x)]
return []
def edit(key: str, edits: list) -> tuple[dict, list[str]]:
return eq_m01.edit_row(whole(), whole().logic(key), edits)
# ── ① 예시 셋 ─────────────────────────────────────────────────────────
def test_굴착기_블록_Q_81_노재경():
eq = export(EXCAVATOR)
block = line(eq, "작업량")["이름식"]
assert block["꼴"] == "블록" and block["열쇠"] == "GC001147" and block["값"] == D("81.000")
sub = block["나무"]
q = line(sub, "결과", "결과")
assert q["결과"]["기호"] == "Q" and q["값"] == D("81.000")
frac = q["이름식"]
assert frac["꼴"] == "분수" and frac["아래"]["열쇠"] == "cm"
assert [v["기호"] for v in vars_in(frac["위"])] == ["q", "K", "f", "E"]
nums = [x.get("글") for x in vars_in(q["값식"])] or []
shown = json.dumps(q["값식"], default=str, ensure_ascii=False)
assert all(f'"글": "{n}"' in shown for n in ("3,600", "1", "0.9", "0.7", "0.75", "21"))
assert nums == [] # 값 꼴엔 변수 없음
item = line(eq, "굴착기(무한궤도)", "호표")
price = item["이름식"]["좌"]
assert price["꼴"] == "블록" and price["열쇠"] == "GC000267"
assert set(price["묶음"]) == {"노무비", "재료비", "경비", "계"}
assert item["이름식"]["우"]["꼴"] == "분수" # 1 / 작업량
cut = mcut.engine(whole(), EXCAVATOR, raw(EXCAVATOR))
assert eq["합계"] == {k: cut[k] for k in mf.BUNDLE}
assert eq["합계"]["계"] == D("641.0")
def test_지게_N_M_42917():
eq = export(AFRAME)
n = line(eq, "일운반회수")
assert n["결과"]["기호"] == "N" and n["글"] == "92.2131"
assert n["이름식"]["꼴"] == "조건" and n["이름식"]["걸림"] == 2 # 운반회수 0 → 식
frac = n["이름식"]["거짓"]
assert frac["꼴"] == "분수"
assert [v["기호"] for v in vars_in(frac["위"])] == ["V", "작업시간"]
m = line(eq, "보통인부", "호표")
qty = m["이름식"]["우"]
assert qty["꼴"] == "분수" and qty.get("사슬") # 무게 / 운반량 / N → 무게 / (운반량 × N)
assert [v["기호"] for v in vars_in(qty["아래"])] == ["운반량", "N"]
assert m["수량"].quantize(D("0.0001")) == D("0.2494")
assert eq["합계"]["노무비"] == D("42917.0")
signs = {v["열쇠"]: v["기호"] for v in eq["변수표"]}
assert signs.items() >= {"운반거리": "L", "적재적하시간": "t", "평균왕복속도": "V"}.items()
assert signs["일운반회수"] == "N" and signs["운반회수"] == "운반회수" # 한 기호 = 한 변수
table = next(p for p in eq["풀이"] if p.get("표") == "CC000343")
assert table["행"] == {"종류": "석재류", "운반로": "불량"}
assert {p["갈래"] for p in eq["풀이"]} >= {"입력", "찾기", "요소"}
def test_거푸집_시공량_형틀목공_공구손료():
eq = export(FORM)
s = line(eq, "시공량")
assert s["값"] == 45 and "퍼센트" in json.dumps(s["이름식"], default=str, ensure_ascii=False)
w = line(eq, "형틀목공")
assert w["이름식"]["꼴"] == "분수" and w["글"] == "0.1111"
tool = line(eq, "공구손료 및 경장비", "덧줄")
assert tool["이름식"]["꼴"] == "퍼센트" and tool["글"] == "382.908"
def test_기호_후보_원문():
w = whole()
assert meq.symbols(w.logic("GC001147")) == {"버킷용량": "q", "결과": "Q"}
got = meq.symbols(w.logic(AFRAME))
assert got == {"운반거리": "L", "실작업시간": "T", "적재적하시간": "t", "평균왕복속도": "V"} | {
"일운반회수": "N"
}
# ── ② 왕복 ────────────────────────────────────────────────────────────
def _texts(row: dict):
for step in row.get("중간") or []:
yield step["식"]
if "결과" in row:
yield row["결과"]["식"]
for item in row.get("호표") or []:
yield item["수량"]
if isinstance(item.get("요소"), str):
yield item["요소"]
for extra in row.get("덧줄") or []:
yield extra["식"]
def test_왕복_M01_식_전부_같은_나무():
files, _ = store.loaded()
count, bad = 0, []
for data in files.values():
if data.get("그룹") != "로직":
continue
for row in data.get("줄") or []:
for text in _texts(row):
try:
node = mf.parse(text)
except mf.FormulaError:
continue
back = me.engine_text(meq.build(node, None))
count += 1
if mf.parse(back) != node or mf.parse(me.raw_text(node)) != node:
bad.append((row["키"], text, back))
assert count > 9000 and not bad, bad[:5]
@pytest.mark.parametrize("key", [EXCAVATOR, AFRAME, FORM, "GC001147"])
def test_왕복_예시_같은_금액(key):
GIVEN.setdefault("GC001147", {})
w = whole()
eq = meq.export(w, key, GIVEN[key])
row = copy.deepcopy(w.logic(key))
for one in eq["줄"]:
tree = one["이름식"]["우"] if one["갈래"] == "호표" else one["이름식"]
text = me.engine_text(tree)
if one["갈래"] == "중간":
next(s for s in row["중간"] if s["이름"] == one["열쇠"])["식"] = text
elif one["갈래"] == "결과":
row["결과"]["식"] = text
else:
row[one["갈래"]][one.get("차례", 0)]["수량" if one["갈래"] == "호표" else "식"] = text
before, after = raw(key), raw(key, row)
pick = ("결과",) if "결과" in row else mf.BUNDLE
assert {k: after[k] for k in pick} == {k: before[k] for k in pick}
def test_변수표_칸은_셈_안_바꿈():
row = dict(whole().logic(AFRAME))
row[meq.FIELD] = {"일운반회수": {"기호": "n", "아래": "d", "이름": "하루 운반 횟수"}}
assert raw(AFRAME, row)["계"] == raw(AFRAME)["계"]
assert revise.row_print(row) == revise.row_print(whole().logic(AFRAME)) # M01 개정 배지 안 뜸
# ── ③ 고침 ────────────────────────────────────────────────────────────
def test_식_고침_금액_바뀜_표시_자리():
new, why = edit(AFRAME, [{"할일": "식", "자리": "중간:환산거리", "식": "환산계수 × L × 2"}])
assert not why
step = next(s for s in new["중간"] if s["이름"] == "환산거리")
assert step["식"] == "환산계수 * 운반거리 * 2" and step["표시"] == meq.SHOW
others = [s for s in new["중간"] if s["이름"] != "환산거리"]
assert all("표시" not in s for s in others) # 고친 줄만
eq = export(AFRAME, new)
assert line(eq, "환산거리")["값"] == 96
assert eq["합계"]["노무비"] > export(AFRAME)["합계"]["노무비"]
def test_표시_자리에서_잘라_셈():
old = line(export(AFRAME), "일운반회수")["식"]
new, why = edit(AFRAME, [{"할일": "식", "자리": "중간:일운반회수", "식": old}])
assert not why and new["중간"][-1]["표시"] == 4
got = raw(AFRAME, new)
assert got["중간"]["일운반회수"] == D("92.2131")
assert got["노무비"] == D(575) / D(25) / D("92.2131") * D(172068)
assert got["노무비"] != raw(AFRAME)["노무비"] # 보이는 자리 셈 = 금액 바뀜(요구 658)
def test_변수_더함_입력_중간_식에서_부름():
new, why = edit(
AFRAME,
[
{
"할일": "더함",
"갈래": "입력",
"열쇠": "할증",
"기호": "k",
"아래": "s",
"기본값": "1.1",
},
{"할일": "더함", "갈래": "중간", "열쇠": "할증무게", "식": "무게 × k_s", "뒤": "무게"},
{"할일": "식", "자리": "호표:0", "식": "할증무게 ÷ 운반량 ÷ N"},
],
)
assert not why, why
assert new["입력"][-1] == {"이름": "할증", "기본": D("1.1")}
mids = [s["이름"] for s in new["중간"]]
assert mids[:2] == ["무게", "할증무게"] and new["중간"][1]["식"] == "무게 * 할증"
assert new["호표"][0]["수량"] == "할증무게 / 운반량 / 일운반회수"
eq = export(AFRAME, new)
assert line(eq, "할증무게")["값"] == D("632.5")
base = export(AFRAME)["합계"]["노무비"]
assert abs(eq["합계"]["노무비"] - base * D("1.1")) <= 1
def test_변수_지움_쓰는_곳_막음():
new, why = edit(AFRAME, [{"할일": "지움", "열쇠": "무게"}])
assert why and "호표 1 보통인부 수량" in why[0]
new, why = edit(AFRAME, [{"할일": "지움", "열쇠": "운반회수"}])
assert why and "중간 일운반회수" in why[0]
new, why = edit(
AFRAME,
[
{"할일": "더함", "갈래": "중간", "열쇠": "임시", "식": "무게 × 2"},
{"할일": "지움", "열쇠": "임시"},
],
)
assert not why and "임시" not in {s["이름"] for s in new["중간"]}
assert new["중간"] == whole().logic(AFRAME)["중간"]
def test_이름_기호_첨자_바꿈_전파():
new, why = edit(
AFRAME,
[
{
"할일": "변수",
"열쇠": "일운반회수",
"기호": "n",
"아래": "d",
"이름": "하루 운반 횟수",
}
],
)
assert not why
assert new["호표"] == whole().logic(AFRAME)["호표"] # 식은 열쇠 — 그대로
assert new["중간"] == whole().logic(AFRAME)["중간"]
eq = export(AFRAME, new)
used = [v for v in vars_in(line(eq, "보통인부", "호표")["이름식"]) if v["열쇠"] == "일운반회수"]
assert used and used[0]["기호"] == "n" and used[0]["아래"] == "d"
assert used[0]["이름"] == "하루 운반 횟수"
assert eq["합계"] == export(AFRAME)["합계"]
typed = eq_m01.export_row(whole(), new, GIVEN[AFRAME])
assert "n_d" in line(typed, "보통인부", "호표")["입력글"]
again, why = eq_m01.edit_row(
whole(), new, [{"할일": "식", "자리": "호표:0", "식": "무게 ÷ 운반량 ÷ n_d"}]
)
assert not why and again["호표"][0]["수량"] == "무게 / 운반량 / 일운반회수"
_, why = edit(AFRAME, [{"할일": "변수", "열쇠": "운반량", "기호": "N"}]) # 겹침
assert why and "겹침" in why[0]
def test_검사_까닭():
cases = {
"없는 이름": ("중간:환산거리", "환산계수 × 없는값", "모르는 이름"),
"괄호": ("중간:환산거리", "(환산계수 × L", "없음"),
"아래 줄": ("중간:무게", "일운반회수 × 2", "아래 줄"),
"순환": ("중간:무게", "무게 + 1", "자기 자신"),
"0 나눔": ("중간:환산거리", "환산계수 / 0", "0 으로 나눔"),
"길이": ("중간:환산거리", "1 + " * 80 + "1", "자를 넘음"),
"깊이": ("중간:환산거리", "(" * 13 + "1" + ")" * 13, "겹을 넘음"),
}
for name, (spot, text, want) in cases.items():
_, why = edit(AFRAME, [{"할일": "식", "자리": spot, "식": text}])
assert why and any(want in w for w in why), (name, why)
_, why = edit(AFRAME, [{"할일": "더함", "갈래": "입력", "열쇠": "무게"}])
assert why and "같은 열쇠" in why[0]
_, why = edit(AFRAME, [{"할일": "더함", "갈래": "입력", "열쇠": "LB000001"}])
assert why and "꼴 아님" in why[0]
_, why = edit(AFRAME, [{"할일": "표시", "열쇠": "무게", "표시": 9}])
assert why
# ── ④ M01 길 ──────────────────────────────────────────────────────────
@pytest.fixture
def client(tmp_path: Path, monkeypatch) -> TestClient:
for path in REAL.glob("*.json"):
if not path.name.startswith("_") or path.name == store.mk.BOOK.name:
shutil.copy(path, tmp_path / path.name)
monkeypatch.setattr(store, "FOLDER", tmp_path)
app = FastAPI()
app.include_router(router_module.router)
return TestClient(app)
def test_M01_길_읽기_미리셈_저장(client: TestClient):
file = "로직_건설품셈_01장_적용기준.json"
real = (REAL / file).read_bytes()
body = {"key": AFRAME, "inputs": {"운반회수": 0}}
got = client.post("/api/m01/logic/equation", json=body).json()
assert got["ok"] and got["수식"]["합계"]["노무비"] == 42917
edits = [
{"할일": "식", "자리": "중간:환산거리", "식": "환산계수 × L × 2"},
{"할일": "변수", "열쇠": "일운반회수", "기호": "n"},
]
pre = client.post("/api/m01/logic/equation/preview", json=body | {"고침": edits}).json()
assert pre["되나"] and pre["후"]["노무비"] > pre["전"]["노무비"]
assert (store.FOLDER / file).read_bytes() == real # 미리 셈 = 쓰기 없음
bad = client.post(
"/api/m01/logic/equation/preview", json=body | {"고침": [edits[0] | {"식": "x)"}]}
)
assert not bad.json()["되나"]
res = client.post("/api/m01/logic/equation/save", json=body | {"고침": edits})
assert res.status_code == 409 # 정본 = 개정 확인
res = client.post("/api/m01/logic/equation/save", json=body | {"고침": edits, "개정확인": True})
assert res.status_code == 200, res.text
assert res.json()["수식"]["합계"]["노무비"] == pre["후"]["노무비"]
saved = json.loads((store.FOLDER / file).read_text(encoding="utf-8"))
before = json.loads(real.decode("utf-8"))
rows = {r["키"]: r for r in saved["줄"]}
assert rows[AFRAME][meq.FIELD] == {"일운반회수": {"기호": "n"}}
assert {k: v for k, v in rows.items() if k != AFRAME} == {
r["키"]: r for r in before["줄"] if r["키"] != AFRAME
}
assert (REAL / file).read_bytes() == real # 정본 안 건드림
stale = client.post(
"/api/m01/logic/equation/save",
json=body | {"고침": edits, "version": "옛판", "개정확인": True},
)
assert stale.status_code == 409