Files
eomsangdonandClaude Opus 5.5 2376e70fbb revert(802-1): 801-1 wip 옛 나무 위 고침 걷음 — 요구 664 수식 문서로 다시 짬
- 걷음: 43784a4c0 의 옛 수식 나무 위 고침(서버 · 화면 · css) = 그 앞 판으로 되돌림
- 남김: master_equation_sign.py · ui_template_equation_text.ts · ui_template_equation_edit.ts(다시 쓸 조각 · 지금 안 부름) · text 는 `입력글` 을 제 타입에
- 지움: test_801_1_equation_inline.py — 걷은 옛 나무 고침을 재는 시험

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:55:58 +09:00

504 lines
21 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.
# -*- coding: utf-8 -*-
"""수식 고침 → 로직(PLAN 796-1 · 요구 657) — 나무 · 키보드 한 줄 식 → 엔진 식 · 변수 추가 · 삭제 ·
이름 · 기호 · 첨자 바꿈 · 표시 자리 · 식 검사.
로직 줄(dict) 한 벌만 고침 — 저장은 부르는 쪽(M01 = `/save` · M02 사본 = 795-1 사본 길)이 함.
고침 요청 `고침` = 목록 · 차례대로:
{할일: 식, 자리: 중간:<열쇠> | 결과 | 호표:<n> | 덧줄:<n>, 식: 글 | 나무: 나무}
{할일: 더함, 갈래: 입력 | 중간, 열쇠, 이름?, 기호?, 위?, 아래?, 단위?, 설명?, 기본값?(입력),
식(중간), 뒤?(중간 열쇠 · "" = 맨 위 · 없으면 끝)}
{할일: 지움, 열쇠} — 쓰는 식이 있으면 막고 쓰는 곳 목록
{할일: 변수, 열쇠, 이름?, 기호?, 위?, 아래?, 단위?} — 식은 열쇠로 부르니 안 깨짐
{할일: 표시, 열쇠, 표시: 0~6 | null}
{할일: 연결, 열쇠, 갈래: 입력|표|요소|로직|상수, …} — 새 기호의 연결 지정(`master_equation_link.py` 머리)
고친 중간 줄은 `표시` 없으면 `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_equation_link as link
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 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
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)
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:
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