feat(M01): 50-2 원문 패널 — 마크다운 기호 벗김 · 출처 항 · 주 강조

- 서버 원문 조각에서 역슬래시 · 인용 머리 · 글 안 주석 벗김(원본 자료 손 안 댐)
- 출처 항 · 주 번호(3-4-2 1 · 9-7-1 주⑥)를 source() 답에 실음 — 그 조각 hit 표시
- 패널이 그 항을 옅은 바탕 + 막대로 강조 · 보이게 스크롤 · 머리에 「1항」 · 「주⑥」
- 시험 3 추가 · 화면 시나리오 50-2

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
This commit is contained in:
2026-10-05 18:42:15 +09:00
co-authored by Claude Sonnet 5.5
parent 3e572ef210
commit 2d954a14dd
5 changed files with 201 additions and 5 deletions
+62 -4
View File
@@ -104,7 +104,16 @@ def sections(book: str, division: str = "", chapter: str = "", q: str = "", limi
# ── 로직 원문(출처 글 → 품셈 본문 절) ──────────────────────────────────
_NUMBER = re.compile(r"(?<![\d-])\d+(?:-\d+)+(?![\d-])")
_NUMBER = re.compile(r"(?<![\d-])(\d+(?:-\d+)+)(?![\d-])(?:\s+(주[①-⑳]?|\d{1,2})(?![\d-]))?")
_ESCAPE = re.compile(r"\\([.\-*_()#\[\]>~`])") # 마크다운 이스케이프 `\.` `\-` …
_COMMENT = re.compile(r"<!--.*?-->") # 글 안 주석(글자층 표시) — 글자만 남김
_CIRCLED = "①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳"
def _plain(text: str) -> str:
"""원문 md 기호 벗김 — 주석 · 이스케이프 · 인용 머리 `> `."""
text = _ESCAPE.sub(r"\1", _COMMENT.sub("", text)).strip()
return text[1:].strip() if text.startswith(">") else text
def _blocks(lines: list[str]) -> list[dict]:
@@ -117,10 +126,15 @@ def _blocks(lines: list[str]) -> list[dict]:
if not text or text.startswith("<!--"):
joinable = False
continue
if not text.startswith(("#", "|")):
text = _plain(text)
if not text:
joinable = False
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("|")]
cells = [_plain(c) for c in text.strip("|").split("|")]
if all(re.fullmatch(r":?-{3,}:?", c) for c in cells):
continue # 머리 · 몸 가름 줄
if not joinable:
@@ -134,6 +148,44 @@ def _blocks(lines: list[str]) -> list[dict]:
return out
def _mark(blocks: list[dict], item: str) -> bool:
"""출처 항 · 주 번호(「1」 · 「주③」 · 「주」)에 드는 조각에 hit 표시 — 못 찾으면 False(절 통째만)."""
texts = [b.get("p") or b.get("text") or "" for b in blocks]
if item.startswith("주"):
note = next((i for i, t in enumerate(texts) if t.startswith("[주]")), None)
if note is None:
return False
start, stop = note, len(blocks)
if len(item) > 1: # 「주③」 — [주] 아래 ③ 줄 하나(다음 ① ~ ⑳ 줄 앞까지)
start = next(
(i for i in range(note + 1, len(texts)) if texts[i].startswith(item[1])), None
)
if start is None:
return False
stop = next(
(i for i in range(start + 1, len(texts)) if texts[i][:1] in _CIRCLED), len(blocks)
)
else: # 「1」 — 「1. …」 제목 · 줄 ~ 다음 제목 · 「2. …」 앞(줄이면 [주] 앞도)
head = re.compile(rf"{item}[.)]\s")
start = next((i for i, t in enumerate(texts) if head.match(t)), None)
if start is None:
return False
under = "h" in blocks[start]
stop = next(
(
i
for i in range(start + 1, len(blocks))
if "h" in blocks[i]
or (not under and re.match(r"\d+[.)]\s|\[주\]", texts[i]))
or (under and re.match(r"\d+[.)]\s", texts[i]) and "p" in blocks[i])
),
len(blocks),
)
for b in blocks[start:stop]:
b["hit"] = True
return True
def _section(book: str, division: str, number: str) -> dict | None:
"""번호 절을 품셈 본문에서 찾음 — 없으면 끝 마디를 하나씩 떼며 위 절로(두 마디까지)."""
parts = number.split("-")
@@ -178,8 +230,14 @@ def source(src: str) -> dict:
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]}
found = []
for number, item in dict.fromkeys(_NUMBER.findall(src)):
one = _section(book, division, number)
if one:
if item and _mark(one["blocks"], item):
one["item"] = item
found.append(one)
return {"source": src, "found": found}
# ── 표 조건 값 ─────────────────────────────────────────────────────────