Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_7

This commit is contained in:
2026-10-05 18:48:15 +09:00
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}
# ── 표 조건 값 ─────────────────────────────────────────────────────────
@@ -55,3 +55,9 @@
.m01lab-src__table td {
white-space: pre;
}
/* 출처가 가리킨 항 · 주 — 옅은 바탕 + 왼쪽 막대 */
.m01lab-src__hit {
background: color-mix(in srgb, var(--color-accent) 14%, transparent);
box-shadow: -3px 0 0 var(--color-accent);
}
@@ -26,15 +26,24 @@ interface Block {
text?: string;
p?: string;
table?: string[][];
/** 출처가 가리킨 항 · 주 — 강조 */
hit?: boolean;
}
interface Found {
number: string;
matched: string;
file: string;
blocks: Block[];
item?: string;
}
function blockNode(b: Block): HTMLElement {
const node = rawNode(b);
if (b.hit) node.classList.add("m01lab-src__hit");
return node;
}
function rawNode(b: Block): HTMLElement {
if (b.table)
return el("div", {
className: "m01-logic__scroll",
@@ -89,11 +98,20 @@ export function createSourceView(): SourceView {
...found.flatMap((f) => [
el("p", {
className: "m01-logic__muted m01lab-src__file",
text: f.matched === f.number ? f.file : `${f.file} · ${ts("Near", f.number)}`,
text: [
f.file,
f.matched === f.number ? "" : ts("Near", f.number),
f.item ? (/^\d+$/.test(f.item) ? `${f.item}항` : f.item) : "",
]
.filter(Boolean)
.join(" · "),
}),
...f.blocks.map(blockNode),
]),
);
requestAnimationFrame(() =>
body.querySelector(".m01lab-src__hit")?.scrollIntoView({ block: "center" }),
);
});
};
+43
View File
@@ -61,3 +61,46 @@ def test_tables_split_and_br() -> None:
tables = [b["table"] for b in one["blocks"] if "table" in b]
assert [len(t[0]) for t in tables] == [7, 2]
assert tables[0][2][0] == "인력운반\n타설"
def test_markdown_stripped() -> None:
"""역슬래시 · 인용 `>` 머리 · 글 안 주석을 벗김(50-2) — 모든 정본 출처 원문에 0."""
bad = 0
for path in store.FOLDER.glob("로직_*품셈_*.json"):
for row in json.loads(path.read_text(encoding="utf-8"))["줄"]:
for one in make.source(row.get("출처", ""))["found"]:
for b in one["blocks"]:
t = b.get("p") or b.get("text") or ""
bad += "\\" in t or "<!--" in t or t.startswith(">")
assert bad == 0
one = make.source("산림품셈 3-4-2 1")["found"][0]
assert any(b.get("p") == "Q = 3,600 × q × K × f × E / Cm" for b in one["blocks"])
def test_item_marked() -> None:
"""출처 항 · 주 번호 → 그 조각만 hit(절은 통째) · 못 찾으면 item 없음."""
def hits(src: str) -> list[str]:
one = make.source(src)["found"][0]
return [(b.get("p") or b.get("text") or "table")[:2] for b in one["blocks"] if b.get("hit")]
one = make.source("산림품셈 3-4-2 1")["found"][0]
assert one["item"] == "1" and one["blocks"][0].get("hit") is None
assert not any(b.get("hit") for b in one["blocks"] if b.get("p", "").startswith("2."))
assert hits("산림품셈 3-4-2 1")[0] == "1."
assert hits("산림품셈 9-7-1 주⑥") == ["⑥ "]
assert make.source("산림품셈 13-10-2 2")["found"][0]["item"] == "2"
assert "item" not in make.source("산림품셈 3-4-2")["found"][0]
def test_item_all_marked() -> None:
"""항 · 주 번호가 달린 정본 출처는 전부 표시가 붙음(전수)."""
total = missing = 0
for path in store.FOLDER.glob("로직_*품셈_*.json"):
for row in json.loads(path.read_text(encoding="utf-8"))["줄"]:
src = row.get("출처", "")
if not any(i for _n, i in make._NUMBER.findall(src)):
continue
total += 1
missing += not any(f.get("item") for f in make.source(src)["found"])
assert total >= 30 and missing == 0, (total, missing)
@@ -0,0 +1,71 @@
/* 50-2 원문 패널 — 마크다운 기호 벗김 · 출처 항 · 주 강조 · `orca eval --expression "$(cat 이 파일)"` 한 번에 돎 · JSON 돌려줌.
*
* 시작 화면: M01 마스터 데이터(#/m01-master-data · 시스템 관리자 로그인) · 아무 화면이나.
* 로직 GF000003(산림품셈 3-4-2 1) · GF000094(산림품셈 9-7-1 주⑥)을 엶 · 고치지 않음 · 패널 취향을 처음 값으로 되돌림.
* 기대 수치:
* item1 = {head:"3-4-2. …", file:"1항", hit:12, first:"1. 단가산출 방법은 …", nextNotHit:true, bad:0(역슬래시 · 「<!--」 · 「>」 머리 글 수), inView:true}
* note6 = {file:"주⑥", hit:1, first:"⑥ 굴착기의 작업능력 …", nextNotHit:true, bad:0, inView:true}
*/
(async () => {
const W = (ms) => new Promise((r) => setTimeout(r, ms));
const $ = (s, root = document) => root.querySelector(s);
const $$ = (s, root = document) => [...root.querySelectorAll(s)];
const until = async (f, ms = 15000) => {
for (let t = 0; t < ms; t += 200) {
const v = f();
if (v) return v;
await W(200);
}
return null;
};
const PREF = "aislo:pref:m01-logic-side";
const first = localStorage.getItem(PREF);
const out = {};
const openLogic = async (key) => {
sessionStorage.setItem("m01.logic.pending", key);
const tab = $$(".ui-collapsible__title").find((h) => h.innerText.trim() === "단가산출 로직");
if (!tab) return false;
if (!tab.closest(".ui-collapsible").classList.contains("is-collapsed")) tab.click();
tab.click();
return until(() => {
const t = $(".m02-dfp__title")?.innerText ?? "";
return !$(".m01lab-side")?.hidden && $(".m01lab-side .m02-dfp") && t;
});
};
const shown = (n) => !!n && n.getBoundingClientRect().width > 0;
const panel = (id) => $(`.m01lab-side .ui-side-panels__panel[data-panel="${id}"]`);
const strip = (title) =>
$$(".m01lab-side .ui-side-panels__handles > *").find(
(s) => s.innerText.replace(/\s/g, "") === title,
);
const inView = (n) => {
const r = n.getBoundingClientRect();
return r.top >= 0 && r.bottom <= innerHeight && r.height > 0;
};
const read = async (key, wantFile) => {
if (!(await openLogic(key))) return { error: "로직 못 엶" };
await W(600);
if (!shown(panel("source"))) strip("원문")?.click();
await until(() => $$(".m01lab-src__file").some((f) => f.innerText.includes(wantFile)), 6000);
await W(600);
const ps = $$(".m01lab-src__body > *");
const hits = $$(".m01lab-src__hit");
const texts = $$(".m01lab-src__p, .m01lab-src__head").map((n) => n.innerText);
return {
head: $(".m01lab-src__head")?.innerText,
file: $(".m01lab-src__file")?.innerText.split(" · ").pop(),
hit: hits.length,
first: hits[0]?.innerText.slice(0, 20),
nextNotHit: !hits.includes(ps[ps.indexOf(hits[hits.length - 1]) + 1]),
bad: texts.filter((t) => /\|<!--|^>/.test(t)).length,
inView: hits.length > 0 && inView(hits[0]),
};
};
if (!(await openLogic("GF000003"))) return JSON.stringify({ error: "로직 못 엶" });
out.item1 = await read("GF000003", "1항");
out.note6 = await read("GF000094", "주⑥");
// 되돌림 — 처음 취향
if (first === null) localStorage.removeItem(PREF);
else localStorage.setItem(PREF, first);
return JSON.stringify(out);
})();