feat(M01): 단가산출 로직 상세 차례 · 오른쪽 흐름도/원문 두 패널

- 좌측 차례 = 기본정보 › 시험 계산 › 단가산출 상세 › 텍스트 수식
- 오른쪽 패널 둘(흐름도 · 원문) — 하나만 열림 · 둘 다 접기 · 머리 토글 단추 · 열린 패널 계정 취향(m01-logic-panel)
- 흐름도 패널 접힘을 부르는 쪽이 정하는 길(folded · onFold · fold)
- 원문 패널 = 출처 글로 품셈 본문 절을 찾아 보임(GET /api/m01/logic/source) · 못 찾으면 「원문 연결 없음」 + 출처
- 시험 test_m01_logic_source.py · 화면 시나리오 ui_checks/44-4_로직상세_패널.js

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
This commit is contained in:
2026-10-05 11:31:09 +09:00
co-authored by Claude Opus 5.5
parent 576b12eb7d
commit 7d39f90a0e
11 changed files with 520 additions and 11 deletions
@@ -103,6 +103,79 @@ def sections(book: str, division: str = "", chapter: str = "", q: str = "", limi
return {"total": len(hits), "sections": hits[: min(max(limit, 1), 1000)]}
# ── 로직 원문(출처 글 → 품셈 본문 절) ──────────────────────────────────
_NUMBER = re.compile(r"(?<![\d-])\d+(?:-\d+)+(?![\d-])")
def _blocks(lines: list[str]) -> list[dict]:
"""md 줄 → 화면 조각 — 제목 {h} · 표 {table} · 글 {p} · 주석 줄은 뺌."""
out: list[dict] = []
for line in lines:
text = line.strip()
if not text or text.startswith("<!--"):
continue
if text.startswith("#"):
out.append({"h": len(text) - len(text.lstrip("#")), "text": text.lstrip("#").strip()})
elif text.startswith("|"):
cells = [c.strip() for c in text.strip("|").split("|")]
if all(re.fullmatch(r":?-{3,}:?", c) for c in cells):
continue # 머리 · 몸 가름 줄
if not out or "table" not in out[-1]:
out.append({"table": []})
out[-1]["table"].append(cells)
else:
out.append({"p": text})
return out
def _section(book: str, division: str, number: str) -> dict | None:
"""번호 절을 품셈 본문에서 찾음 — 없으면 끝 마디를 하나씩 떼며 위 절로(두 마디까지)."""
parts = number.split("-")
folders = [f for d, no, _t, f in _chapters(book) if d == division and int(no) == int(parts[0])]
for upto in range(len(parts), 1, -1):
want = "-".join(parts[:upto])
for folder in folders:
for md in sorted(folder.glob("*.md")):
lines = md.read_text(encoding="utf-8").split("\n")
for i, line in enumerate(lines):
m = _HEAD.match(line)
if not m or m.group(1) != want:
continue
level = len(line) - len(line.lstrip("#"))
end = next(
(
j
for j in range(i + 1, len(lines))
if _HEAD.match(lines[j])
and len(lines[j]) - len(lines[j].lstrip("#")) <= level
),
len(lines),
)
return {
"number": number,
"matched": want,
"file": md.relative_to(BODY.parent).as_posix(),
"blocks": _blocks(lines[i:end]),
}
return None
def source(src: str) -> dict:
"""로직 출처 글(예 「건설품셈 토목 1-1-2」 · 「산림품셈 3-4-2 1」)로 찾은 품셈 원문 절 —
번호가 여럿이면(「8-1-5 · 8-1-6」) 절마다 · 못 찾으면 found 빈 목록(화면 「원문 연결 없음」)."""
words = src.split()
book = words[0] if words else ""
if book not in BOOKS:
return {"source": src, "found": []}
division = (
next((d for d in cm.DIVISIONS if src.startswith(f"{book} {d} ")), "")
if book == "건설품셈"
else ""
)
found = [_section(book, division, n) for n in dict.fromkeys(_NUMBER.findall(src))]
return {"source": src, "found": [x for x in found if x]}
# ── 표 조건 값 ─────────────────────────────────────────────────────────
def table_options(file: str, key: str) -> dict:
"""표 조건 칸의 값만 — 표 몸을 통째로 받지 않으려는 자리(만들기 입력 제안)."""