"""건설 원문 PDF ↔ md **전수 커버리지** — 2026-09-18 브레인 B14(사용자 지시). 왜 — 지금껏 증상이 잡히는 대로 하나씩 찾아 고쳐 느렸다. **구멍 전체 크기를 한 번에** 드러내 목록으로 몰아 고치려고 둔 검사다. 무엇을 재나 — PDF 글자층에서 좌표로 줄을 다시 묶어 **값처럼 생긴 수**를 뽑고, md 표의 수와 양방향으로 맞댄다. ``` 결손 PDF 에 있는데 md 에 없음 ← 9020 해상기계처럼 표째 빠진 병 허구 md 에 있는데 PDF 에 없음 ← 잘못 지어 넣은 값 ``` ⚠ **줄 짝은 안 맞춘다** — PDF 는 칸마다 줄을 바꾸고 md 는 칸을 묶어 적어, 줄끼리 맞대면 멀쩡한 표도 어긋난 것으로 뜬다. **수의 갯수(중복 포함)** 로만 맞댄다. ⚠ **값처럼 생긴 수만 본다** — 소수점이 있거나 세 자리 이상. 쪽번호·차례 번호·항목 번호를 값으로 세면 셈이 노이즈에 묻힌다. ⚠ 차례(`···· 123`) 줄은 뺀다 — 쪽 번호가 값으로 섞인다. 지금 남은 것은 `fixtures/const_pdf_coverage_known.json` 에 **쪽별 셈**으로 적어 둔다. 줄이면 초록, 늘면 빨강 — 새 구멍이 생겼다는 뜻이다. 목록 갱신: `./venv/Scripts/python.exe resources/tester/test_const_pdf_coverage.py --prune` """ from __future__ import annotations import collections import json import re import sys from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[2] PDF = ROOT / "resources/knowledge/original/원가계산/건설공사_표준품셈/2026년_건설공사_표준품셈.pdf" MD = ROOT / "resources/knowledge/original/원가계산/건설공사_표준품셈/2026년_건설공사_표준품셈.md" KNOWN = Path(__file__).with_name("fixtures") / "const_pdf_coverage_known.json" _NUM = re.compile(r"\d[\d,]*(?:\.\d+)?") #: 차례 줄 — 점선 뒤 쪽번호가 값으로 섞인다. _LEADER = re.compile(r"[·․…\.]{4,}") #: 쪽 머리 — 「제8장 건설기계 219」. ⚠ **셈에서는 안 뺀다** — md 도 머리를 본문에 붙여 적어 #: 한쪽만 빼면 그만큼 허구로 뜬다(749/547 → 600/929 였다). 목록을 뽑을 때만 가린다. _PAGE_HEAD = re.compile(r"제\s*\d+\s*장|공통부문|토목부문|건축부문|기계설비부문|유지관리부문") #: 쪽번호 줄 — 수 하나만 있고 다른 글자가 없다. _ONLY_NUMBER = re.compile(r"\d[\d,]*(?:\.\d+)?") #: 한 줄로 묶을 때 세로 오차(포인트). _ROW_TOLERANCE = 2.5 def _norm(token: str) -> str: return token.replace(",", "") def _valuish(token: str) -> bool: """값처럼 생긴 수인가 — 소수점이 있거나 세 자리 이상. ⚠ 연도(`2026`·`1999`)는 뺀다 — 「'22년 보완」·발행연도가 값으로 섞인다. """ if re.fullmatch(r"(?:19|20)\d\d", token): return False return "." in token or len(token) >= 3 def _page_rows(page) -> list[str]: """글자 좌표로 **줄을 다시 묶는다** — PDF 글자층은 칸마다 줄을 바꾼다.""" words = sorted(page.get_text("words"), key=lambda w: (round(w[1] / _ROW_TOLERANCE), w[0])) rows: list[list] = [] current: list = [] top: float | None = None for word in words: if top is None or abs(word[1] - top) <= _ROW_TOLERANCE: current.append(word) top = word[1] if top is None else top else: rows.append(current) current, top = [word], word[1] if current: rows.append(current) return [" ".join(w[4] for w in sorted(row, key=lambda x: x[0])) for row in rows] def pdf_numbers() -> tuple[collections.Counter, dict[str, list[int]]]: """PDF 값 수의 셈과 **어느 쪽에서 나왔는지**.""" import pymupdf counts: collections.Counter = collections.Counter() pages: dict[str, list[int]] = collections.defaultdict(list) with pymupdf.open(PDF) as doc: for number, page in enumerate(doc, start=1): for line in _page_rows(page): if _LEADER.search(line): continue found = [_norm(x) for x in _NUM.findall(line) if _valuish(_norm(x))] # ⚠ **쪽번호 줄만 뺀다** — 줄에 수 하나뿐이고 다른 글자가 없는 줄이다. # 값 하나짜리 줄까지 빼면 여러 줄로 갈린 칸(`[80∼95]`)이 통째로 안 세어진다. if not found or _ONLY_NUMBER.fullmatch(line.strip()): continue for token in found: counts[token] += 1 pages[token].append(number) return counts, pages def md_numbers() -> collections.Counter: """md 전문의 값 수 — ⚠ **표만 보지 않는다.** md 는 PDF 를 옮겨 적은 글이라 본문·[주]까지 맞대야 「옮겨 적다 빠뜨린 것」이 드러난다(표만 보면 PDF 본문 쪽이 통째로 결손으로 뜬다).""" counts: collections.Counter = collections.Counter() for line in MD.read_text(encoding="utf-8").splitlines(): if _LEADER.search(line): continue found = [_norm(x) for x in _NUM.findall(line) if _valuish(_norm(x))] if not found or _ONLY_NUMBER.fullmatch(line.strip()): continue counts.update(found) return counts def measure() -> dict[str, object]: """`{결손 쪽별 셈, 허구 수, 총계}` — 양방향 구멍.""" pdf, pages = pdf_numbers() md = md_numbers() missing, invented = pdf - md, md - pdf per_page: collections.Counter = collections.Counter() for token, count in missing.items(): for page in pages[token][:count]: per_page[page] += 1 return { "결손_쪽별": {str(k): v for k, v in sorted(per_page.items())}, "결손_총": sum(missing.values()), "허구_총": sum(invented.values()), "허구_값": {k: v for k, v in sorted(invented.items())}, } @pytest.fixture(scope="module") def now() -> dict[str, object]: return measure() def _known() -> dict: return json.loads(KNOWN.read_text(encoding="utf-8")) def test_결손이_목록보다_늘지_않음(now: dict) -> None: """PDF 에 있는데 md 에 없는 값 — **줄면 초록, 늘면 빨강**(새 구멍).""" known = _known() grew = [ f" 쪽 {page}: 목록 {known['결손_쪽별'].get(page, 0)} → 지금 {count}" for page, count in now["결손_쪽별"].items() if count > known["결손_쪽별"].get(page, 0) ] assert not grew, "PDF 에만 있는 값이 늘었음 — md 에서 빠진 자리:\n" + "\n".join(grew) def test_허구가_목록보다_늘지_않음(now: dict) -> None: """md 에 있는데 PDF 에 없는 값 — 지어 넣은 자리.""" known = _known() grew = { token: count for token, count in now["허구_값"].items() if count > known["허구_값"].get(token, 0) } assert not grew, f"PDF 에 없는 값이 md 에 늘었음: {grew}" def test_목록이_원문과_같은_판(now: dict) -> None: """목록의 총계가 지금 셈과 같은가 — 줄었으면 `--prune` 으로 목록을 줄일 것.""" known = _known() assert now["결손_총"] <= known["결손_총"], ( f"결손 {known['결손_총']} → {now['결손_총']}" # 늘었다 ) assert now["허구_총"] <= known["허구_총"], f"허구 {known['허구_총']} → {now['허구_총']}" if __name__ == "__main__" and "--report" in sys.argv: # 구멍 목록 — md 를 고치는 창(코덱스·랩탑서브·안티그래비티)이 볼 자리 pdf_counts, pdf_pages = pdf_numbers() md_counts = md_numbers() missing, invented = pdf_counts - md_counts, md_counts - pdf_counts holes: collections.Counter = collections.Counter() for value, count in missing.items(): for page in pdf_pages[value][:count]: holes[page] += 1 print(f"결손 {sum(missing.values())} · 허구 {sum(invented.values())}") print("구멍 큰 쪽 스물") for page, count in holes.most_common(20): print(f" 쪽 {page:4d} {count}") print("PDF 에 없는 값 열(붙은 줄 잔재가 많다)") for value, count in invented.most_common(10): print(f" {value} x{count}") if __name__ == "__main__" and "--prune" in sys.argv: got = measure() before = _known() KNOWN.write_text(json.dumps(got, ensure_ascii=False, indent=1) + "\n", encoding="utf-8") print( f"결손 {before['결손_총']} → {got['결손_총']} · 허구 {before['허구_총']} → {got['허구_총']}" )